diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index d29be7a4..841e2f31 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -9,11 +9,13 @@ mod dock_model; mod model; mod surface; mod targets; +mod workspace; pub use dock_model::{Column, ColumnLocation, Group, GroupLocation, PanelState, Side, Target}; pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone}; pub use surface::{DockArea, DockContent, DockOutput, DockStyle, PanelLimits, begin_drag, cancel_drag}; pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, zone_rect}; +pub use workspace::Workspace; #[cfg(test)] mod tests; diff --git a/crates/ui/src/docking/workspace.rs b/crates/ui/src/docking/workspace.rs new file mode 100644 index 00000000..90fe8d6e --- /dev/null +++ b/crates/ui/src/docking/workspace.rs @@ -0,0 +1,243 @@ +//! A saved arrangement: the current layout, where hidden panels go back, the user's saved +//! arrangement for Reset, and Lock Workspace. +//! +//! Storage, file names and size caps stay with the application. A file that does not parse or +//! validate, or comes from a newer version, should be kept on disk and not overwritten. + +use std::{collections::HashSet, hash::Hash}; + +use super::model::{Action, DockError, Layout, Location, MAX_PANELS, Permissions}; + +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(default, bound(deserialize = "P: serde::Deserialize<'de>")))] +pub struct Workspace

{ + /// Format version; files without one are v1 (`{layout, hidden}`). Always written as + /// [`Workspace::VERSION`]. + #[cfg_attr(feature = "serde", serde(default = "first_version", serialize_with = "write_version"))] + pub version: u32, + /// The current arrangement, changes included. + pub layout: Layout

, + /// Closed panels, and panels the application does not register now, with where each one + /// goes back. + pub hidden: Vec<(P, Location

)>, + /// A user workspace's saved arrangement; Reset returns to it. Built-in workspaces use the + /// application's default instead. + pub saved: Option>, + /// Lock Workspace, saved with this workspace: [`Self::apply`] uses + /// [`Permissions::locked`], so moving, floating and closing are rejected while activating, + /// opening, minimizing and resizing still work. An application with a global lock + /// preference instead passes locked permissions itself. + pub locked: bool, +} + +#[cfg(feature = "serde")] +fn first_version() -> u32 { + 1 +} + +#[cfg(feature = "serde")] +fn write_version(_: &u32, serializer: S) -> Result { + serializer.serialize_u32(Workspace::<()>::VERSION) +} + +impl

Default for Workspace

{ + fn default() -> Self { + Self::new(Layout::default()) + } +} + +impl

Workspace

{ + pub const VERSION: u32 = 2; + + pub fn new(layout: Layout

) -> Self { + Self { version: Self::VERSION, layout, hidden: Vec::new(), saved: None, locked: false } + } +} + +impl Workspace

{ + /// Check the version, both layouts and that every hidden panel is hidden once and absent + /// from the current layout. + pub fn validate(&self) -> Result<(), DockError> { + if self.version > Self::VERSION { + return Err(DockError::UnsupportedVersion); + } + self.layout.validate()?; + if let Some(saved) = &self.saved { + saved.validate()?; + } + if self.hidden.len() > MAX_PANELS { + return Err(DockError::LimitExceeded); + } + let visible: HashSet<&P> = self.layout.panels().into_iter().collect(); + let mut seen = HashSet::new(); + for (panel, _) in &self.hidden { + if visible.contains(panel) || !seen.insert(panel) { + return Err(DockError::DuplicatePanel); + } + } + Ok(()) + } + + /// Apply one action. Closed panels record where they go back, so each one can be reopened + /// with [`Self::set_visible`]. Honours [`Self::locked`]. [`Layout::closed_by`], called + /// before applying, names the panels a close action removes. + pub fn apply(&mut self, action: Action

, permissions: impl Fn(&P) -> Permissions) -> Result<(), DockError> { + self.apply_all([action], permissions) + } + + /// Apply every action in order, or none. + pub fn apply_all(&mut self, actions: impl IntoIterator>, permissions: impl Fn(&P) -> Permissions) -> Result<(), DockError> { + self.validate()?; + let locked = self.locked; + let policy = |panel: &P| if locked { permissions(panel).locked() } else { permissions(panel) }; + let mut proposed = self.clone(); + for action in actions { + let closed = proposed.layout.closed_by(&action)?; + let locations = close_locations(&proposed.layout, &closed); + proposed.layout.apply_with_permissions(action, policy)?; + for (panel, location) in locations { + proposed.hidden.retain(|(p, _)| p != &panel); + proposed.hidden.push((panel, location)); + } + } + let visible: HashSet

