Files
lightcraft/crates/ui-egui/src/docking.rs
T

824 lines
38 KiB
Rust

//! 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<String, Value>) -> 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<Panel> {
Node::Tabs { panels: vec![panel], active: 0 }
}
fn split(axis: SplitAxis, size: SplitSize, first: Node<Panel>, second: Node<Panel>) -> Node<Panel> {
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<Panel>;
fn column(panel: Panel, width: f32) -> Column<Panel> {
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<Panel> {
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<Panel>) -> 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<Panel> = 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<egui::Pos2>) -> 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::<Panel>(ctx, egui::Id::unique("lightcraft-docking"));
}
}
pub fn command(app: &mut crate::LightcraftApp, reset: bool, params: &Value) -> Result<Value, String> {
command_impl(app, reset, params, false)
}
fn command_impl(app: &mut crate::LightcraftApp, reset: bool, params: &Value, renderer_action: bool) -> Result<Value, String> {
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::<Panel>(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<Panel>| 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<Vec<Panel>>)> {
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<u32>) -> serde_json::Map<String, Value> {
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<Panel> = 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]])]);
}
}