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