mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 11:37:13 -06:00
Docking: the workspace, its commands and their parser come from craft-ui (craft-libs 16fb3d4)
craft-ui and craft-docpath are pinned to craft-libs 16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3. The saved workspace is craft_ui::docking::Workspace<Panel>; settings written before it had a version load as version 1. ui.dock params parse as the shared DockCommand and apply through Workspace::apply_all. LightCraft keeps its panels, permissions, default layout, photo-view checks, the fallback beside the photo view for a panel with no recorded place, and its settings file. The photo view takes side splits (accepts_splits on PROTECTED), so panels still open and move beside it. Tests: a version 1 workspace file loads and reopens its hidden panel; a drag of a group by its bar moves both of its panels.
This commit is contained in:
Generated
+6
-2
@@ -1084,14 +1084,18 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3"
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"log",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"serde",
|
||||
]
|
||||
|
||||
|
||||
+2
-2
@@ -10,8 +10,8 @@ rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/lightcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3", features = ["serde"] }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" }
|
||||
# internal
|
||||
lightcraft-denoise-core = { path = "crates/denoise-core" }
|
||||
lightcraft-denoise = { path = "crates/denoise", default-features = false }
|
||||
|
||||
@@ -265,7 +265,7 @@ fn decode_saved_ui(bytes: &[u8]) -> Result<(UiState, bool), String> {
|
||||
let fields = saved.as_object_mut().ok_or("settings must be an object")?;
|
||||
let recovered =
|
||||
fields.get("docking").is_some_and(|value| match serde_json::from_value::<Option<lightcraft_ui_egui::docking::Workspace>>(value.clone()) {
|
||||
Ok(Some(workspace)) => workspace.validate().is_err(),
|
||||
Ok(Some(workspace)) => lightcraft_ui_egui::docking::validate(&workspace).is_err(),
|
||||
Ok(None) => false,
|
||||
Err(_) => true,
|
||||
});
|
||||
@@ -376,7 +376,7 @@ impl PrefsWriter {
|
||||
}
|
||||
let Some(path) = self.path.clone() else { return Ok(()) };
|
||||
if let Some(workspace) = &ui.docking {
|
||||
workspace.validate()?;
|
||||
lightcraft_ui_egui::docking::validate(workspace)?;
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?;
|
||||
if bytes == self.written {
|
||||
@@ -1245,7 +1245,7 @@ mod tests {
|
||||
restored_app.ui = restored.unwrap();
|
||||
restored_app.run("ui.dock", serde_json::json!({"operation":"open", "panel":"tools"})).unwrap();
|
||||
assert_eq!(
|
||||
restored_app.ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect,
|
||||
restored_app.ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.contains(&Panel::Tools)).unwrap().rect,
|
||||
[70.0, 80.0, 310.0, 500.0]
|
||||
);
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
|
||||
+170
-248
@@ -1,7 +1,10 @@
|
||||
//! Application panel identities and persistence around the shared docking renderer.
|
||||
//! Document contents remain owned by their existing view implementations.
|
||||
|
||||
use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone};
|
||||
use craft_ui::docking::{
|
||||
Action, DockArea, DockCommand, DockError, DockOperation, DockStyle, FloatSpace, Layout, Node, PanelLimits, PanelState, Permissions, Placement,
|
||||
UiDock, Zone,
|
||||
};
|
||||
use craft_ui::layout::{SplitAxis, SplitSize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
@@ -41,13 +44,11 @@ impl Panel {
|
||||
Self::Presets => Zone::Right,
|
||||
}
|
||||
}
|
||||
fn parse(id: &str) -> Result<Self, String> {
|
||||
Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn permissions(panel: &Panel) -> Permissions {
|
||||
if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() }
|
||||
// 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> {
|
||||
@@ -58,233 +59,119 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node<Panel>, second: Node<Pane
|
||||
Node::Split { axis, size, first: Box::new(first), second: Box::new(second) }
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Workspace {
|
||||
pub layout: Layout<Panel>,
|
||||
/// Closed panels retain their most recent group, side, size or floating rectangle.
|
||||
pub hidden: Vec<(Panel, Location<Panel>)>,
|
||||
/// The saved arrangement. Closed panels keep their last group, side, size or floating rect.
|
||||
pub type Workspace = craft_ui::docking::Workspace<Panel>;
|
||||
|
||||
pub fn default_workspace() -> Workspace {
|
||||
Workspace::new(Layout {
|
||||
root: Some(split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedFirst(crate::state::LEFT_WIDTH.default),
|
||||
tabs(Panel::Library),
|
||||
split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedSecond(crate::state::RIGHT_WIDTH.default),
|
||||
split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Presets)),
|
||||
tabs(Panel::Tools),
|
||||
),
|
||||
)),
|
||||
floating: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
impl Default for Workspace {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
layout: Layout {
|
||||
root: Some(split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedFirst(crate::state::LEFT_WIDTH.default),
|
||||
tabs(Panel::Library),
|
||||
split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedSecond(crate::state::RIGHT_WIDTH.default),
|
||||
split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Presets)),
|
||||
tabs(Panel::Tools),
|
||||
),
|
||||
)),
|
||||
floating: Vec::new(),
|
||||
},
|
||||
hidden: Vec::new(),
|
||||
}
|
||||
}
|
||||
fn error(error: DockError) -> String {
|
||||
error.to_string()
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
self.layout.validate().map_err(|error| error.to_string())?;
|
||||
if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() {
|
||||
return Err("The panel layout must contain its document view and bounded hidden panels".into());
|
||||
}
|
||||
// Saved settings cannot turn the protected document view into a floating or closable tab.
|
||||
if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) {
|
||||
return Err("The document view cannot float".into());
|
||||
}
|
||||
let mut nodes: Vec<_> = self.layout.root.iter().collect();
|
||||
while let Some(node) = nodes.pop() {
|
||||
match node {
|
||||
Node::Split { first, second, .. } => {
|
||||
nodes.push(first);
|
||||
nodes.push(second);
|
||||
}
|
||||
Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => {
|
||||
return Err("The document view cannot share a tab group".into());
|
||||
}
|
||||
Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => {
|
||||
return Err("The document view cannot be collapsed".into());
|
||||
}
|
||||
_ => {}
|
||||
/// 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],
|
||||
_ => {}
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (panel, location) in &self.hidden {
|
||||
if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) {
|
||||
return Err("A hidden panel has an invalid or duplicate identity".into());
|
||||
}
|
||||
if 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());
|
||||
}
|
||||
let mut probe = self.layout.clone();
|
||||
if let Err(error) = probe.restore(*panel, location)
|
||||
&& error != craft_ui::docking::DockError::MissingTarget
|
||||
{
|
||||
return Err(error.to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
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(())
|
||||
}
|
||||
|
||||
pub fn apply(&mut self, action: Action<Panel>) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
let mut next = self.clone();
|
||||
if let Action::Close { panel } = &action {
|
||||
let location = next.layout.location(panel).map_err(|error| error.to_string())?;
|
||||
next.hidden.retain(|(id, _)| id != panel);
|
||||
next.hidden.push((*panel, location));
|
||||
} else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action {
|
||||
next.hidden.retain(|(id, _)| id != panel);
|
||||
}
|
||||
next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?;
|
||||
next.validate()?;
|
||||
*self = next;
|
||||
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(())
|
||||
}
|
||||
|
||||
pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> {
|
||||
if self.layout.contains(&panel) == visible {
|
||||
return Ok(());
|
||||
}
|
||||
if !visible {
|
||||
return self.apply(Action::Close { panel });
|
||||
}
|
||||
self.validate()?;
|
||||
let mut next = self.clone();
|
||||
let mut location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone());
|
||||
next.hidden.retain(|(id, _)| *id != panel);
|
||||
if let Some(saved) = &mut location
|
||||
&& saved.floating.is_some()
|
||||
&& saved.anchor.as_ref().is_some_and(|anchor| !next.layout.floating.iter().any(|group| group.panels.contains(anchor)))
|
||||
{
|
||||
// The other members of a floating group may have been closed too.
|
||||
saved.anchor = None;
|
||||
saved.placement = Placement::Tab { before: None };
|
||||
}
|
||||
let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok());
|
||||
if !restored {
|
||||
next.layout
|
||||
.apply_with_permissions(
|
||||
Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) },
|
||||
permissions,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
next.validate()?;
|
||||
*self = next;
|
||||
Ok(())
|
||||
/// Shows a panel at its recorded place, else beside the photo on its usual side. 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)?;
|
||||
let mut next = workspace.clone();
|
||||
if next.set_visible(panel, true, None, permissions).is_ok() && validate(&next).is_ok() {
|
||||
*workspace = next;
|
||||
return Ok(());
|
||||
}
|
||||
apply(workspace, Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) })
|
||||
}
|
||||
|
||||
/// Membership is passive state; an explicit command also exposes the panel's body.
|
||||
pub fn reveal(&mut self, panel: Panel) -> Result<(), String> {
|
||||
self.set_visible(panel, true)?;
|
||||
self.apply(Action::Activate { panel })
|
||||
/// 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(&self, panel: Panel) -> bool {
|
||||
if self.layout.floating.iter().any(|group| group.panels.get(group.active) == Some(&panel)) {
|
||||
return true;
|
||||
}
|
||||
let mut nodes = self.layout.root.as_ref().map(|node| vec![node]).unwrap_or_default();
|
||||
while let Some(node) = nodes.pop() {
|
||||
match node {
|
||||
Node::Tabs { panels, active } if panels.get(*active) == Some(&panel) => return true,
|
||||
Node::Stack { entries } if entries.iter().any(|entry| entry.panel == panel && entry.open) => return true,
|
||||
Node::Split { first, second, .. } => {
|
||||
nodes.push(first);
|
||||
nodes.push(second);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
pub fn exposed_in(workspace: &Workspace, panel: Panel) -> bool {
|
||||
workspace.layout.panel_state(&panel) == PanelState::Front
|
||||
}
|
||||
|
||||
fn command(&mut self, params: &Value) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
if let Some(action) = params.get("action") {
|
||||
let action: Action<Panel> = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?;
|
||||
return self.apply(action);
|
||||
}
|
||||
let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?;
|
||||
if operation == "resizeSplit" {
|
||||
let path: Vec<bool> =
|
||||
serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?;
|
||||
let size: SplitSize =
|
||||
serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?;
|
||||
return self.apply(Action::ResizeSplit { path, size });
|
||||
}
|
||||
let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?;
|
||||
let action = match operation {
|
||||
"open" => return self.reveal(panel),
|
||||
"close" => Action::Close { panel },
|
||||
"activate" => Action::Activate { panel },
|
||||
"setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? },
|
||||
"resizeStack" => {
|
||||
let value = params.get("height").ok_or("height is required")?;
|
||||
let height = if value.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
value
|
||||
.as_f64()
|
||||
.filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height))
|
||||
.ok_or("height must be finite and bounded")? as f32,
|
||||
)
|
||||
};
|
||||
Action::ResizeStack { panel, height }
|
||||
}
|
||||
"float" | "moveFloating" => {
|
||||
let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?;
|
||||
let mut values = [0.0f32; 4];
|
||||
for (output, value) in values.iter_mut().zip(rect) {
|
||||
*output = value
|
||||
.as_f64()
|
||||
.filter(|number| number.is_finite() && number.abs() <= 1_000_000.0)
|
||||
.ok_or("rect values must be finite and bounded")? as f32;
|
||||
}
|
||||
if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } }
|
||||
}
|
||||
"move" => {
|
||||
let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?;
|
||||
let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") {
|
||||
"center" => Placement::Split(Zone::Center),
|
||||
"left" => Placement::Split(Zone::Left),
|
||||
"right" => Placement::Split(Zone::Right),
|
||||
"top" => Placement::Split(Zone::Top),
|
||||
"bottom" => Placement::Split(Zone::Bottom),
|
||||
_ => return Err("zone must be center, left, right, top or bottom".into()),
|
||||
};
|
||||
let placement = if let Some(before) = params.get("before") {
|
||||
if !matches!(placement, Placement::Split(Zone::Center)) {
|
||||
return Err("before only applies to a center move".into());
|
||||
}
|
||||
Placement::Tab {
|
||||
before: if before.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?)
|
||||
},
|
||||
}
|
||||
} else {
|
||||
placement
|
||||
};
|
||||
Action::Move { panel, anchor, placement }
|
||||
}
|
||||
_ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()),
|
||||
};
|
||||
self.apply(action)
|
||||
/// `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 {
|
||||
let mut workspace = Workspace::default();
|
||||
let mut workspace = default_workspace();
|
||||
if let Some(Node::Split { size, second, .. }) = workspace.layout.root.as_mut() {
|
||||
*size = SplitSize::FixedFirst(crate::state::LEFT_WIDTH.clamp(app.ui.left_width));
|
||||
if let Node::Split { size, .. } = second.as_mut() {
|
||||
@@ -295,9 +182,9 @@ fn from_ui(app: &crate::LightcraftApp) -> Workspace {
|
||||
}
|
||||
|
||||
fn sync(workspace: &mut Workspace, app: &crate::LightcraftApp) -> Result<(), String> {
|
||||
workspace.set_visible(Panel::Library, app.ui.left_panel)?;
|
||||
workspace.set_visible(Panel::Tools, app.ui.right != crate::state::RightPanel::None)?;
|
||||
workspace.set_visible(Panel::Presets, app.ui.presets)
|
||||
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) {
|
||||
@@ -328,7 +215,7 @@ fn publish_visibility(app: &mut crate::LightcraftApp, workspace: &Workspace) {
|
||||
}
|
||||
|
||||
pub(crate) fn exposed(app: &crate::LightcraftApp, panel: Panel) -> bool {
|
||||
app.ui.docking.as_ref().is_none_or(|workspace| workspace.exposed(panel))
|
||||
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> {
|
||||
@@ -349,7 +236,7 @@ pub(crate) fn reveal_command(app: &mut crate::LightcraftApp, id: &str) -> Result
|
||||
if let Some(panel) = panel {
|
||||
let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app));
|
||||
sync(&mut workspace, app)?;
|
||||
workspace.reveal(panel)?;
|
||||
reveal(&mut workspace, panel)?;
|
||||
cancel_replaced_gesture(app, Some(&workspace));
|
||||
app.ui.docking = Some(workspace);
|
||||
}
|
||||
@@ -381,13 +268,13 @@ pub fn command(app: &mut crate::LightcraftApp, reset: bool, params: &Value) -> R
|
||||
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 = Workspace::default();
|
||||
workspace = default_workspace();
|
||||
// The usual LightCraft starting workspace shows its current tool beside the photo.
|
||||
workspace.set_visible(Panel::Library, false)?;
|
||||
workspace.set_visible(Panel::Presets, false)?;
|
||||
set_visible(&mut workspace, Panel::Library, false)?;
|
||||
set_visible(&mut workspace, Panel::Presets, false)?;
|
||||
} else {
|
||||
sync(&mut workspace, app)?;
|
||||
workspace.command(params)?;
|
||||
run(&mut workspace, params)?;
|
||||
}
|
||||
if !renderer_action {
|
||||
cancel_replaced_gesture(app, Some(&workspace));
|
||||
@@ -400,7 +287,7 @@ fn command_impl(app: &mut crate::LightcraftApp, reset: bool, params: &Value, ren
|
||||
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) = workspace.validate().and_then(|()| sync(&mut workspace, 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}"), ui.input(|input| input.time) + 8.0, None));
|
||||
workspace = from_ui(app);
|
||||
let _ = sync(&mut workspace, app);
|
||||
@@ -413,7 +300,7 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) {
|
||||
style.min_pane = 96.0;
|
||||
style.float_label = crate::i18n::tr("Float panel").into();
|
||||
style.close_label = crate::i18n::tr("Close panel").into();
|
||||
style.move_label = crate::i18n::tr("Move to group").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();
|
||||
@@ -427,7 +314,7 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) {
|
||||
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::new("lightcraft-docking")).show_with_limits(
|
||||
let output = DockArea::new(egui::Id::new("lightcraft-docking")).locked(workspace.locked).show_with_limits(
|
||||
ui,
|
||||
&workspace.layout,
|
||||
&style,
|
||||
@@ -500,7 +387,7 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(
|
||||
let Some(workspace) = state.docking.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
workspace.validate()?;
|
||||
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;
|
||||
@@ -516,10 +403,10 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(group) = workspace.layout.floating.iter().find(|group| group.panels.contains(&panel)) {
|
||||
if let Some(group) = workspace.layout.floating.iter().find(|group| group.contains(&panel)) {
|
||||
let mut rect = group.rect;
|
||||
rect[2] = width;
|
||||
workspace.apply(Action::MoveFloating { panel, rect })?;
|
||||
apply(workspace, Action::MoveFloating { panel, rect })?;
|
||||
continue;
|
||||
}
|
||||
let mut nodes = workspace.layout.root.as_ref().map(|node| vec![(node, Vec::new())]).unwrap_or_default();
|
||||
@@ -529,7 +416,7 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(
|
||||
let contains = |node: &Node<Panel>| match node {
|
||||
Node::Tabs { panels, .. } => panels.contains(&panel),
|
||||
Node::Stack { entries } => entries.iter().any(|entry| entry.panel == panel),
|
||||
Node::Split { .. } => false,
|
||||
_ => false,
|
||||
};
|
||||
if *axis == SplitAxis::Horizontal {
|
||||
if contains(first) {
|
||||
@@ -548,7 +435,7 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(
|
||||
}
|
||||
}
|
||||
if let Some(action) = change {
|
||||
workspace.apply(action)?;
|
||||
apply(workspace, action)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -583,7 +470,7 @@ mod tests {
|
||||
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 { layout: Layout { root: Some(root), floating: Vec::new() }, hidden: Vec::new() }
|
||||
Workspace::new(Layout { root: Some(root), floating: Vec::new() })
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -594,23 +481,20 @@ mod tests {
|
||||
.into_iter()
|
||||
.map(|panel| craft_ui::docking::StackEntry { panel, open: panel != Panel::Tools, height: Some(180.0) })
|
||||
.collect();
|
||||
app.ui.docking = Some(Workspace {
|
||||
layout: Layout {
|
||||
root: Some(split(SplitAxis::Horizontal, SplitSize::FixedSecond(350.0), tabs(Panel::Canvas), Node::Stack { entries })),
|
||||
floating: Vec::new(),
|
||||
},
|
||||
hidden: Vec::new(),
|
||||
});
|
||||
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!(workspace.exposed(Panel::Tools));
|
||||
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();
|
||||
reloaded.validate().unwrap();
|
||||
assert!(reloaded.exposed(Panel::Tools));
|
||||
validate(&reloaded).unwrap();
|
||||
assert!(exposed_in(&reloaded, Panel::Tools));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -622,7 +506,7 @@ mod tests {
|
||||
(SplitAxis::Horizontal, SplitSize::FixedFirst(180.0), false),
|
||||
] {
|
||||
let workspace = tools_split(axis, size, first);
|
||||
workspace.validate().unwrap();
|
||||
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}");
|
||||
@@ -637,8 +521,8 @@ mod tests {
|
||||
publish_visibility(&mut app, &workspace);
|
||||
assert_eq!((app.ui.left_width, app.ui.right_width), (333.0, 310.0));
|
||||
}
|
||||
let mut workspace = Workspace::default();
|
||||
workspace.apply(Action::Float { panel: Panel::Tools, rect: [12.0, 34.0, 390.0, 450.0] }).unwrap();
|
||||
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);
|
||||
@@ -646,12 +530,50 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn legacy_width_update_preserves_hidden_top_panel_height() {
|
||||
let mut workspace = Workspace::default();
|
||||
workspace.apply(Action::Move { panel: Panel::Tools, anchor: Panel::Canvas, placement: Placement::Split(Zone::Top) }).unwrap();
|
||||
workspace.set_visible(Panel::Tools, false).unwrap();
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ fn inactive_same_mode_commands_reveal_without_closing_or_editing() {
|
||||
let undo = h.app.session.undo.len();
|
||||
let photos = serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::<Vec<_>>()).unwrap();
|
||||
h.app.run(command, json!({})).unwrap();
|
||||
assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Tools), "{command}");
|
||||
assert!(crate::docking::exposed_in(h.app.ui.docking.as_ref().unwrap(), Panel::Tools), "{command}");
|
||||
assert_ne!(h.app.ui.right, RightPanel::None);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert_eq!(serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::<Vec<_>>()).unwrap(), photos);
|
||||
@@ -64,10 +64,10 @@ fn inactive_same_mode_commands_reveal_without_closing_or_editing() {
|
||||
dock(&mut h, json!({"operation":"move","panel":"presets","target":"library","zone":"center"}));
|
||||
dock(&mut h, json!({"operation":"activate","panel":"library"}));
|
||||
h.app.run("panel.presets", json!({})).unwrap();
|
||||
assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Presets));
|
||||
assert!(crate::docking::exposed_in(h.app.ui.docking.as_ref().unwrap(), Panel::Presets));
|
||||
dock(&mut h, json!({"operation":"activate","panel":"library"}));
|
||||
dock(&mut h, json!({"operation":"open","panel":"presets"}));
|
||||
assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Presets));
|
||||
assert!(crate::docking::exposed_in(h.app.ui.docking.as_ref().unwrap(), Panel::Presets));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -81,7 +81,7 @@ fn crop_canvas_double_click_reveals_edit_in_actual_custom_workspace() {
|
||||
dock(&mut h, json!({"operation":"move","panel":"tools","target":"presets","zone":"center"}));
|
||||
dock(&mut h, json!({"operation":"activate","panel":"presets"}));
|
||||
assert_eq!(h.app.ui.right, RightPanel::Crop);
|
||||
assert!(!h.app.ui.docking.as_ref().unwrap().exposed(Panel::Tools));
|
||||
assert!(!crate::docking::exposed_in(h.app.ui.docking.as_ref().unwrap(), Panel::Tools));
|
||||
let library = h.app.ui.docking.as_ref().unwrap().layout.location(&Panel::Library).unwrap();
|
||||
let id = h.app.session.active().unwrap();
|
||||
let photo = serde_json::to_value(h.app.session.catalog.photo(id).unwrap().as_ref()).unwrap();
|
||||
@@ -94,7 +94,7 @@ fn crop_canvas_double_click_reveals_edit_in_actual_custom_workspace() {
|
||||
}
|
||||
assert_eq!(h.app.ui.right, RightPanel::Edit, "the real Canvas double-click completes Crop");
|
||||
let workspace = h.app.ui.docking.as_ref().unwrap();
|
||||
assert!(workspace.exposed(Panel::Tools), "tool.done reveals Tools after the Canvas callback");
|
||||
assert!(crate::docking::exposed_in(workspace, Panel::Tools), "tool.done reveals Tools after the Canvas callback");
|
||||
assert_eq!(workspace.layout.location(&Panel::Tools).unwrap().anchor, Some(Panel::Presets));
|
||||
assert_eq!(workspace.layout.location(&Panel::Library).unwrap(), library);
|
||||
assert_eq!(h.app.session.active(), Some(id));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Real library/tool panel moves preserve the selected tool and saved arrangement.
|
||||
use craft_ui::docking::{DockArea, Hit, Node};
|
||||
use egui::{Event, Modifiers, PointerButton, vec2};
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use lightcraft_ui_egui::{LightcraftApp, Services, UiState, docking::Panel, state::RightPanel};
|
||||
@@ -63,7 +64,7 @@ fn real_drag_float_close_reopen_and_state_reload_keep_photo_tool_mode() {
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Float panel").click();
|
||||
h.run_steps(5);
|
||||
let rect = h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect;
|
||||
let rect = h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.contains(&Panel::Tools)).unwrap().rect;
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").click_button(PointerButton::Secondary);
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Close panel").click();
|
||||
@@ -71,7 +72,7 @@ fn real_drag_float_close_reopen_and_state_reload_keep_photo_tool_mode() {
|
||||
assert_eq!(h.state().ui.right, RightPanel::None);
|
||||
h.state_mut().ui.right = RightPanel::Crop;
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect, rect);
|
||||
assert_eq!(h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.contains(&Panel::Tools)).unwrap().rect, rect);
|
||||
let saved = serde_json::to_string(&h.state().ui).unwrap();
|
||||
let expected = serde_json::to_value(&h.state().ui.docking).unwrap();
|
||||
let mut reloaded = harness(Some(serde_json::from_str(&saved).unwrap()), 1400.0, 1.0);
|
||||
@@ -86,6 +87,35 @@ fn real_drag_float_close_reopen_and_state_reload_keep_photo_tool_mode() {
|
||||
assert_eq!(reloaded.state().ui.right, RightPanel::Crop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_group_by_its_bar_moves_the_whole_group() {
|
||||
let mut h = harness(None, 1400.0, 1.0);
|
||||
h.state_mut().ui.presets = true;
|
||||
h.run_steps(4);
|
||||
h.state_mut().run("ui.dock", json!({"operation":"move","panel":"presets","target":"tools","zone":"center"})).unwrap();
|
||||
h.run_steps(6);
|
||||
let bar = DockArea::<Panel>::hit_rects(&h.ctx, egui::Id::new("lightcraft-docking"))
|
||||
.into_iter()
|
||||
.find_map(|(hit, rect)| matches!(hit, Hit::DragBar(Panel::Tools | Panel::Presets)).then_some(rect))
|
||||
.expect("the Tools and Presets group draws its drag bar");
|
||||
let target = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Library").rect().center();
|
||||
drag(&mut h, bar.center(), target);
|
||||
let layout = &h.state().ui.docking.as_ref().unwrap().layout;
|
||||
let mut nodes: Vec<_> = layout.root.iter().collect();
|
||||
let mut groups = Vec::new();
|
||||
while let Some(node) = nodes.pop() {
|
||||
match node {
|
||||
Node::Split { first, second, .. } => nodes.extend([&**first, &**second]),
|
||||
Node::Tabs { panels, .. } => groups.push(panels.clone()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut library = groups.into_iter().find(|panels| panels.contains(&Panel::Library)).unwrap();
|
||||
library.sort_by_key(|panel| panel.id());
|
||||
assert_eq!(library, [Panel::Library, Panel::Presets, Panel::Tools], "both panels of the dragged group joined Library");
|
||||
assert_eq!(h.state().ui.right, RightPanel::Edit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_docking_requests_preserve_layout_and_selected_tool() {
|
||||
let mut h = harness(None, 1400.0, 1.0);
|
||||
@@ -115,7 +145,7 @@ fn docking_renders_real_photo_panels_default_floating_and_redocked() {
|
||||
}
|
||||
h.run_steps(8);
|
||||
settle_photo(&mut h);
|
||||
h.state().ui.docking.as_ref().unwrap().validate().unwrap();
|
||||
lightcraft_ui_egui::docking::validate(h.state().ui.docking.as_ref().unwrap()).unwrap();
|
||||
if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") {
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
h.render().unwrap().save(format!("{directory}/light-{width}-{scale}-{state}.png")).unwrap();
|
||||
|
||||
@@ -364,11 +364,11 @@ the pending name. `ui.dialog.confirm` starts the same import into the selected a
|
||||
|
||||
## Panel docking
|
||||
|
||||
Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents.
|
||||
Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences as a craft-ui docking workspace (`docking`: `version`, `layout`, `hidden`, `saved`, `locked`); settings written before workspaces had a `version` load as version 1. These operations do not edit document contents.
|
||||
|
||||
Panel IDs: `library`, `tools`, `presets`. The protected center is `canvas`.
|
||||
|
||||
`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout.
|
||||
`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. Both commands return `{"docking": workspace}`.
|
||||
|
||||
Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tools","rect":[100,100,320,440]}}`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user