= proposed.layout.panels().into_iter().cloned().collect(); + proposed.hidden.retain(|(p, _)| !visible.contains(p)); + proposed.validate()?; + *self = proposed; + Ok(()) + } + + /// Show or hide one panel. Showing restores its recorded location, following the chain of + /// hidden anchors when its own anchor is hidden too; otherwise it opens beside `fallback`. + /// A shown panel becomes the active tab of its group. + pub fn set_visible(&mut self, panel: P, visible: bool, fallback: Option

) -> Result<(), DockError> { + self.validate()?; + if !visible { + if !self.layout.contains(&panel) { + return Ok(()); + } + return self.apply(Action::Close { panel }, |_| Permissions::default()); + } + if self.layout.contains(&panel) { + return self.apply(Action::Activate { panel }, |_| Permissions::default()); + } + let mut layout = self.layout.clone(); + if self.restore_into(&mut layout, &panel).is_err() { + layout = self.layout.clone(); + layout.apply(Action::Open { panel: panel.clone(), anchor: fallback })?; + } + layout.apply(Action::Activate { panel: panel.clone() })?; + self.layout = layout; + self.hidden.retain(|(p, _)| p != &panel); + Ok(()) + } + + fn restore_into(&self, layout: &mut Layout

, panel: &P) -> Result<(), DockError> { + let find = |p: &P| self.hidden.iter().find(|(q, _)| q == p).map(|(_, location)| location); + let mut location = find(panel).ok_or(DockError::MissingTarget)?; + for _ in 0..=self.hidden.len() { + match layout.restore(panel.clone(), location) { + Err(DockError::MissingTarget) => { + let next = location.anchor.as_ref().filter(|anchor| *anchor != panel).and_then(find); + location = next.ok_or(DockError::MissingTarget)?; + } + result => return result, + } + } + Err(DockError::MissingTarget) + } + + /// Fit a loaded workspace to the panels the application registers now. An invalid + /// workspace is replaced by `default`. Panels not registered now (plug-ins, on-demand or + /// numbered panels) move to `hidden` with their location, so they come back once registered; + /// the saved arrangement drops them. Registered panels in `default` that are neither shown + /// nor hidden go to their default place, or to `hidden` when that place cannot be rebuilt. + /// `hidden` keeps at most [`MAX_PANELS`] entries, dropping the oldest unregistered ones first. + pub fn reconcile(&mut self, registered: impl Fn(&P) -> bool, default: &Workspace

) { + if self.validate().is_err() { + *self = Workspace { locked: self.locked, ..default.clone() }; + } + let unknown: Vec

= self.layout.panels().into_iter().filter(|p| !registered(p)).cloned().collect(); + let locations = close_locations(&self.layout, &unknown); + for panel in &unknown { + self.layout.take(panel); + } + self.hidden.extend(locations); + if let Some(saved) = self.saved.as_mut() { + let unknown: Vec

= saved.panels().into_iter().filter(|p| !registered(p)).cloned().collect(); + for panel in unknown { + saved.take(&panel); + } + if saved.validate().is_err() { + self.saved = None; + } + } + let known: HashSet

= self.layout.panels().into_iter().chain(self.hidden.iter().map(|(p, _)| p)).cloned().collect(); + let mut missing: Vec<(P, Location

)> = Vec::new(); + for panel in default.layout.panels() { + if registered(panel) + && !known.contains(panel) + && let Ok(location) = default.layout.location(panel) + { + missing.push((panel.clone(), location)); + } + } + // Anchors may themselves be missing: place what fits, then retry the rest. + while !missing.is_empty() { + let before = missing.len(); + missing.retain(|(panel, location)| self.layout.restore(panel.clone(), location).is_err()); + if missing.len() == before { + break; + } + } + self.hidden.extend(missing); + for (panel, location) in &default.hidden { + if registered(panel) && !known.contains(panel) && !self.layout.contains(panel) && !self.hidden.iter().any(|(p, _)| p == panel) { + self.hidden.push((panel.clone(), location.clone())); + } + } + let mut excess = self.hidden.len().saturating_sub(MAX_PANELS); + self.hidden.retain(|(p, _)| { + let drop = excess > 0 && !registered(p); + excess -= usize::from(drop); + !drop + }); + self.hidden.truncate(MAX_PANELS); + } + + /// Reset: return to the saved arrangement, or `default` when there is none. Panels the + /// reset layout does not show stay hidden with their last location. + pub fn reset(&mut self, default: &Layout

) { + let layout = self.saved.clone().unwrap_or_else(|| default.clone()); + let shown: HashSet

= layout.panels().into_iter().cloned().collect(); + for panel in self.layout.panels() { + if !shown.contains(panel) + && !self.hidden.iter().any(|(p, _)| p == panel) + && let Ok(location) = self.layout.location(panel) + { + self.hidden.push((panel.clone(), location)); + } + } + self.hidden.retain(|(p, _)| !shown.contains(p)); + self.layout = layout; + } +} + +/// Where each of `closed` goes back, captured as if they closed last to first. Reopening them +/// first to last rebuilds the group: the first restores the group itself, the rest join it. +fn close_locations(layout: &Layout

, closed: &[P]) -> Vec<(P, Location

)> { + if let [panel] = closed { + return layout.location(panel).map(|location| vec![(panel.clone(), location)]).unwrap_or_default(); + } + let mut scratch = layout.clone(); + let mut locations = Vec::new(); + for panel in closed.iter().rev() { + if let Ok(location) = scratch.location(panel) { + locations.push((panel.clone(), location)); + } + scratch.take(panel); + } + locations.reverse(); + locations +}