//! Application panel identities and persistence around the shared docking renderer. //! Document contents remain owned by their existing view implementations. use craft_ui::docking::{ Action, Column, ColumnLocation, DockArea, DockCommand, DockError, DockOperation, DockStyle, FloatSpace, Group, GroupLocation, Layout, Location, Node, PanelLimits, PanelState, Permissions, Placement, Side, UiDock, Zone, }; use craft_ui::layout::{SplitAxis, SplitSize}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Panel { Canvas, Library, Tools, Presets, } impl Panel { pub const ALL: &[Self] = &[Self::Canvas, Self::Library, Self::Tools, Self::Presets]; pub fn id(self) -> &'static str { match self { Self::Canvas => "canvas", Self::Library => "library", Self::Tools => "tools", Self::Presets => "presets", } } fn label(self) -> &'static str { match self { Self::Canvas => "Photos", Self::Library => "Library", Self::Tools => "Tools", Self::Presets => "Presets", } } /// The dock the panel opens in when it has no recorded place. fn home_side(self) -> Side { if self == Self::Library { Side::Start } else { Side::End } } /// The width of the panel's own column in the default workspace. fn home_width(self) -> f32 { match self { Self::Canvas => crate::state::MIN_PHOTO_WIDTH, Self::Library => crate::state::LEFT_WIDTH.default, Self::Tools => crate::state::RIGHT_WIDTH.default, Self::Presets => PRESETS_WIDTH, } } } const PRESETS_WIDTH: f32 = 300.0; /// The arrangement generation saved as `UiState::docking_version`. Settings without it hold the /// side panels in split frames; they start again from [`default_workspace`]. pub const DOCKING_VERSION: u32 = 1; /// Drops saved settings' arrangement from before [`DOCKING_VERSION`], with the places of its /// closed panels, so the app starts from the default workspace; every other setting stays. /// Returns true when an arrangement was dropped. pub fn drop_outdated(settings: &mut serde_json::Map) -> bool { if settings.get("dockingVersion").and_then(Value::as_u64).is_some_and(|version| version >= u64::from(DOCKING_VERSION)) { return false; } settings.insert("dockingVersion".into(), json!(DOCKING_VERSION)); settings.remove("docking").is_some_and(|docking| !docking.is_null()) } fn permissions(panel: &Panel) -> Permissions { // Panels open and split beside the photo view, but never join it as tabs. if *panel == Panel::Canvas { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() } } fn tabs(panel: Panel) -> Node { Node::Tabs { panels: vec![panel], active: 0 } } fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node) -> Node { Node::Split { axis, size, first: Box::new(first), second: Box::new(second) } } /// The saved arrangement. Closed panels keep their last group, side, size or floating rect. pub type Workspace = craft_ui::docking::Workspace; fn column(panel: Panel, width: f32) -> Column { Column::new(vec![Group::new(vec![panel], 0)], width) } /// Library in a start dock and Tools in an end dock beside the photo. Presets has a column of /// its own on Tools' photo side rather than a group under Tools: both are tall lists that are /// read side by side while trying presets, and stacking them would halve both. fn docked(left: f32, right: f32) -> Workspace { let start = Node::Dock { side: Side::Start, columns: vec![column(Panel::Library, left)] }; let end = Node::Dock { side: Side::End, columns: vec![column(Panel::Tools, right), column(Panel::Presets, PRESETS_WIDTH)] }; Workspace::new(Layout { root: Some(split( SplitAxis::Horizontal, SplitSize::FixedFirst(left), start, split(SplitAxis::Horizontal, SplitSize::FixedSecond(right + PRESETS_WIDTH), tabs(Panel::Canvas), end), )), floating: Vec::new(), }) } pub fn default_workspace() -> Workspace { docked(crate::state::LEFT_WIDTH.default, crate::state::RIGHT_WIDTH.default) } /// Where a panel without a usable recorded place opens: its own column at its dock's window /// edge, or Presets on the photo side of Tools while Tools is in the end dock. fn home(workspace: &Workspace, panel: Panel) -> Location { let column = ColumnLocation { side: panel.home_side(), width: panel.home_width(), iconic: false, iconic_width: None }; let docked_at = |other: Panel| workspace.layout.location(&other).ok().and_then(|location| location.group).and_then(|group| group.column).map(|c| c.side); let beside = (panel == Panel::Presets && docked_at(Panel::Tools) == Some(Side::End)).then_some(Panel::Tools); Location { floating: None, anchor: beside, placement: if beside.is_some() { Placement::Column { after: true } } else { Placement::Tab { before: None } }, size: None, stack: None, sibling: Vec::new(), group: Some(GroupLocation { minimized: false, height: None, column: Some(column) }), } } fn error(error: DockError) -> String { error.to_string() } /// The shared checks plus LightCraft's own: saved settings cannot float, hide, share or /// collapse the photo view, nor send a hidden panel back onto it. pub fn validate(workspace: &Workspace) -> Result<(), String> { workspace.validate().map_err(error)?; let mut nodes: Vec<_> = workspace.layout.root.iter().collect(); let mut alone = false; while let Some(node) = nodes.pop() { match node { Node::Split { first, second, .. } => { nodes.push(first); nodes.push(second); } Node::Tabs { panels, .. } => alone |= panels.as_slice() == [Panel::Canvas], _ => {} } } if !alone { return Err("The document view must stay docked in a group of its own".into()); } if workspace.hidden.iter().any(|(_, location)| { location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) }) { return Err("A hidden panel cannot restore over the document view".into()); } Ok(()) } fn apply(workspace: &mut Workspace, action: Action) -> Result<(), String> { let mut next = workspace.clone(); next.apply(action, permissions).map_err(error)?; validate(&next)?; *workspace = next; Ok(()) } /// Shows a panel at its recorded place, else in its dock column ([`home`]). A panel that /// already has the asked visibility keeps its tab selection. fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result<(), String> { if workspace.layout.contains(&panel) == visible { return Ok(()); } if !visible { return apply(workspace, Action::Close { panel }); } validate(workspace)?; // A recorded place that can't be rebuilt falls back to joining the fallback's tabs. The // photo view takes no tabs, so that is refused and the panel goes to its home column below. if workspace.hidden.iter().any(|(hidden, _)| *hidden == panel) { let mut next = workspace.clone(); if next.set_visible(panel, true, Some(Panel::Canvas), permissions).is_ok() && validate(&next).is_ok() { *workspace = next; return Ok(()); } } let mut next = workspace.clone(); next.hidden.retain(|(hidden, _)| *hidden != panel); let location = home(&next, panel); next.hidden.push((panel, location)); next.set_visible(panel, true, Some(Panel::Canvas), permissions).map_err(error)?; validate(&next)?; *workspace = next; Ok(()) } /// Membership is passive state; an explicit command also exposes the panel's body. fn reveal(workspace: &mut Workspace, panel: Panel) -> Result<(), String> { set_visible(workspace, panel, true)?; apply(workspace, Action::Activate { panel })?; if workspace.layout.panel_state(&panel) != PanelState::Front { // A minimized window or group opens again; where nothing is minimized the action is // refused and the layout stays as it is. let _ = apply(workspace, Action::SetMinimized { panel, minimized: false }); } Ok(()) } pub fn exposed_in(workspace: &Workspace, panel: Panel) -> bool { workspace.layout.panel_state(&panel) == PanelState::Front } /// `ui.dock` params, parsed by the shared command vocabulary. fn run(workspace: &mut Workspace, params: &Value) -> Result<(), String> { validate(workspace)?; let mut tagged = params.clone(); let fields = tagged.as_object_mut().ok_or("ui.dock params must be an object")?; fields.insert("command".into(), json!("ui.dock")); let command: DockCommand = serde_json::from_value(tagged).map_err(|error| error.to_string())?; if let DockCommand::UiDock(UiDock::Operation(DockOperation::Open { panel })) = command { return reveal(workspace, panel); } let mut next = workspace.clone(); // `ui.dock` operations carry their own rects; only `window.panel.float` reads the space. command.apply(&mut next, false, &FloatSpace::new(egui::Rect::ZERO), permissions).map_err(error)?; validate(&next)?; *workspace = next; Ok(()) } fn from_ui(app: &crate::LightcraftApp) -> Workspace { docked(crate::state::LEFT_WIDTH.clamp(app.ui.left_width), crate::state::RIGHT_WIDTH.clamp(app.ui.right_width)) } fn sync(workspace: &mut Workspace, app: &crate::LightcraftApp) -> Result<(), String> { set_visible(workspace, Panel::Library, app.ui.left_panel)?; set_visible(workspace, Panel::Tools, app.ui.right != crate::state::RightPanel::None)?; set_visible(workspace, Panel::Presets, app.ui.presets) } fn publish_visibility(app: &mut crate::LightcraftApp, workspace: &Workspace) { app.ui.left_panel = workspace.layout.contains(&Panel::Library); app.ui.presets = workspace.layout.contains(&Panel::Presets); if !workspace.layout.contains(&Panel::Tools) { app.ui.right = crate::state::RightPanel::None; } else if app.ui.right == crate::state::RightPanel::None { app.ui.right = crate::state::RightPanel::Edit; } for (panel, width, limits) in [(Panel::Library, &mut app.ui.left_width, crate::state::LEFT_WIDTH), (Panel::Tools, &mut app.ui.right_width, crate::state::RIGHT_WIDTH)] { let Ok(location) = workspace.layout.location(&panel) else { continue; }; let column = location.group.and_then(|group| group.column); let value = match (location.floating, column, location.placement, location.size) { (Some([_, _, width, _]), ..) => Some(width), (None, Some(column), ..) => Some(column.width), (None, None, Placement::Split(Zone::Left), Some(SplitSize::FixedFirst(width))) | (None, None, Placement::Split(Zone::Right), Some(SplitSize::FixedSecond(width))) => Some(width), // A vertical split stores height, and the opposite fixed side sizes a sibling. _ => None, }; if let Some(value) = value { *width = limits.clamp(value); } } } pub(crate) fn exposed(app: &crate::LightcraftApp, panel: Panel) -> bool { app.ui.docking.as_ref().is_none_or(|workspace| exposed_in(workspace, panel)) } pub(crate) fn reveal_command(app: &mut crate::LightcraftApp, id: &str) -> Result<(), String> { let panel = if id == "panel.presets" && app.ui.presets { Some(Panel::Presets) } else if id == "app.showInFolders" { // the sidebar it scrolls, also when it is a tab behind another panel Some(Panel::Library) } else if (id.starts_with("panel.") && id != "panel.close" && id != "panel.presets" || id == "view.rightPanel" || id.starts_with("section.") || id.starts_with("tool.") && id != "tool.none") && app.ui.right != crate::state::RightPanel::None { Some(Panel::Tools) } else { None }; if let Some(panel) = panel { let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app)); sync(&mut workspace, app)?; reveal(&mut workspace, panel)?; cancel_replaced_gesture(app, Some(&workspace)); app.ui.docking = Some(workspace); } Ok(()) } /// Check the target's actual egui layer even while a different widget owns the drag. pub(crate) fn drop_target(ui: &egui::Ui, rect: egui::Rect, pointer: Option) -> bool { pointer.is_some_and(|pos| rect.intersect(ui.clip_rect()).contains(pos) && ui.ctx().layer_id_at(pos) == Some(ui.layer_id())) } /// Explicit host commands replace the snapshot before rendering. Cancel only their old /// gestures; actions returned by the renderer must keep continuous resizing alive. pub(crate) fn cancel_replaced_gesture(app: &crate::LightcraftApp, workspace: Option<&Workspace>) { let replaced = match (app.ui.docking.as_ref(), workspace) { (Some(old), Some(next)) => old.layout != next.layout || old.hidden != next.hidden, (None, None) => false, _ => true, }; if replaced && let Some(ctx) = &app.tasks.repaint { craft_ui::docking::cancel_drag::(ctx, egui::Id::unique("lightcraft-docking")); } } pub fn command(app: &mut crate::LightcraftApp, reset: bool, params: &Value) -> Result { command_impl(app, reset, params, false) } fn command_impl(app: &mut crate::LightcraftApp, reset: bool, params: &Value, renderer_action: bool) -> Result { let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app)); if reset { workspace = default_workspace(); // The usual LightCraft starting workspace shows its current tool beside the photo. set_visible(&mut workspace, Panel::Library, false)?; set_visible(&mut workspace, Panel::Presets, false)?; } else { sync(&mut workspace, app)?; run(&mut workspace, params)?; } if !renderer_action { cancel_replaced_gesture(app, Some(&workspace)); } publish_visibility(app, &workspace); app.ui.docking = Some(workspace); Ok(json!({"docking": app.ui.docking})) } /// Draw the docked workspace: restore or fall back from an invalid layout, sync panel /// visibility, render the panels and the current view in the canvas, then apply the docking /// actions. If the layout changed during the pass, any in-flight dock drag is cancelled and the /// docking actions it produced are not applied. pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(ui.ctx()); let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app)); if let Err(error) = validate(&workspace).and_then(|()| sync(&mut workspace, app)) { app.ui.toast = Some((format!("Panel layout could not be restored: {error}"), app.tick_now() + 8.0, None)); workspace = from_ui(app); let _ = sync(&mut workspace, app); } // Legacy visibility commands may replace the layout before this render starts. cancel_replaced_gesture(app, Some(&workspace)); // Tool/Canvas callbacks dispatch app commands and must see the real workspace. app.ui.docking = Some(workspace.clone()); let mut style = DockStyle::from_ui(ui); style.min_pane = 96.0; // Free docking: a panel dropped at an empty window edge makes a dock, and the photo takes // drops on all four sides. style.new_dock_zones = true; style.side_edge_zones = false; style.float_label = crate::i18n::tr("Float panel").into(); style.close_label = crate::i18n::tr("Close panel").into(); style.move_panel_to_label = crate::i18n::tr("Move to group").into(); style.panels_label = crate::i18n::tr("Panels").into(); style.resize_label = crate::i18n::tr("Resize panels").into(); style.resize_window_label = crate::i18n::tr("Resize panel window").into(); style.move_group_label = crate::i18n::tr("Move tab group").into(); style.move_group_to_label = crate::i18n::tr("Move group to").into(); style.float_group_label = crate::i18n::tr("Float tab group").into(); style.close_group_label = crate::i18n::tr("Close tab group").into(); style.close_others_label = crate::i18n::tr("Close other panels in group").into(); style.minimize_label = crate::i18n::tr("Minimize").into(); style.expand_label = crate::i18n::tr("Expand").into(); style.move_column_label = crate::i18n::tr("Move panel column").into(); style.collapse_to_icons_label = crate::i18n::tr("Collapse to Icons").into(); style.expand_panels_label = crate::i18n::tr("Expand Panels").into(); style.collapse_column_label = crate::i18n::tr("Collapse column").into(); style.expand_column_label = crate::i18n::tr("Expand column").into(); style.auto_collapse_label = crate::i18n::tr("Auto-Collapse Iconic Panels").into(); style.move_window_label = crate::i18n::tr("Move panel window").into(); style.close_window_label = crate::i18n::tr("Close panel window").into(); style.float_hint_label = crate::i18n::tr("Hold {key} to undock").into(); style.background = t.chrome; style.tab_background = t.chrome; style.active_background = t.canvas; style.text = t.text; style.inactive_text = t.text_dim; style.border = egui::Stroke::new(1.0, t.divider); style.accent = t.accent; style.font = t.font(12.0); // Tool and preset panels publish hover previews before the photo consumes them. let mut canvas = None; let output = DockArea::new(egui::Id::unique("lightcraft-docking")).locked(workspace.locked).show_with_limits( ui, &workspace.layout, &style, |panel| crate::i18n::tr(panel.label()).into(), permissions, panel_limits, |ui, panel| match panel { Panel::Canvas => { canvas = Some(ui.new_child(egui::UiBuilder::new().id_salt("photo-view").max_rect(ui.max_rect()))); } Panel::Library => { crate::widgets::register(ui, "panel:left_panel", ui.max_rect()); crate::panels::left::show(app, ui); } Panel::Tools => { crate::widgets::register(ui, "panel:right_panel", ui.max_rect()); crate::panels::right::show(app, ui); } Panel::Presets => crate::panels::presets::show(app, ui), }, ); if let Some(mut canvas) = canvas { let ui = &mut canvas; crate::panels::bottombar::show(app, ui); let bg = if matches!( app.ui.view, crate::state::ViewMode::Detail | crate::state::ViewMode::Compare | crate::state::ViewMode::Survey | crate::state::ViewMode::Reference ) { t.canvas } else { t.grid_bg }; crate::panels::compare::prepare(app); egui::CentralPanel::default().frame(egui::Frame::NONE.fill(bg)).show(ui, |ui| match app.ui.view { crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid => crate::panels::grid::show(app, ui), crate::state::ViewMode::Detail => crate::panels::detail::show(app, ui), crate::state::ViewMode::Compare => crate::panels::compare::show_compare(app, ui), crate::state::ViewMode::Survey => crate::panels::compare::show_survey(app, ui), crate::state::ViewMode::Reference => crate::panels::compare::show_reference(app, ui), crate::state::ViewMode::People => crate::panels::people::show(app, ui), }); } let rendered = workspace; let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app)); let _ = sync(&mut workspace, app); let stale = workspace.layout != rendered.layout || workspace.hidden != rendered.hidden; if stale { craft_ui::docking::cancel_drag::(ui.ctx(), egui::Id::unique("lightcraft-docking")); } publish_visibility(app, &workspace); app.ui.docking = Some(workspace); if stale { return; } for action in output.actions { if let Err(error) = serde_json::to_value(action) .map_err(|error| error.to_string()) .and_then(|action| command_impl(app, false, &json!({"action": action}), true)) { app.ui.toast = Some((format!("Panel move was not applied: {error}"), app.tick_now() + 8.0, None)); } } } /// Keep the established ui.set width controls connected to the panel's dock column, floating /// window or parent split. pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(), String> { state.left_width = crate::state::LEFT_WIDTH.clamp(state.left_width); state.right_width = crate::state::RIGHT_WIDTH.clamp(state.right_width); let Some(workspace) = state.docking.as_mut() else { return Ok(()); }; validate(workspace)?; for (key, panel, width) in [("leftWidth", Panel::Library, state.left_width), ("rightWidth", Panel::Tools, state.right_width)] { if params.get(key).is_none() { continue; } if let Some((_, location)) = workspace.hidden.iter_mut().find(|(id, _)| *id == panel) { if let Some(rect) = &mut location.floating { rect[2] = width; } if let Some(column) = location.group.as_mut().and_then(|group| group.column.as_mut()) { column.width = width; continue; } match location.placement { Placement::Split(Zone::Left) => location.size = Some(SplitSize::FixedFirst(width)), Placement::Split(Zone::Right) => location.size = Some(SplitSize::FixedSecond(width)), _ => {} // Width controls do not rewrite a saved vertical split's height. } continue; } if let Some(group) = workspace.layout.floating.iter().find(|group| group.contains(&panel)) { let mut rect = group.rect; rect[2] = width; apply(workspace, Action::MoveFloating { panel, rect })?; continue; } if workspace.layout.location(&panel).is_ok_and(|location| location.group.is_some_and(|group| group.column.is_some())) { apply(workspace, Action::ResizeColumn { panel, width })?; continue; } let mut nodes = workspace.layout.root.as_ref().map(|node| vec![(node, Vec::new())]).unwrap_or_default(); let mut change = None; while let Some((node, path)) = nodes.pop() { if let Node::Split { axis, first, second, .. } = node { let contains = |node: &Node| match node { Node::Tabs { panels, .. } => panels.contains(&panel), Node::Stack { entries } => entries.iter().any(|entry| entry.panel == panel), _ => false, }; if *axis == SplitAxis::Horizontal { if contains(first) { change = Some(Action::ResizeSplit { path: path.clone(), size: SplitSize::FixedFirst(width) }); } if contains(second) { change = Some(Action::ResizeSplit { path: path.clone(), size: SplitSize::FixedSecond(width) }); } } let mut second_path = path.clone(); second_path.push(true); let mut first_path = path; first_path.push(false); nodes.push((second, second_path)); nodes.push((first, first_path)); } } if let Some(action) = change { apply(workspace, action)?; } } Ok(()) } fn panel_limits(panel: &Panel) -> PanelLimits { let (min, max_width) = match panel { Panel::Canvas => (egui::vec2(crate::state::MIN_PHOTO_WIDTH, 160.0), 1_000_000.0), Panel::Library => (egui::vec2(crate::state::LEFT_WIDTH.min, 120.0), crate::state::LEFT_WIDTH.max), Panel::Tools => (egui::vec2(crate::state::RIGHT_WIDTH.min, 120.0), crate::state::RIGHT_WIDTH.max), Panel::Presets => (egui::vec2(250.0, 120.0), 520.0), }; PanelLimits { min, max: egui::vec2(max_width, 1_000_000.0) } } /// The columns of the dock on `side`, from the window edge inwards: width and the panels of /// each group. #[cfg(test)] pub(crate) fn columns(workspace: &Workspace, side: Side) -> Vec<(f32, Vec>)> { let mut nodes: Vec<_> = workspace.layout.root.iter().collect(); while let Some(node) = nodes.pop() { match node { Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), Node::Dock { side: dock, columns } if *dock == side => { return columns.iter().map(|column| (column.width, column.groups.iter().map(|group| group.panels.clone()).collect())).collect(); } _ => {} } } Vec::new() } #[cfg(test)] mod tests { use super::*; fn app_with_widths() -> crate::LightcraftApp { let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); app.ui.left_width = 333.0; app.ui.right_width = 410.0; app } fn tools_split(axis: SplitAxis, size: SplitSize, first: bool) -> Workspace { let rest = split( SplitAxis::Horizontal, SplitSize::FixedFirst(333.0), tabs(Panel::Library), split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Presets)), ); let root = if first { split(axis, size, tabs(Panel::Tools), rest) } else { split(axis, size, rest, tabs(Panel::Tools)) }; Workspace::new(Layout { root: Some(root), floating: Vec::new() }) } #[test] fn explicit_reveal_opens_collapsed_tools_and_survives_reload() { let mut app = app_with_widths(); app.ui.right = crate::state::RightPanel::Crop; let entries = [Panel::Tools, Panel::Library, Panel::Presets] .into_iter() .map(|panel| craft_ui::docking::StackEntry { panel, open: panel != Panel::Tools, height: Some(180.0) }) .collect(); app.ui.docking = Some(Workspace::new(Layout { root: Some(split(SplitAxis::Horizontal, SplitSize::FixedSecond(350.0), tabs(Panel::Canvas), Node::Stack { entries })), floating: Vec::new(), })); let undo = app.session.undo.len(); app.run("panel.crop", json!({})).unwrap(); assert_eq!(app.ui.right, crate::state::RightPanel::Crop); let workspace = app.ui.docking.as_ref().unwrap(); assert!(exposed_in(workspace, Panel::Tools)); assert_eq!(app.session.undo.len(), undo); let saved = serde_json::to_string(workspace).unwrap(); let reloaded: Workspace = serde_json::from_str(&saved).unwrap(); validate(&reloaded).unwrap(); assert!(exposed_in(&reloaded, Panel::Tools)); } #[test] fn legacy_width_publication_ignores_heights_and_sibling_widths() { for (axis, size, first) in [ (SplitAxis::Vertical, SplitSize::FixedFirst(180.0), true), (SplitAxis::Vertical, SplitSize::FixedSecond(180.0), false), (SplitAxis::Horizontal, SplitSize::FixedSecond(180.0), true), (SplitAxis::Horizontal, SplitSize::FixedFirst(180.0), false), ] { let workspace = tools_split(axis, size, first); validate(&workspace).unwrap(); let mut app = app_with_widths(); publish_visibility(&mut app, &workspace); assert_eq!((app.ui.left_width, app.ui.right_width), (333.0, 410.0), "{axis:?} {size:?}, first={first}"); } } #[test] fn legacy_width_publication_reads_own_split_and_floating_widths() { for (size, first) in [(SplitSize::FixedFirst(310.0), true), (SplitSize::FixedSecond(310.0), false)] { let workspace = tools_split(SplitAxis::Horizontal, size, first); let mut app = app_with_widths(); publish_visibility(&mut app, &workspace); assert_eq!((app.ui.left_width, app.ui.right_width), (333.0, 310.0)); } let mut workspace = default_workspace(); apply(&mut workspace, Action::Float { panel: Panel::Tools, rect: [12.0, 34.0, 390.0, 450.0] }).unwrap(); let mut app = app_with_widths(); publish_visibility(&mut app, &workspace); assert_eq!(app.ui.right_width, 390.0); } #[test] fn legacy_width_update_preserves_hidden_top_panel_height() { let mut workspace = default_workspace(); apply(&mut workspace, Action::Move { panel: Panel::Tools, anchor: Panel::Canvas, placement: Placement::Split(Zone::Top) }).unwrap(); set_visible(&mut workspace, Panel::Tools, false).unwrap(); let height = workspace.hidden.iter().find(|(panel, _)| *panel == Panel::Tools).unwrap().1.size; let mut state = crate::UiState { docking: Some(workspace), right_width: 410.0, ..Default::default() }; set_widths(&mut state, &json!({"rightWidth":410.0})).unwrap(); assert_eq!(state.docking.as_ref().unwrap().hidden.iter().find(|(panel, _)| *panel == Panel::Tools).unwrap().1.size, height); } /// Settings written before workspaces had a version: `{layout, hidden}`, a floating /// window as one group, hidden locations without dock state. #[test] fn version_one_workspace_file_loads_and_reopens_its_hidden_panel() { let v1 = json!({ "layout": { "root": {"Split": { "axis": "Horizontal", "size": {"FixedFirst": 300.0}, "first": {"Tabs": {"panels": ["library"], "active": 0}}, "second": {"Tabs": {"panels": ["canvas"], "active": 0}} }}, "floating": [{"panels": ["tools"], "active": 0, "rect": [120.0, 90.0, 320.0, 600.0]}] }, "hidden": [["presets", { "floating": null, "anchor": "canvas", "placement": {"Split": "Right"}, "size": {"FixedSecond": 280.0}, "stack": null, "sibling": ["canvas"] }]] }); let mut workspace: Workspace = serde_json::from_value(v1).unwrap(); validate(&workspace).unwrap(); assert_eq!(workspace.version, 1); let window = workspace.layout.floating.first().unwrap(); assert!(window.contains(&Panel::Tools)); assert_eq!(window.rect, [120.0, 90.0, 320.0, 600.0]); assert!(exposed_in(&workspace, Panel::Library)); set_visible(&mut workspace, Panel::Presets, true).unwrap(); let location = workspace.layout.location(&Panel::Presets).unwrap(); assert_eq!((location.anchor, location.size), (Some(Panel::Canvas), Some(SplitSize::FixedSecond(280.0)))); let saved = serde_json::to_value(&workspace).unwrap(); assert_eq!(saved["version"], 2); assert_eq!(saved["layout"]["floating"][0]["panels"], json!(["tools"]), "a one-group window keeps the v1 shape"); } #[test] fn default_workspace_docks_library_at_the_start_and_tools_and_presets_at_the_end() { let workspace = default_workspace(); validate(&workspace).unwrap(); assert_eq!(columns(&workspace, Side::Start), [(crate::state::LEFT_WIDTH.default, vec![vec![Panel::Library]])]); assert_eq!( columns(&workspace, Side::End), [(crate::state::RIGHT_WIDTH.default, vec![vec![Panel::Tools]]), (PRESETS_WIDTH, vec![vec![Panel::Presets]])], "Presets has its own column on Tools' photo side" ); let Some(Node::Split { second, .. }) = &workspace.layout.root else { panic!("{:?}", workspace.layout.root) }; let Node::Split { first, .. } = &**second else { panic!("{second:?}") }; assert_eq!(**first, tabs(Panel::Canvas), "the photo sits between the docks"); assert!(workspace.layout.floating.is_empty()); let app = app_with_widths(); let workspace = from_ui(&app); assert_eq!(columns(&workspace, Side::Start).first().map(|column| column.0), Some(333.0)); assert_eq!(columns(&workspace, Side::End).first().map(|column| column.0), Some(410.0)); } #[test] fn dock_column_widths_publish_and_follow_width_controls() { let mut app = app_with_widths(); let workspace = docked(300.0, 310.0); publish_visibility(&mut app, &workspace); assert_eq!((app.ui.left_width, app.ui.right_width), (300.0, 310.0)); let mut state = crate::UiState { docking: Some(workspace), left_width: 350.0, right_width: 400.0, ..Default::default() }; set_visible(state.docking.as_mut().unwrap(), Panel::Library, false).unwrap(); set_widths(&mut state, &json!({"leftWidth":350.0, "rightWidth":400.0})).unwrap(); let workspace = state.docking.as_mut().unwrap(); assert_eq!(columns(workspace, Side::End).first().map(|column| column.0), Some(400.0), "Tools' column"); set_visible(workspace, Panel::Library, true).unwrap(); assert_eq!(columns(workspace, Side::Start), [(350.0, vec![vec![Panel::Library]])], "the closed Library's column"); } #[test] fn a_panel_without_a_recorded_place_opens_in_its_dock() { let root = split( SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), Node::Dock { side: Side::End, columns: vec![column(Panel::Tools, 300.0)] }, ); let mut workspace = Workspace::new(Layout { root: Some(root), floating: Vec::new() }); set_visible(&mut workspace, Panel::Presets, true).unwrap(); set_visible(&mut workspace, Panel::Library, true).unwrap(); validate(&workspace).unwrap(); assert_eq!(columns(&workspace, Side::Start), [(crate::state::LEFT_WIDTH.default, vec![vec![Panel::Library]])]); assert_eq!(columns(&workspace, Side::End), [(300.0, vec![vec![Panel::Tools]]), (PRESETS_WIDTH, vec![vec![Panel::Presets]])]); // Without Tools in the end dock, Presets takes the dock's window edge. let mut workspace = default_workspace(); set_visible(&mut workspace, Panel::Presets, false).unwrap(); workspace.hidden.clear(); apply(&mut workspace, Action::Float { panel: Panel::Tools, rect: [12.0, 34.0, 390.0, 450.0] }).unwrap(); set_visible(&mut workspace, Panel::Presets, true).unwrap(); assert_eq!(columns(&workspace, Side::End), [(PRESETS_WIDTH, vec![vec![Panel::Presets]])]); } fn saved_settings(docking: Value, version: Option) -> serde_json::Map { let ui = crate::UiState { thumb_size: 210.0, filmstrip: false, presets: true, ..Default::default() }; let mut saved = serde_json::to_value(&ui).unwrap(); let fields = saved.as_object_mut().unwrap(); fields.insert("docking".into(), docking); match version { Some(version) => fields.insert("dockingVersion".into(), json!(version)), None => fields.remove("dockingVersion"), }; fields.clone() } #[test] fn a_saved_split_frame_layout_without_a_docking_version_loads_as_the_default_docks() { let frames = split( SplitAxis::Horizontal, SplitSize::FixedFirst(333.0), tabs(Panel::Library), split(SplitAxis::Horizontal, SplitSize::FixedSecond(410.0), tabs(Panel::Canvas), tabs(Panel::Tools)), ); let frames = Workspace::new(Layout { root: Some(frames), floating: Vec::new() }); validate(&frames).unwrap(); for version in [None, Some(0)] { let mut fields = saved_settings(serde_json::to_value(&frames).unwrap(), version); assert!(drop_outdated(&mut fields)); let ui: crate::UiState = serde_json::from_value(Value::Object(fields)).unwrap(); assert!(ui.docking.is_none()); assert_eq!(ui.docking_version, DOCKING_VERSION); assert_eq!((ui.thumb_size, ui.filmstrip, ui.presets), (210.0, false, true), "other settings stay"); let mut app = app_with_widths(); app.ui = ui; command(&mut app, false, &json!({"operation":"activate","panel":"tools"})).unwrap(); let workspace = app.ui.docking.as_ref().unwrap(); assert!(!workspace.layout.contains(&Panel::Library), "the Library panel stays closed"); assert_eq!( columns(workspace, Side::End), [(crate::state::RIGHT_WIDTH.default, vec![vec![Panel::Tools]]), (PRESETS_WIDTH, vec![vec![Panel::Presets]])] ); } } #[test] fn a_saved_dock_layout_round_trips() { let mut workspace = default_workspace(); apply(&mut workspace, Action::ResizeColumn { panel: Panel::Tools, width: 333.0 }).unwrap(); apply( &mut workspace, Action::Move { panel: Panel::Presets, anchor: Panel::Library, placement: Placement::Group { below: true, height: None } }, ) .unwrap(); set_visible(&mut workspace, Panel::Library, false).unwrap(); let mut fields = saved_settings(serde_json::to_value(&workspace).unwrap(), Some(DOCKING_VERSION)); assert!(!drop_outdated(&mut fields)); let ui: crate::UiState = serde_json::from_value(Value::Object(fields)).unwrap(); let loaded = ui.docking.unwrap(); validate(&loaded).unwrap(); assert_eq!(loaded, workspace); assert_eq!(serde_json::to_value(crate::UiState::default()).unwrap()["dockingVersion"], DOCKING_VERSION, "saves write the version"); } #[test] fn a_panel_whose_saved_place_is_gone_opens_in_its_dock() { let root = split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Tools)); let mut workspace = Workspace::new(Layout { root: Some(root), floating: Vec::new() }); // Presets, the panel Library went back beside, is neither shown nor hidden. let location: craft_ui::docking::Location = serde_json::from_value(json!({ "floating": null, "anchor": "presets", "placement": {"Split": "Left"}, "size": {"FixedFirst": 280.0}, "stack": null, "sibling": ["presets"] })) .unwrap(); workspace.hidden.push((Panel::Library, location)); validate(&workspace).unwrap(); set_visible(&mut workspace, Panel::Library, true).unwrap(); validate(&workspace).unwrap(); assert_eq!(columns(&workspace, Side::Start), [(crate::state::LEFT_WIDTH.default, vec![vec![Panel::Library]])]); } }