mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 04:37:29 -06:00
460 lines
20 KiB
Rust
460 lines
20 KiB
Rust
//! Saved preferences at the file boundary: docking data this version cannot read is dropped
|
|
//! without resetting anything else, and layouts saved before the canvas joined the panel layout
|
|
//! are converted.
|
|
//!
|
|
//! Those older files kept the dock as a tree of the right dock's panels only (`docking`, or the
|
|
//! tabbed Properties · Layers · Libraries group when there was none) and described the rest in
|
|
//! fields of their own: `floating_panels` (floating groups and sets, collapsed groups, sets
|
|
//! docked as columns), `docking_icons` (panels dropped on the icon column), `docking_collapsed`
|
|
//! (dock groups collapsed to their tabs), `dock_collapsed` and `dock_width`. [`from_legacy`]
|
|
//! turns them into one layout; the legacy fields are read, never written.
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
use craft_ui::docking::{Column, Floating, Group, Layout, Location, Node};
|
|
use serde_json::{Map, Value};
|
|
|
|
use crate::panel_docking::{self as docking, CANVAS, COLUMN_MAX, COLUMN_WIDTH, ICON_WIDTH, Placed};
|
|
use crate::state::{DockTab, FloatingPanels, ICON_PANEL_GROUPS, ICON_PANELS, UiState};
|
|
use crate::workspaces::Workspace;
|
|
|
|
/// Saved preferences, and whether reading them discarded anything.
|
|
pub struct ReadPreferences {
|
|
pub ui: UiState,
|
|
/// False when unreadable docking data, or a layout that could not be converted, was dropped:
|
|
/// the caller keeps a copy of the file before saving over it.
|
|
pub intact: bool,
|
|
/// True when a layout saved before the canvas joined the panel layout was converted: the
|
|
/// caller keeps a copy of the file as it was.
|
|
pub converted: bool,
|
|
}
|
|
|
|
/// Floating groups read from one legacy list; more are dropped.
|
|
const MAX_LEGACY_GROUPS: usize = 256;
|
|
/// Size of a floating window that saved none (older files saved only its position).
|
|
const FLOAT_WIDTH: f32 = 300.0;
|
|
const FLOAT_HEIGHT: f32 = 400.0;
|
|
const FLOAT_MIN: f32 = 200.0;
|
|
/// The tallest a converted floating set starts.
|
|
const STACK_MAX: f32 = 720.0;
|
|
|
|
impl UiState {
|
|
/// Read saved preferences. None when the file is not a preferences file at all; unreadable
|
|
/// docking data alone does not reset the other settings or the saved workspaces.
|
|
pub fn read_preferences(bytes: &[u8]) -> Option<ReadPreferences> {
|
|
let mut saved: Value = serde_json::from_slice(bytes).ok()?;
|
|
let fields = saved.as_object_mut()?;
|
|
let mut intact = recover(fields, Keys::SNAKE);
|
|
for key in ["custom_workspaces", "workspace_changes"] {
|
|
if let Some(Value::Array(workspaces)) = fields.get_mut(key) {
|
|
for workspace in workspaces.iter_mut().filter_map(Value::as_object_mut) {
|
|
intact &= recover(workspace, Keys::CAMEL);
|
|
}
|
|
}
|
|
}
|
|
let mut ui: UiState = serde_json::from_value(saved).ok()?;
|
|
let outcome = migrate_ui(&mut ui);
|
|
Some(ReadPreferences { ui, intact: intact && outcome.intact, converted: outcome.converted })
|
|
}
|
|
}
|
|
|
|
/// The JSON keys of the docking fields: `UiState` is snake_case, [`Workspace`] camelCase.
|
|
struct Keys {
|
|
hidden: &'static str,
|
|
floating: &'static str,
|
|
icons: &'static str,
|
|
collapsed: &'static str,
|
|
}
|
|
|
|
impl Keys {
|
|
const SNAKE: Keys = Keys { hidden: "docking_hidden", floating: "floating_panels", icons: "docking_icons", collapsed: "docking_collapsed" };
|
|
const CAMEL: Keys = Keys { hidden: "dockingHidden", floating: "floatingPanels", icons: "dockingIcons", collapsed: "dockingCollapsed" };
|
|
}
|
|
|
|
/// Drop the docking data of one saved state that this version cannot read: the layout, each
|
|
/// hidden place and floating group that fails, and legacy panel sets of another type. True
|
|
/// when nothing was dropped.
|
|
fn recover(fields: &mut Map<String, Value>, keys: Keys) -> bool {
|
|
fn readable<T: serde::de::DeserializeOwned>(value: &Value) -> bool {
|
|
serde_json::from_value::<T>(value.clone()).is_ok()
|
|
}
|
|
let mut intact = true;
|
|
if fields.get("docking").is_some_and(|v| !readable::<Option<Layout<String>>>(v)) {
|
|
fields.remove("docking");
|
|
intact = false;
|
|
}
|
|
match fields.get_mut(keys.hidden) {
|
|
Some(Value::Object(entries)) => {
|
|
let before = entries.len();
|
|
entries.retain(|_, location| readable::<Location<String>>(location));
|
|
intact &= entries.len() == before;
|
|
}
|
|
Some(Value::Null) => {
|
|
fields.remove(keys.hidden);
|
|
}
|
|
None => {}
|
|
Some(_) => {
|
|
fields.remove(keys.hidden);
|
|
intact = false;
|
|
}
|
|
}
|
|
match fields.get_mut(keys.floating) {
|
|
Some(Value::Array(groups)) => {
|
|
let before = groups.len();
|
|
groups.retain(readable::<FloatingPanels>);
|
|
intact &= groups.len() == before;
|
|
}
|
|
Some(Value::Null) => {
|
|
fields.remove(keys.floating);
|
|
}
|
|
None => {}
|
|
Some(_) => {
|
|
fields.remove(keys.floating);
|
|
intact = false;
|
|
}
|
|
}
|
|
for key in [keys.icons, keys.collapsed] {
|
|
if fields.get(key).is_some_and(|v| !readable::<BTreeSet<String>>(v)) {
|
|
fields.remove(key);
|
|
intact = false;
|
|
}
|
|
}
|
|
intact
|
|
}
|
|
|
|
/// Closed panels with the place each goes back to.
|
|
type Hidden = BTreeMap<String, Location<String>>;
|
|
|
|
/// The docking fields of a `UiState` or a [`Workspace`].
|
|
pub(crate) struct Fields<'a> {
|
|
pub docking: &'a mut Option<Layout<String>>,
|
|
pub hidden: &'a mut BTreeMap<String, Location<String>>,
|
|
pub floating: &'a mut Vec<FloatingPanels>,
|
|
pub icons: &'a mut BTreeSet<String>,
|
|
pub collapsed: &'a mut BTreeSet<String>,
|
|
pub dock_tab: DockTab,
|
|
pub dock_tab_order: &'a [DockTab],
|
|
pub dock_collapsed: bool,
|
|
pub dock_width: Option<f32>,
|
|
}
|
|
|
|
impl<'a> Fields<'a> {
|
|
pub(crate) fn of_ui(ui: &'a mut UiState) -> Self {
|
|
Fields {
|
|
docking: &mut ui.docking,
|
|
hidden: &mut ui.docking_hidden,
|
|
floating: &mut ui.floating_panels,
|
|
icons: &mut ui.docking_icons,
|
|
collapsed: &mut ui.docking_collapsed,
|
|
dock_tab: ui.dock_tab,
|
|
dock_tab_order: &ui.dock_tab_order,
|
|
dock_collapsed: ui.dock_collapsed,
|
|
dock_width: ui.dock_width,
|
|
}
|
|
}
|
|
|
|
/// A workspace's fields; workspaces keep no tab order of their own.
|
|
pub(crate) fn of_workspace(w: &'a mut Workspace, dock_tab_order: &'a [DockTab]) -> Self {
|
|
Fields {
|
|
docking: &mut w.docking,
|
|
hidden: &mut w.docking_hidden,
|
|
floating: &mut w.floating_panels,
|
|
icons: &mut w.docking_icons,
|
|
collapsed: &mut w.docking_collapsed,
|
|
dock_tab: w.dock_tab,
|
|
dock_tab_order,
|
|
dock_collapsed: w.dock_collapsed,
|
|
dock_width: w.dock_width,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What converting saved layouts did.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub(crate) struct Outcome {
|
|
/// False when a layout was dropped.
|
|
pub intact: bool,
|
|
/// True when a legacy layout was converted (or dropped trying).
|
|
pub converted: bool,
|
|
}
|
|
|
|
impl Outcome {
|
|
fn and(self, other: Outcome) -> Outcome {
|
|
Outcome { intact: self.intact && other.intact, converted: self.converted || other.converted }
|
|
}
|
|
}
|
|
|
|
/// Convert the layouts of `ui`, its saved workspaces and the changes kept for each workspace.
|
|
pub(crate) fn migrate_ui(ui: &mut UiState) -> Outcome {
|
|
let order = ui.dock_tab_order.clone();
|
|
let mut outcome = migrate(Fields::of_ui(ui));
|
|
for w in ui.custom_workspaces.iter_mut().chain(ui.workspace_changes.iter_mut()) {
|
|
outcome = outcome.and(migrate(Fields::of_workspace(w, &order)));
|
|
}
|
|
outcome
|
|
}
|
|
|
|
/// Convert one saved state's legacy layout in place and clear its legacy fields. A state with
|
|
/// no legacy data is left alone: without a layout, its flags still describe its default one.
|
|
/// Not intact when a layout was dropped: one this version cannot use, or a conversion that
|
|
/// failed (the state then falls back to its default layout).
|
|
pub(crate) fn migrate(f: Fields<'_>) -> Outcome {
|
|
let current = f.docking.as_ref().is_some_and(has_canvas);
|
|
let legacy = !current && (f.docking.is_some() || !f.floating.is_empty() || !f.icons.is_empty() || !f.collapsed.is_empty());
|
|
let mut intact = true;
|
|
if current {
|
|
intact = f.docking.as_ref().is_some_and(docking::valid);
|
|
} else if legacy {
|
|
match from_legacy(&f) {
|
|
Some((layout, hidden)) => {
|
|
*f.docking = Some(layout);
|
|
*f.hidden = hidden;
|
|
}
|
|
None => {
|
|
*f.docking = None;
|
|
f.hidden.clear();
|
|
intact = false;
|
|
}
|
|
}
|
|
}
|
|
f.floating.clear();
|
|
f.icons.clear();
|
|
f.collapsed.clear();
|
|
Outcome { intact, converted: legacy }
|
|
}
|
|
|
|
/// The layout older versions described with `f`, and the hidden places that still apply. The
|
|
/// dock, from the window edge inwards: the old dock tree's columns (its groups stacked top to
|
|
/// bottom; a horizontal split puts its parts side by side), the icon column, then the floating
|
|
/// sets docked as columns. Old split ratios are not kept. None when the result is not a valid
|
|
/// layout.
|
|
pub(crate) fn from_legacy(f: &Fields<'_>) -> Option<(Layout<String>, Hidden)> {
|
|
let old = f.docking.as_ref();
|
|
let mut placed = Placed::default();
|
|
let groups: Vec<&FloatingPanels> = f.floating.iter().take(MAX_LEGACY_GROUPS).collect();
|
|
|
|
// Floating groups and sets first: the old dock tree never held a floating panel.
|
|
let mut floating = Vec::new();
|
|
let mut docked: Vec<(u32, Column<String>)> = Vec::new();
|
|
let mut sets: Vec<u32> = Vec::new();
|
|
for (k, g) in groups.iter().enumerate() {
|
|
if let Some(set) = g.column {
|
|
if sets.contains(&set) {
|
|
continue;
|
|
}
|
|
sets.push(set);
|
|
}
|
|
let members: Vec<&FloatingPanels> = match g.column {
|
|
Some(set) => groups.iter().skip(k).filter(|o| o.column == Some(set)).copied().collect(),
|
|
None => vec![*g],
|
|
};
|
|
let kept: Vec<(Group<String>, f32)> = members
|
|
.iter()
|
|
.filter_map(|m| {
|
|
let mut group = take_group(&mut placed, &m.panels, m.active)?;
|
|
group.minimized = m.collapsed;
|
|
let height = old_height(old, &group.panels);
|
|
Some((group, height))
|
|
})
|
|
.collect();
|
|
if kept.is_empty() {
|
|
continue;
|
|
}
|
|
let width = g.width.filter(|w| w.is_finite()).map_or(FLOAT_WIDTH, |w| w.clamp(FLOAT_MIN, COLUMN_MAX));
|
|
match g.docked {
|
|
Some(n) => {
|
|
let groups = kept.into_iter().map(|(group, _)| group);
|
|
match docked.iter_mut().find(|(m, _)| *m == n) {
|
|
Some((_, column)) => column.groups.extend(groups),
|
|
None => docked.push((n, Column::new(groups.collect(), width))),
|
|
}
|
|
}
|
|
None => {
|
|
let at = finite_pos(g.pos);
|
|
let all_minimized = kept.iter().all(|(group, _)| group.minimized);
|
|
let height = kept.iter().map(|(_, h)| *h).sum::<f32>().min(STACK_MAX);
|
|
let rect = [at[0], at[1], width, height];
|
|
// A floating window's groups have no minimized state of their own: a set is
|
|
// minimized when all its groups were collapsed, else every group shows. Their
|
|
// heights become the set's split ratios.
|
|
let window = match kept.as_slice() {
|
|
[(group, _)] => Some(Floating::single(group.panels.clone(), group.active, rect)),
|
|
_ => Floating::stack(kept.into_iter().map(|(group, height)| Group { height: Some(height), ..group }).collect(), rect),
|
|
};
|
|
if let Some(mut window) = window {
|
|
window.minimized = all_minimized;
|
|
floating.push(window);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Windows of the old layout that the floating groups did not list.
|
|
for window in old.map(|l| l.floating.as_slice()).unwrap_or_default() {
|
|
if let Some(root) = unplaced_tree(&window.root, 0, &mut placed) {
|
|
floating.push(Floating { minimized: window.minimized, ..Floating::new(root, usable_rect(window.rect)) });
|
|
}
|
|
}
|
|
|
|
// The dock tree, or the tabbed group of a file from before there was one.
|
|
let tree_columns: Vec<Vec<Group<String>>> = match old {
|
|
Some(layout) => {
|
|
let mut reading = layout.root.as_ref().map(|root| tree_columns(root, 0, &mut placed)).unwrap_or_default();
|
|
reading.reverse();
|
|
reading
|
|
}
|
|
None => {
|
|
let tabs: Vec<&str> = docking::main_tabs(f.dock_tab_order).into_iter().map(|t| t.info().0).filter(|id| !f.icons.contains(*id)).collect();
|
|
let active = tabs.iter().position(|id| *id == f.dock_tab.info().0).unwrap_or(0);
|
|
let tabs: Vec<String> = tabs.into_iter().map(String::from).collect();
|
|
take_group(&mut placed, &tabs, active).map(|g| vec![vec![g]]).unwrap_or_default()
|
|
}
|
|
};
|
|
let n = tree_columns.len().max(1) as f32;
|
|
let tree_width = (docking::main_width(f.dock_width) / n).max(FLOAT_MIN.min(docking::main_width(f.dock_width)));
|
|
let mut columns: Vec<Column<String>> = tree_columns
|
|
.into_iter()
|
|
.map(|mut groups| {
|
|
for g in &mut groups {
|
|
g.minimized |= g.panels.get(g.active).is_some_and(|p| f.collapsed.contains(p));
|
|
}
|
|
Column { groups, width: tree_width, iconic: f.dock_collapsed, iconic_width: Some(ICON_WIDTH) }
|
|
})
|
|
.collect();
|
|
|
|
// Panels closed with a remembered place stay closed, except those the icon column showed
|
|
// anyway (its rows, and the panels dropped on it): they take their place there.
|
|
let in_rows = |id: &str| ICON_PANEL_GROUPS.iter().any(|row| row.contains(&id));
|
|
let hidden: BTreeMap<String, Location<String>> =
|
|
f.hidden.iter().take(craft_ui::docking::MAX_PANELS).map(|(id, location)| (id.clone(), location.clone())).collect();
|
|
for id in hidden.keys().filter(|id| !in_rows(id) && !f.icons.contains(*id)) {
|
|
let _ = placed.take(id);
|
|
}
|
|
|
|
// The icon column: the tabbed group's panels dropped on it, its rows of icon panels, then
|
|
// the other panels dropped on it.
|
|
let mut icon_groups = Vec::new();
|
|
let tabs: Vec<String> = DockTab::ALL.iter().map(|t| t.info().0.to_string()).filter(|id| f.icons.contains(id)).collect();
|
|
icon_groups.extend(take_group(&mut placed, &tabs, 0));
|
|
for row in ICON_PANEL_GROUPS {
|
|
let row: Vec<String> = row.iter().map(|id| id.to_string()).collect();
|
|
icon_groups.extend(take_group(&mut placed, &row, 0));
|
|
}
|
|
let dropped: Vec<String> = ICON_PANELS.iter().map(|e| e.0).filter(|id| !in_rows(id) && f.icons.contains(*id)).map(String::from).collect();
|
|
icon_groups.extend(take_group(&mut placed, &dropped, 0));
|
|
if !icon_groups.is_empty() {
|
|
columns.push(Column { groups: icon_groups, width: COLUMN_WIDTH, iconic: true, iconic_width: Some(ICON_WIDTH) });
|
|
}
|
|
docked.sort_by_key(|(n, _)| *n);
|
|
columns.extend(docked.into_iter().map(|(_, column)| column));
|
|
|
|
let layout = docking::with_canvas(columns, floating);
|
|
if !docking::valid(&layout) {
|
|
return None;
|
|
}
|
|
// A hidden place applies to a closed panel, and to a floating one Dock Panel sends back
|
|
// there; a floating panel whose way back was the icon column finds its icon group again.
|
|
let hidden = hidden
|
|
.into_iter()
|
|
.filter(|(id, _)| match layout.floating.iter().any(|w| w.contains(id)) {
|
|
true => !f.icons.contains(id),
|
|
false => !layout.contains(id),
|
|
})
|
|
.collect();
|
|
Some((layout, hidden))
|
|
}
|
|
|
|
/// A group of the panels of `panels` that have no place yet, showing the panel at `active` when
|
|
/// it is kept.
|
|
fn take_group(placed: &mut Placed, panels: &[String], active: usize) -> Option<Group<String>> {
|
|
let front = panels.get(active).cloned();
|
|
let kept: Vec<String> = panels.iter().take(craft_ui::docking::MAX_PANELS).filter_map(|p| placed.take(p)).collect();
|
|
let active = front.and_then(|front| kept.iter().position(|p| *p == front)).unwrap_or(0);
|
|
(!kept.is_empty()).then(|| Group::new(kept, active))
|
|
}
|
|
|
|
/// The height the old layout gave the floating window of `panels`, else the default.
|
|
fn old_height(old: Option<&Layout<String>>, panels: &[String]) -> f32 {
|
|
old.and_then(|l| l.floating.iter().find(|w| panels.iter().any(|p| w.contains(p))))
|
|
.map(|w| w.rect[3])
|
|
.filter(|h| h.is_finite() && *h >= 40.0)
|
|
.map_or(FLOAT_HEIGHT, |h| h.min(STACK_MAX))
|
|
}
|
|
|
|
/// A saved floating window rect with a usable position and size.
|
|
fn usable_rect(rect: [f32; 4]) -> [f32; 4] {
|
|
let [x, y, width, height] = rect;
|
|
let at = finite_pos([x, y]);
|
|
let width = Some(width).filter(|w| w.is_finite() && *w > 0.0).map_or(FLOAT_WIDTH, |w| w.clamp(FLOAT_MIN, COLUMN_MAX));
|
|
let height = Some(height).filter(|h| h.is_finite() && *h >= 40.0).map_or(FLOAT_HEIGHT, |h| h.min(STACK_MAX));
|
|
[at[0], at[1], width, height]
|
|
}
|
|
|
|
/// A saved floating position, or a place near the window's corner when it is not a number.
|
|
fn finite_pos(pos: [f32; 2]) -> [f32; 2] {
|
|
if pos.iter().all(|v| v.is_finite()) { pos.map(|v| v.clamp(-100_000.0, 100_000.0)) } else { [100.0, 100.0] }
|
|
}
|
|
|
|
/// The groups of an old dock tree's node, column by column in reading order. A vertical split
|
|
/// stacks its parts in one column; a horizontal split puts them side by side.
|
|
fn tree_columns(node: &Node<String>, depth: usize, placed: &mut Placed) -> Vec<Vec<Group<String>>> {
|
|
if depth > craft_ui::docking::MAX_DEPTH {
|
|
return Vec::new();
|
|
}
|
|
let column = |groups: Vec<Group<String>>| if groups.is_empty() { Vec::new() } else { vec![groups] };
|
|
match node {
|
|
Node::Tabs { panels, active } => column(take_group(placed, panels, *active).into_iter().collect()),
|
|
Node::Stack { entries } => column(
|
|
entries
|
|
.iter()
|
|
.take(craft_ui::docking::MAX_PANELS)
|
|
.filter_map(|e| placed.take(&e.panel).map(|id| Group { minimized: !e.open, ..Group::new(vec![id], 0) }))
|
|
.collect(),
|
|
),
|
|
Node::Split { axis, first, second, .. } => {
|
|
let mut parts = tree_columns(first, depth.saturating_add(1), placed);
|
|
parts.extend(tree_columns(second, depth.saturating_add(1), placed));
|
|
match axis {
|
|
craft_ui::layout::SplitAxis::Horizontal => parts,
|
|
_ => column(parts.into_iter().flatten().collect()),
|
|
}
|
|
}
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// A floating window's tree with only the panels not placed yet; a split that loses one side
|
|
/// becomes its other side. None when no panel is left.
|
|
fn unplaced_tree(node: &Node<String>, depth: usize, placed: &mut Placed) -> Option<Node<String>> {
|
|
if depth > craft_ui::docking::MAX_DEPTH {
|
|
return None;
|
|
}
|
|
match node {
|
|
Node::Tabs { panels, active } => take_group(placed, panels, *active).map(|g| Node::Tabs { panels: g.panels, active: g.active }),
|
|
Node::Stack { entries } => {
|
|
let entries: Vec<_> = entries
|
|
.iter()
|
|
.take(craft_ui::docking::MAX_PANELS)
|
|
.filter_map(|e| placed.take(&e.panel).map(|panel| craft_ui::docking::StackEntry { panel, ..e.clone() }))
|
|
.collect();
|
|
(!entries.is_empty()).then_some(Node::Stack { entries })
|
|
}
|
|
Node::Split { axis, size, first, second } => {
|
|
let first = unplaced_tree(first, depth.saturating_add(1), placed);
|
|
let second = unplaced_tree(second, depth.saturating_add(1), placed);
|
|
match (first, second) {
|
|
(Some(first), Some(second)) => Some(Node::Split { axis: *axis, size: *size, first: Box::new(first), second: Box::new(second) }),
|
|
(one, other) => one.or(other),
|
|
}
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Does a saved layout hold the canvas (one saved by this version)?
|
|
pub(crate) fn has_canvas(layout: &Layout<String>) -> bool {
|
|
layout.panels().iter().any(|p| p.as_str() == CANVAS)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|