From 5ce171da234b42cedc82fb05902b1631f27ca61d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 19:23:08 +0000 Subject: [PATCH 01/24] 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; 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. --- Cargo.lock | 8 +- Cargo.toml | 4 +- apps/lightcraft/src/main.rs | 6 +- crates/ui-egui/src/docking.rs | 418 +++++++----------- .../ui-egui/src/tests_docking_acceptance.rs | 10 +- crates/ui-egui/tests/it/docking_workflows.rs | 36 +- docs/control-protocol.md | 4 +- 7 files changed, 221 insertions(+), 265 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 78de058c3..5d17222c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/Cargo.toml b/Cargo.toml index c22395b8a..3564f8491 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 7d30e69cd..d89a71ef8 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -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::>(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(); diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index e9f51a56f..4128b1628 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -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::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 { @@ -58,233 +59,119 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node, - /// Closed panels retain their most recent group, side, size or floating rectangle. - pub hidden: Vec<(Panel, Location)>, +/// The saved arrangement. Closed panels keep their last group, side, size or floating rect. +pub type Workspace = craft_ui::docking::Workspace; + +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) -> 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) -> 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 = 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 = - 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 = 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 { 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| 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"); + } } diff --git a/crates/ui-egui/src/tests_docking_acceptance.rs b/crates/ui-egui/src/tests_docking_acceptance.rs index 1dc97892e..5e496b822 100644 --- a/crates/ui-egui/src/tests_docking_acceptance.rs +++ b/crates/ui-egui/src/tests_docking_acceptance.rs @@ -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::>()).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::>()).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)); diff --git a/crates/ui-egui/tests/it/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs index aa80e8301..7538946b1 100644 --- a/crates/ui-egui/tests/it/docking_workflows.rs +++ b/crates/ui-egui/tests/it/docking_workflows.rs @@ -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::::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(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7a8033aa5..5e374da64 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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]}}`. From c964b7f654d930db29cdfe0c3cc75979e2d254af Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 19:44:32 +0000 Subject: [PATCH 02/24] Docking: a dragged divider beside Library or Tools keeps that panel's width craft-ui stores a moved divider as a ratio. After each frame the split beside a side panel takes the width the panel was drawn at, so the panel keeps its width when the window resizes and left_width / right_width follow the drag. --- crates/ui-egui/src/docking.rs | 50 +++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 4128b1628..52914dd91 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -314,6 +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 (mut library_width, mut tools_width) = (None, None); let output = DockArea::new(egui::Id::new("lightcraft-docking")).locked(workspace.locked).show_with_limits( ui, &workspace.layout, @@ -326,10 +327,12 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { canvas = Some(ui.new_child(egui::UiBuilder::new().id_salt("photo-view").max_rect(ui.max_rect()))); } Panel::Library => { + library_width = Some(ui.max_rect().width()); crate::widgets::register(ui.ctx(), "panel:left_panel", ui.max_rect()); crate::panels::left::show(app, ui); } Panel::Tools => { + tools_width = Some(ui.max_rect().width()); crate::widgets::register(ui.ctx(), "panel:right_panel", ui.max_rect()); crate::panels::right::show(app, ui); } @@ -364,6 +367,8 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { let stale = workspace.layout != rendered.layout || workspace.hidden != rendered.hidden; if stale { craft_ui::docking::cancel_drag::(ui.ctx(), egui::Id::new("lightcraft-docking")); + } else { + keep_side_widths(&mut workspace, [(Panel::Library, library_width), (Panel::Tools, tools_width)]); } publish_visibility(app, &workspace); app.ui.docking = Some(workspace); @@ -380,6 +385,45 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { } } +/// A dragged divider stores a ratio. The side panels keep the width they were drawn at instead, +/// so a window resize leaves them as they are and `left_width` / `right_width` follow them. +fn keep_side_widths(workspace: &mut Workspace, drawn: [(Panel, Option); 2]) { + let mut nodes = workspace.layout.root.as_ref().map(|node| vec![(node, Vec::new())]).unwrap_or_default(); + let mut changes = Vec::new(); + while let Some((node, path)) = nodes.pop() { + let Node::Split { axis, size, first, second } = node else { continue }; + if *axis == SplitAxis::Horizontal && matches!(size, SplitSize::Ratio(_)) { + let fixed = drawn.iter().find_map(|(panel, width)| { + // A width drawn from a ratio carries float noise; in hundredths a panel at its + // limit stores the limit itself. + let width = width.filter(|width| width.is_finite() && *width > 0.0).map(|width| (width * 100.0).round() / 100.0)?; + if holds(first, *panel) { Some(SplitSize::FixedFirst(width)) } else { holds(second, *panel).then_some(SplitSize::FixedSecond(width)) } + }); + if let Some(size) = fixed { + changes.push(Action::ResizeSplit { path: path.clone(), size }); + } + } + for (child, side) in [(second, true), (first, false)] { + let mut path = path.clone(); + path.push(side); + nodes.push((child, path)); + } + } + for action in changes { + // A size the layout refuses leaves the ratio, which still draws the same widths. + let _ = apply(workspace, action); + } +} + +/// Whether `node` is the group (tabs or accordion) holding `panel`. +fn holds(node: &Node, panel: Panel) -> bool { + match node { + Node::Tabs { panels, .. } => panels.contains(&panel), + Node::Stack { entries } => entries.iter().any(|entry| entry.panel == panel), + _ => false, + } +} + /// Keep the established ui.set width controls connected to the panel's current 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); @@ -413,11 +457,7 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<( 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, - }; + let contains = |node: &Node| holds(node, panel); if *axis == SplitAxis::Horizontal { if contains(first) { change = Some(Action::ResizeSplit { path: path.clone(), size: SplitSize::FixedFirst(width) }); From a70d7638a07fc4ceb433ac833c0fcee79f360ab3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 20:45:00 +0000 Subject: [PATCH 03/24] Docking: repin craft-libs 1774443; a dragged divider keeps its panel's width without app code craft-ui and craft-docpath are pinned to craft-libs 17744430be327d0257496a6a31c8d38fe20a6756, where a divider drag keeps the split's sizing mode. LightCraft no longer turns dragged ratios back into side-panel widths. --- Cargo.lock | 4 +-- Cargo.toml | 4 +-- crates/ui-egui/src/docking.rs | 50 ++++------------------------------- 3 files changed, 9 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5d17222c1..a15fe2744 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,12 +1084,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" +source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" +source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" dependencies = [ "egui", "log", diff --git a/Cargo.toml b/Cargo.toml index 3564f8491..20118c218 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3", features = ["serde"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 52914dd91..4128b1628 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -314,7 +314,6 @@ 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 (mut library_width, mut tools_width) = (None, None); let output = DockArea::new(egui::Id::new("lightcraft-docking")).locked(workspace.locked).show_with_limits( ui, &workspace.layout, @@ -327,12 +326,10 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { canvas = Some(ui.new_child(egui::UiBuilder::new().id_salt("photo-view").max_rect(ui.max_rect()))); } Panel::Library => { - library_width = Some(ui.max_rect().width()); crate::widgets::register(ui.ctx(), "panel:left_panel", ui.max_rect()); crate::panels::left::show(app, ui); } Panel::Tools => { - tools_width = Some(ui.max_rect().width()); crate::widgets::register(ui.ctx(), "panel:right_panel", ui.max_rect()); crate::panels::right::show(app, ui); } @@ -367,8 +364,6 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { let stale = workspace.layout != rendered.layout || workspace.hidden != rendered.hidden; if stale { craft_ui::docking::cancel_drag::(ui.ctx(), egui::Id::new("lightcraft-docking")); - } else { - keep_side_widths(&mut workspace, [(Panel::Library, library_width), (Panel::Tools, tools_width)]); } publish_visibility(app, &workspace); app.ui.docking = Some(workspace); @@ -385,45 +380,6 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { } } -/// A dragged divider stores a ratio. The side panels keep the width they were drawn at instead, -/// so a window resize leaves them as they are and `left_width` / `right_width` follow them. -fn keep_side_widths(workspace: &mut Workspace, drawn: [(Panel, Option); 2]) { - let mut nodes = workspace.layout.root.as_ref().map(|node| vec![(node, Vec::new())]).unwrap_or_default(); - let mut changes = Vec::new(); - while let Some((node, path)) = nodes.pop() { - let Node::Split { axis, size, first, second } = node else { continue }; - if *axis == SplitAxis::Horizontal && matches!(size, SplitSize::Ratio(_)) { - let fixed = drawn.iter().find_map(|(panel, width)| { - // A width drawn from a ratio carries float noise; in hundredths a panel at its - // limit stores the limit itself. - let width = width.filter(|width| width.is_finite() && *width > 0.0).map(|width| (width * 100.0).round() / 100.0)?; - if holds(first, *panel) { Some(SplitSize::FixedFirst(width)) } else { holds(second, *panel).then_some(SplitSize::FixedSecond(width)) } - }); - if let Some(size) = fixed { - changes.push(Action::ResizeSplit { path: path.clone(), size }); - } - } - for (child, side) in [(second, true), (first, false)] { - let mut path = path.clone(); - path.push(side); - nodes.push((child, path)); - } - } - for action in changes { - // A size the layout refuses leaves the ratio, which still draws the same widths. - let _ = apply(workspace, action); - } -} - -/// Whether `node` is the group (tabs or accordion) holding `panel`. -fn holds(node: &Node, panel: Panel) -> bool { - match node { - Node::Tabs { panels, .. } => panels.contains(&panel), - Node::Stack { entries } => entries.iter().any(|entry| entry.panel == panel), - _ => false, - } -} - /// Keep the established ui.set width controls connected to the panel's current 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); @@ -457,7 +413,11 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<( let mut change = None; while let Some((node, path)) = nodes.pop() { if let Node::Split { axis, first, second, .. } = node { - let contains = |node: &Node| holds(node, panel); + 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) }); From 5b7a4e9a9458b0821429663e365c61bdc36e0d3d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 21:31:46 +0000 Subject: [PATCH 04/24] Docs: ui.dock move lists the tab, above and below zones --- docs/control-protocol.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 5e374da64..c25deb98c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -368,7 +368,7 @@ Library, Tools, and Presets panels can be regrouped, split, floated within the a 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. Both commands return `{"docking": workspace}`. +`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|tab|left|right|top|bottom|above|below`; PascalCase names also parse). Center and tab group with the target; side zones split beside it; above and below put the panel in a new group above or below the target's. `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]}}`. From a27d8c05d49d609c443f5edb20b1e2835b011a78 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 22:19:29 +0000 Subject: [PATCH 05/24] Docs: ui.dock above and below name the target's group --- docs/control-protocol.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c25deb98c..ebaebcbb6 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -368,7 +368,7 @@ Library, Tools, and Presets panels can be regrouped, split, floated within the a 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|tab|left|right|top|bottom|above|below`; PascalCase names also parse). Center and tab group with the target; side zones split beside it; above and below put the panel in a new group above or below the target's. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. Both commands return `{"docking": workspace}`. +`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|tab|left|right|top|bottom|above|below`; PascalCase names also parse). Center and tab group with the target; side zones split beside it; above and below put the panel in a new group above or below the target's group. `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]}}`. From b2f624631425ff10949ccdbfb9bdc957ada5134a Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 22:29:00 +0000 Subject: [PATCH 06/24] Docking: repin craft-libs 6e7f285 (the docking follow-ups) --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a15fe2744..5c90435b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,12 +1084,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "egui", "log", diff --git a/Cargo.toml b/Cargo.toml index 20118c218..136644fce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = "17744430be327d0257496a6a31c8d38fe20a6756", features = ["serde"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } From ee1576682abc0ec7e6672c7cae815ba6fb4000ec Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:33:27 +0000 Subject: [PATCH 07/24] Docking: a drop at the window's edge makes no dock column The side panels are frames whose widths LightCraft tracks, so new_dock_zones is off. Test: an edge drop of Tools leaves the layout without a Node::Dock. --- crates/ui-egui/src/docking.rs | 2 ++ crates/ui-egui/tests/it/docking_workflows.rs | 23 ++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 4128b1628..636081967 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -298,6 +298,8 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { app.ui.docking = Some(workspace.clone()); let mut style = DockStyle::from_ui(ui); style.min_pane = 96.0; + // The side panels are frames whose widths LightCraft tracks; dock columns are not offered. + style.new_dock_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(); diff --git a/crates/ui-egui/tests/it/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs index 7538946b1..a8e6c3d51 100644 --- a/crates/ui-egui/tests/it/docking_workflows.rs +++ b/crates/ui-egui/tests/it/docking_workflows.rs @@ -116,6 +116,29 @@ fn dragging_a_group_by_its_bar_moves_the_whole_group() { assert_eq!(h.state().ui.right, RightPanel::Edit); } +/// LightCraft's side panels live in frames; a drop at the window's edge makes no dock column. +#[test] +fn an_edge_drop_does_not_create_a_dock() { + for at_right in [false, true] { + let mut h = harness(None, 1400.0, 1.0); + let library = h.state().widgets.iter().find(|(id, _)| id == "panel:left_panel").unwrap().1; + let tools = h.state().widgets.iter().find(|(id, _)| id == "panel:right_panel").unwrap().1; + let x = if at_right { tools.right() - 4.0 } else { library.left() + 4.0 }; + let source = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").rect().center(); + drag(&mut h, source, egui::pos2(x, 500.0)); + let layout = &h.state().ui.docking.as_ref().unwrap().layout; + assert!(layout.contains(&Panel::Tools)); + let mut nodes: Vec<_> = layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Dock { .. } => panic!("a drop at x = {x} made a dock: {layout:?}"), + _ => {} + } + } + } +} + #[test] fn invalid_docking_requests_preserve_layout_and_selected_tool() { let mut h = harness(None, 1400.0, 1.0); From e442e3d220a8ac43460e8927da2afe56da76392f Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:36:03 +0000 Subject: [PATCH 08/24] Docking: a panel with no recorded place opens beside the photo on its side Showing a panel that has no hidden entry skips craft-ui's right-edge fallback and opens it beside the photo view on the panel's default side. Test: Library opens to the left of the photo, Presets to the right. --- crates/ui-egui/src/docking.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 636081967..017ffe842 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -128,10 +128,14 @@ fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result 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(()); + // Without a recorded place craft-ui opens the panel at the right edge; LightCraft puts it + // beside the photo instead. + if workspace.hidden.iter().any(|(hidden, _)| *hidden == panel) { + 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()) }) } @@ -578,4 +582,15 @@ mod tests { assert_eq!(saved["version"], 2); assert_eq!(saved["layout"]["floating"][0]["panels"], json!(["tools"]), "a one-group window keeps the v1 shape"); } + + #[test] + fn a_panel_without_a_recorded_place_opens_beside_the_photo_on_its_side() { + for (panel, zone) in [(Panel::Library, Zone::Left), (Panel::Presets, Zone::Right)] { + 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() }); + set_visible(&mut workspace, panel, true).unwrap(); + let location = workspace.layout.location(&panel).unwrap(); + assert_eq!((location.anchor, location.placement), (Some(Panel::Canvas), Placement::Split(zone)), "{panel:?}"); + } + } } From 63ab092576a8d2e52ad565f281d25ea74378cd13 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:37:41 +0000 Subject: [PATCH 09/24] View: F8 on a right panel that isn't showing brings it forward in its mode When the right panel is open but minimized or behind another tab, F8 keeps its mode instead of switching to the one it last hid, and view.rightPanel reveals Tools. Test: F8 on a minimized floating Tools window in Crop opens the window and stays in Crop. --- crates/ui-egui/src/docking.rs | 1 + crates/ui-egui/src/menus.rs | 4 +++- crates/ui-egui/src/shortcuts.rs | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 017ffe842..2fc69e0da 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -229,6 +229,7 @@ pub(crate) fn reveal_command(app: &mut crate::LightcraftApp, id: &str) -> Result // 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 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a162e6ca6..baf3d4514 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -865,7 +865,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option RightPanel::Edit, p => p, @@ -878,6 +878,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 032548f6a..435936190 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -863,6 +863,24 @@ mod tests { assert_eq!(h.app.ui.right, RightPanel::Edit); } + /// F8 on a minimized Tools window opens it again in the mode it had. + #[test] + fn classic_f8_shows_a_minimized_tools_window_and_keeps_its_mode() { + use crate::{docking::Panel, state::RightPanel}; + let mut h = library(); + h.app.ui.view = crate::state::ViewMode::Detail; + h.app.ui.right = RightPanel::Edit; + h.settle(std::time::Duration::from_secs(10)); + h.app.run("ui.dock", json!({"operation": "float", "panel": "tools", "rect": [400, 100, 320, 500]})).unwrap(); + h.app.run("ui.dock", json!({"action": {"SetMinimized": {"panel": "tools", "minimized": true}}})).unwrap(); + h.app.ui.right = RightPanel::Crop; + h.step(); + assert!(!crate::docking::exposed(&h.app, Panel::Tools)); + key(&mut h, "F8", false); + assert_eq!(h.app.ui.right, RightPanel::Crop); + assert!(crate::docking::exposed(&h.app, Panel::Tools), "the window opens"); + } + #[test] fn shift_culling_keys_advance_exactly_once() { use lightcraft_catalog::{ColorLabel, Flag, PhotoId}; From 1e4cc14dfbceb3c26c3294d5a208fde9d3af776f Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:41:54 +0000 Subject: [PATCH 10/24] Docking: group and window menu labels go through tr() in sentence case --- crates/ui-egui/src/docking.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 2fc69e0da..be4c9d060 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -311,6 +311,16 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { 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_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; From 02aa1e0f59e80f2ee271afa64fa5212fd2d0347a Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:41:54 +0000 Subject: [PATCH 11/24] Docs: only the five split zones of ui.dock move have PascalCase names --- docs/control-protocol.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ebaebcbb6..e4c03a9d7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -368,7 +368,7 @@ Library, Tools, and Presets panels can be regrouped, split, floated within the a 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|tab|left|right|top|bottom|above|below`; PascalCase names also parse). Center and tab group with the target; side zones split beside it; above and below put the panel in a new group above or below the target's group. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. Both commands return `{"docking": workspace}`. +`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|tab|left|right|top|bottom|above|below`; `Center`, `Left`, `Right`, `Top` and `Bottom` also parse, while `tab`, `above` and `below` are lowercase only). Center and tab group with the target; side zones split beside it; above and below put the panel in a new group above or below the target's group. `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]}}`. From 8e16c05cbb1e3cf609cef990bf4f94ccd359a20c Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 00:34:42 +0000 Subject: [PATCH 12/24] Docking: a panel whose saved place is gone opens beside the photo on its side When a hidden panel's anchor is neither shown nor hidden, craft-ui falls back to the given anchor's tabs. The photo view is that anchor and takes no tabs, so the panel opens beside it on its default side instead of at the right edge. Test: Library anchored on an absent Presets opens to the left of the photo. --- crates/ui-egui/src/docking.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index be4c9d060..6feebe5cb 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -132,7 +132,9 @@ fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result // beside the photo instead. if workspace.hidden.iter().any(|(hidden, _)| *hidden == panel) { let mut next = workspace.clone(); - if next.set_visible(panel, true, None, permissions).is_ok() && validate(&next).is_ok() { + // A saved 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 opens beside it below. + if next.set_visible(panel, true, Some(Panel::Canvas), permissions).is_ok() && validate(&next).is_ok() { *workspace = next; return Ok(()); } @@ -604,4 +606,25 @@ mod tests { assert_eq!((location.anchor, location.placement), (Some(Panel::Canvas), Placement::Split(zone)), "{panel:?}"); } } + + #[test] + fn a_panel_whose_saved_place_is_gone_opens_beside_the_photo_on_its_side() { + 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(); + let location = workspace.layout.location(&Panel::Library).unwrap(); + assert_eq!((location.anchor, location.placement), (Some(Panel::Canvas), Placement::Split(Zone::Left))); + } } From e8588b925b477043e5947584884d9f0c5fda7acb Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 03:59:13 +0000 Subject: [PATCH 13/24] Docking: repin craft-ui and craft-docpath to craft-libs 8a503cf --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5c90435b2..d1d45b277 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,12 +1084,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" dependencies = [ "egui", "log", diff --git a/Cargo.toml b/Cargo.toml index 136644fce..e9d36bd16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", features = ["serde"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } From 4e6c210a5f4cd728d8001f2701f1320c05f95927 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 05:57:50 +0000 Subject: [PATCH 14/24] Docking: repin craft-ui and craft-docpath to craft-libs 4dc543c --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d1d45b277..eb0c8168a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,12 +1084,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" +source = "git+https://github.com/storytold/craft-libs?rev=4dc543cb449cc232e4c3d58f78a05c1ab169db9e#4dc543cb449cc232e4c3d58f78a05c1ab169db9e" [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" +source = "git+https://github.com/storytold/craft-libs?rev=4dc543cb449cc232e4c3d58f78a05c1ab169db9e#4dc543cb449cc232e4c3d58f78a05c1ab169db9e" dependencies = [ "egui", "log", diff --git a/Cargo.toml b/Cargo.toml index e9d36bd16..70b4db861 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = "8a503cfe79f0a4e7da2e167e01e71155668e0cee", features = ["serde"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "4dc543cb449cc232e4c3d58f78a05c1ab169db9e", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "4dc543cb449cc232e4c3d58f78a05c1ab169db9e" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } From 8ae15d4f5e891605394f977920e1d6e847219bd3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 12:17:28 +0000 Subject: [PATCH 15/24] Docking: side panels in dock columns; saved split-frame layouts reset once --- apps/lightcraft/src/main.rs | 4 +- crates/ui-egui/src/docking.rs | 285 +++++++++++++++--- crates/ui-egui/src/state.rs | 4 + .../ui-egui/src/tests_docking_acceptance.rs | 30 +- crates/ui-egui/tests/it/docking_workflows.rs | 59 ++-- 5 files changed, 302 insertions(+), 80 deletions(-) diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 0cab9e189..b971e5519 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -259,10 +259,12 @@ fn load_prefs(in_memory: bool) -> (Option, Option, bool) { load_prefs_at(&path, !in_memory) } -/// Compatibility recovery is limited to persisted docking, not runtime UiState commands. +/// Compatibility recovery is limited to persisted docking, not runtime UiState commands. An +/// arrangement from an older docking version starts again from the default without a warning. fn decode_saved_ui(bytes: &[u8]) -> Result<(UiState, bool), String> { let mut saved: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| error.to_string())?; let fields = saved.as_object_mut().ok_or("settings must be an object")?; + lightcraft_ui_egui::docking::drop_outdated(fields); let recovered = fields.get("docking").is_some_and(|value| match serde_json::from_value::>(value.clone()) { Ok(Some(workspace)) => lightcraft_ui_egui::docking::validate(&workspace).is_err(), diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 6feebe5cb..59c00b0f6 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -2,8 +2,8 @@ //! Document contents remain owned by their existing view implementations. use craft_ui::docking::{ - Action, DockArea, DockCommand, DockError, DockOperation, DockStyle, FloatSpace, Layout, Node, PanelLimits, PanelState, Permissions, Placement, - UiDock, Zone, + 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}; @@ -36,16 +36,38 @@ impl Panel { Self::Presets => "Presets", } } - fn default_side(self) -> Zone { + /// 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 => Zone::Right, - Self::Library => Zone::Left, - Self::Tools => Zone::Right, - Self::Presets => Zone::Right, + 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() } @@ -62,23 +84,49 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node; -pub fn default_workspace() -> 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(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), - ), + 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() } @@ -118,7 +166,7 @@ fn apply(workspace: &mut Workspace, action: Action) -> Result<(), String> Ok(()) } -/// Shows a panel at its recorded place, else beside the photo on its usual side. A panel that +/// 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 { @@ -128,18 +176,23 @@ fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result return apply(workspace, Action::Close { panel }); } validate(workspace)?; - // Without a recorded place craft-ui opens the panel at the right edge; LightCraft puts it - // beside the photo instead. + // 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(); - // A saved 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 opens beside it below. if next.set_visible(panel, true, Some(Panel::Canvas), 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()) }) + 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. @@ -177,14 +230,7 @@ fn run(workspace: &mut Workspace, params: &Value) -> Result<(), String> { } fn from_ui(app: &crate::LightcraftApp) -> Workspace { - 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() { - *size = SplitSize::FixedSecond(crate::state::RIGHT_WIDTH.clamp(app.ui.right_width)); - } - } - 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> { @@ -207,10 +253,12 @@ fn publish_visibility(app: &mut crate::LightcraftApp, workspace: &Workspace) { let Ok(location) = workspace.layout.location(&panel) else { continue; }; - let value = match (location.floating, location.placement, location.size) { - (Some([_, _, width, _]), _, _) - | (None, Placement::Split(Zone::Left), Some(SplitSize::FixedFirst(width))) - | (None, Placement::Split(Zone::Right), Some(SplitSize::FixedSecond(width))) => Some(width), + 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, }; @@ -305,8 +353,10 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { app.ui.docking = Some(workspace.clone()); let mut style = DockStyle::from_ui(ui); style.min_pane = 96.0; - // The side panels are frames whose widths LightCraft tracks; dock columns are not offered. - style.new_dock_zones = false; + // 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(); @@ -320,6 +370,12 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { 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(); @@ -399,7 +455,8 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { } } -/// Keep the established ui.set width controls connected to the panel's current parent split. +/// 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); @@ -415,6 +472,10 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<( 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)), @@ -428,6 +489,10 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<( 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() { @@ -470,6 +535,23 @@ fn panel_limits(panel: &Panel) -> PanelLimits { 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::*; @@ -533,7 +615,7 @@ mod tests { } #[test] - fn legacy_width_publication_reads_own_horizontal_and_floating_widths() { + 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(); @@ -597,18 +679,125 @@ mod tests { } #[test] - fn a_panel_without_a_recorded_place_opens_beside_the_photo_on_its_side() { - for (panel, zone) in [(Panel::Library, Zone::Left), (Panel::Presets, Zone::Right)] { - 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() }); - set_visible(&mut workspace, panel, true).unwrap(); - let location = workspace.layout.location(&panel).unwrap(); - assert_eq!((location.anchor, location.placement), (Some(Panel::Canvas), Placement::Split(zone)), "{panel:?}"); + 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_panel_whose_saved_place_is_gone_opens_beside_the_photo_on_its_side() { + 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. @@ -624,7 +813,7 @@ mod tests { workspace.hidden.push((Panel::Library, location)); validate(&workspace).unwrap(); set_visible(&mut workspace, Panel::Library, true).unwrap(); - let location = workspace.layout.location(&Panel::Library).unwrap(); - assert_eq!((location.anchor, location.placement), (Some(Panel::Canvas), Placement::Split(Zone::Left))); + validate(&workspace).unwrap(); + assert_eq!(columns(&workspace, Side::Start), [(crate::state::LEFT_WIDTH.default, vec![vec![Panel::Library]])]); } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 0bbd619d8..f9da46444 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -782,6 +782,9 @@ pub struct UiState { pub window: Option, /// Shared panel arrangement; absent in legacy settings until their first layout. pub docking: Option, + /// The arrangement generation `docking` was saved in ([`crate::docking::DOCKING_VERSION`]); + /// settings files are read through [`crate::docking::drop_outdated`]. + pub docking_version: u32, #[serde(default = "crate::i18n::default_language")] pub language: crate::i18n::Locale, /// The Build Previews run last announced (its identity, finished?). @@ -1482,6 +1485,7 @@ impl Default for UiState { UiState { window: None, docking: None, + docking_version: crate::docking::DOCKING_VERSION, language: crate::i18n::default_language(), preview_build_seen: None, unsaved_seen: false, diff --git a/crates/ui-egui/src/tests_docking_acceptance.rs b/crates/ui-egui/src/tests_docking_acceptance.rs index 5e496b822..940451f19 100644 --- a/crates/ui-egui/src/tests_docking_acceptance.rs +++ b/crates/ui-egui/src/tests_docking_acceptance.rs @@ -44,6 +44,30 @@ fn release(h: &mut Headless, pos: egui::Pos2) { h.step(); } +#[test] +fn window_menu_reopens_closed_panels_in_their_dock_columns() { + use craft_ui::docking::Side; + let mut h = app(); + let columns = |h: &Headless, side| { + crate::docking::columns(h.app.ui.docking.as_ref().unwrap(), side).into_iter().map(|(_, groups)| groups).collect::>() + }; + let end = vec![vec![vec![Panel::Tools]], vec![vec![Panel::Presets]]]; + h.app.run("panel.presets", json!({})).unwrap(); + h.step(); + assert_eq!(columns(&h, Side::End), end, "Presets opens on Tools' photo side"); + dock(&mut h, json!({"operation":"close","panel":"tools"})); + assert_eq!(h.app.ui.right, RightPanel::None); + h.app.run("panel.edit", json!({})).unwrap(); + h.step(); + assert_eq!(columns(&h, Side::End), end, "Tools reopens at the window edge"); + let width = crate::docking::columns(h.app.ui.docking.as_ref().unwrap(), Side::Start).first().unwrap().0; + dock(&mut h, json!({"operation":"close","panel":"library"})); + assert!(!h.app.ui.left_panel); + h.app.run("view.leftPanel", json!({})).unwrap(); + h.step(); + assert_eq!(crate::docking::columns(h.app.ui.docking.as_ref().unwrap(), Side::Start), [(width, vec![vec![Panel::Library]])]); +} + #[test] fn inactive_same_mode_commands_reveal_without_closing_or_editing() { let mut h = app(); @@ -271,8 +295,8 @@ fn covered_keyword_rejects_hierarchy_move_and_uncovered_target_moves_exact_keywo #[test] fn reset_during_held_divider_drag_keeps_replacement_layout_on_later_movement() { let mut h = app(); - let divider = egui::Id::new("lightcraft-docking").with(("split", Vec::::new())); - let from = h.view.ctx.read_response(divider).expect("the custom workspace root divider is rendered").rect.center(); + let divider = egui::Id::new("lightcraft-docking").with(("column-resize", Some(Panel::Library))); + let from = h.view.ctx.read_response(divider).expect("the Library column's divider is rendered").rect.center(); let before = h.app.ui.docking.as_ref().unwrap().layout.clone(); h.app.synthetic.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); h.step(); @@ -303,7 +327,7 @@ fn reset_during_held_divider_drag_keeps_replacement_layout_on_later_movement() { fn held_divider_replacement_route(route: &str) { let mut h = app(); - let divider = egui::Id::new("lightcraft-docking").with(("split", Vec::::new())); + let divider = egui::Id::new("lightcraft-docking").with(("column-resize", Some(Panel::Library))); let from = h.view.ctx.read_response(divider).unwrap().rect.center(); let photos = serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::>()).unwrap(); h.app.synthetic.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); diff --git a/crates/ui-egui/tests/it/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs index a8e6c3d51..1d360a6b2 100644 --- a/crates/ui-egui/tests/it/docking_workflows.rs +++ b/crates/ui-egui/tests/it/docking_workflows.rs @@ -1,5 +1,5 @@ //! Real library/tool panel moves preserve the selected tool and saved arrangement. -use craft_ui::docking::{DockArea, Hit, Node}; +use craft_ui::docking::{DockArea, Hit, Node, Side}; use egui::{Event, Modifiers, PointerButton, vec2}; use egui_kittest::{Harness, kittest::Queryable}; use lightcraft_ui_egui::{LightcraftApp, Services, UiState, docking::Panel, state::RightPanel}; @@ -100,42 +100,45 @@ fn dragging_a_group_by_its_bar_moves_the_whole_group() { .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(); + let mut library = start_dock(&h).into_iter().flatten().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); } -/// LightCraft's side panels live in frames; a drop at the window's edge makes no dock column. +/// The start dock's columns from the window edge inwards, each as its groups' panels. +fn start_dock(h: &Harness<'_, LightcraftApp>) -> Vec>> { + let layout = &h.state().ui.docking.as_ref().unwrap().layout; + let mut nodes: Vec<_> = layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Dock { side: Side::Start, columns } => { + return columns.iter().map(|column| column.groups.iter().map(|group| group.panels.clone()).collect()).collect(); + } + _ => {} + } + } + Vec::new() +} + +/// In a dock column, a group's top third stacks above it, its photo-side third adds a column +/// there and its middle joins its tabs. #[test] -fn an_edge_drop_does_not_create_a_dock() { - for at_right in [false, true] { +fn a_tab_dropped_on_a_dock_group_stacks_adds_a_column_or_joins_by_where_it_lands() { + let cases: [(fn(egui::Rect) -> egui::Pos2, Vec>>); 3] = [ + (|r| egui::pos2(r.center().x, r.top() + r.height() / 6.0), vec![vec![vec![Panel::Tools], vec![Panel::Library]]]), + (|r| egui::pos2(r.right() - r.width() / 6.0, r.center().y), vec![vec![vec![Panel::Library]], vec![vec![Panel::Tools]]]), + (|r| r.center(), vec![vec![vec![Panel::Library, Panel::Tools]]]), + ]; + for (at, expected) in cases { let mut h = harness(None, 1400.0, 1.0); let library = h.state().widgets.iter().find(|(id, _)| id == "panel:left_panel").unwrap().1; - let tools = h.state().widgets.iter().find(|(id, _)| id == "panel:right_panel").unwrap().1; - let x = if at_right { tools.right() - 4.0 } else { library.left() + 4.0 }; let source = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").rect().center(); - drag(&mut h, source, egui::pos2(x, 500.0)); - let layout = &h.state().ui.docking.as_ref().unwrap().layout; - assert!(layout.contains(&Panel::Tools)); - let mut nodes: Vec<_> = layout.root.iter().collect(); - while let Some(node) = nodes.pop() { - match node { - Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), - Node::Dock { .. } => panic!("a drop at x = {x} made a dock: {layout:?}"), - _ => {} - } - } + drag(&mut h, source, at(library)); + assert_eq!(start_dock(&h), expected, "dropped at {:?} of {library:?}", at(library)); + lightcraft_ui_egui::docking::validate(h.state().ui.docking.as_ref().unwrap()).unwrap(); + assert_eq!(h.state().ui.right, RightPanel::Edit); } } From b477efd3da21c815c14069f78c2ba350dacde21e Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 12:17:31 +0000 Subject: [PATCH 16/24] Docking: repin craft-ui and craft-docpath to craft-libs b6d35e2 --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eb0c8168a..c5d73c571 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,12 +1084,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=4dc543cb449cc232e4c3d58f78a05c1ab169db9e#4dc543cb449cc232e4c3d58f78a05c1ab169db9e" +source = "git+https://github.com/storytold/craft-libs?rev=b6d35e28f4d19145c9c7595742cc3ea6fb8352d5#b6d35e28f4d19145c9c7595742cc3ea6fb8352d5" [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=4dc543cb449cc232e4c3d58f78a05c1ab169db9e#4dc543cb449cc232e4c3d58f78a05c1ab169db9e" +source = "git+https://github.com/storytold/craft-libs?rev=b6d35e28f4d19145c9c7595742cc3ea6fb8352d5#b6d35e28f4d19145c9c7595742cc3ea6fb8352d5" dependencies = [ "egui", "log", diff --git a/Cargo.toml b/Cargo.toml index 70b4db861..8a87d0e24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = "4dc543cb449cc232e4c3d58f78a05c1ab169db9e", features = ["serde"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "4dc543cb449cc232e4c3d58f78a05c1ab169db9e" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "b6d35e28f4d19145c9c7595742cc3ea6fb8352d5", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b6d35e28f4d19145c9c7595742cc3ea6fb8352d5" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } From 4bc5d2b587fc2f64de333f59e62ed3945a3f8c4c Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 12:17:42 +0000 Subject: [PATCH 17/24] Docking: the web app resets a saved split-frame layout once too --- apps/lightcraft-web/src/web.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 8cdb32462..fe77ce1e1 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -472,7 +472,12 @@ impl WebApp { let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone())) .with_chinese_font(chinese_font); let ui_written = files.get("ui.json").unwrap_or_default(); - if let Ok(ui) = serde_json::from_slice::(&ui_written) { + // an arrangement from an older docking version starts again from the default + let mut saved = serde_json::from_slice::(&ui_written).unwrap_or_default(); + if let Some(fields) = saved.as_object_mut() { + lightcraft_ui_egui::docking::drop_outdated(fields); + } + if let Ok(ui) = serde_json::from_value::(saved) { app.ui = ui; } app.ui = app.ui.sanitized(); From 577b6cfcedb83a648a973b85c3ed8f0df7b49b48 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 12:35:44 +0000 Subject: [PATCH 18/24] Top bar: the title-bar drag uses craft-window's title_bar, and a panel over the bar keeps the window still on macOS --- Cargo.lock | 12 +++++ Cargo.toml | 1 + crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/lib.rs | 8 ++- crates/ui-egui/src/panels/topbar.rs | 40 +++------------ crates/ui-egui/src/tests_titlebar.rs | 22 ++++++++ crates/ui-egui/src/titlebar.rs | 76 ---------------------------- 7 files changed, 50 insertions(+), 110 deletions(-) delete mode 100644 crates/ui-egui/src/titlebar.rs diff --git a/Cargo.lock b/Cargo.lock index 78de058c3..7b7dbfb41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1095,6 +1095,17 @@ dependencies = [ "serde", ] +[[package]] +name = "craft-window" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=8177bad411b577f3204deb0a0e074516d76a0b93#8177bad411b577f3204deb0a0e074516d76a0b93" +dependencies = [ + "egui", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "wayland-client", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -3522,6 +3533,7 @@ name = "lightcraft-ui-egui" version = "0.13.0" dependencies = [ "craft-ui", + "craft-window", "egui", "egui_kittest", "lightcraft-catalog", diff --git a/Cargo.toml b/Cargo.toml index c22395b8a..b641f471a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ 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-window = { git = "https://github.com/storytold/craft-libs", rev = "8177bad411b577f3204deb0a0e074516d76a0b93", features = ["egui"] } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 5a1e214dd..0673e087b 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] craft-ui = { workspace = true } +craft-window = { workspace = true } lightcraft-geom = { workspace = true } lightcraft-meta = { workspace = true } lightcraft-color = { workspace = true } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4fc43172c..e7d1af303 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -49,7 +49,6 @@ pub mod sync; pub mod tasks; pub mod text_field; pub mod theme; -pub mod titlebar; pub mod widgets; pub mod window_place; @@ -388,6 +387,8 @@ pub struct LightcraftApp { pub perf: Perf, /// macOS: the host draws the traffic lights over our top bar. pub integrated_titlebar: bool, + /// The `movable` flag last given to the window (macOS, `craft_window::title_bar::sync_movable`). + pub window_movable: Option, /// The host installed a native menu bar (no in-window menus then). pub native_menu: bool, /// Shortcuts the native menu bar currently handles (`Cmd+Z`, `G`…): the egui shortcut handler @@ -545,6 +546,7 @@ impl LightcraftApp { perf: Perf::default(), caches: Caches::default(), integrated_titlebar: false, + window_movable: None, native_menu: false, native_shortcuts: Default::default(), recording_shortcut: None, @@ -1624,6 +1626,10 @@ impl LightcraftApp { panels::keyword_list::drag_feedback(self, &ctx); panels::toast(self, &ctx); drop_overlay::show(self, &ctx); + // AppKit moves the window on a drag anywhere in the title-bar band unless the window isn't + // movable: it stops being movable while the pointer is on a bar control or a panel over it + #[cfg(target_os = "macos")] + craft_window::title_bar::sync_movable(&ctx, &mut self.window_movable); self.widgets = widgets::take_registry(&ctx); self.text_focus = ctx.egui_wants_keyboard_input(); self.end_frame(&ctx, t0); diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index e26d1088f..7d1cf6530 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -9,39 +9,13 @@ use crate::theme::Tokens; use crate::widgets::{icon_button, register}; /// The bar doubles as the window's title bar: dragging its empty space moves the window and a -/// double-click zooms or restores it. Registered before the bar's widgets so they win the click. -fn window_handle(app: &LightcraftApp, ui: &mut egui::Ui, bar: Rect, search_rect: Rect) { - use crate::titlebar::{Gesture, WindowState, command_for}; +/// double-click acts as the system setting says. Made before the bar's widgets so they win the click. +fn window_handle(ui: &mut egui::Ui, bar: Rect) { register(ui.ctx(), "region:titlebar", bar); - let handle = egui::Id::new("titlebar-handle"); - let resp = ui.interact(bar, handle, Sense::click_and_drag()); - crate::widgets::announce(&resp, crate::i18n::tr("Title bar")); - // a press that began on a button or the search field is theirs: dragging off it must not move the window - // (while pressing, egui hovers every widget under the pointer; once the drag starts, only the dragged one) - let ctx = ui.ctx().clone(); - let memo = egui::Id::new("titlebar-press-on-widget"); - if resp.is_pointer_button_down_on() && ctx.input(|i| i.pointer.primary_pressed()) { - let hovered = ctx.interaction_snapshot(|s| s.hovered.clone()); - let on_widget = - hovered.iter().any(|id| *id != handle && ctx.read_response(*id).is_some_and(|r| r.sense.senses_click() || r.sense.senses_drag())); - ctx.data_mut(|d| d.insert_temp(memo, on_widget)); - } - let on_widget = ctx.data(|d| d.get_temp::(memo)).unwrap_or(false); - // The search field is drawn after this handle. Its hit-test response may not exist - // yet when the press is recorded; the press origin still identifies text selection. - let on_search = ui.input(|i| i.pointer.press_origin().is_some_and(|p| search_rect.contains(p))); - let gesture = if resp.double_clicked_by(egui::PointerButton::Primary) && !on_search { - Some(Gesture::DoubleClicked) - } else if resp.drag_started_by(egui::PointerButton::Primary) && !on_widget && !on_search { - Some(Gesture::DragStarted) - } else { - None - }; - if let Some(g) = gesture { - let window = WindowState { maximized: ui.input(|i| i.viewport().maximized).unwrap_or(false), fullscreen: app.window_is_fullscreen }; - if let Some(cmd) = command_for(g, window) { - ui.ctx().send_viewport_cmd(cmd); - } + craft_window::title_bar::empty_area(ui, bar); + // (the empty area is the widget `empty_area` makes under this id) + if let Some(resp) = ui.ctx().read_response(ui.id().with("title_bar_empty")) { + crate::widgets::announce(&resp, crate::i18n::tr("Title bar")); } } @@ -75,7 +49,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let search_left = bar.center().x - sw / 2.0; let sr = Rect::from_center_size(pos2(bar.center().x, full.center().y), vec2(sw, 28.0)); if app.integrated_titlebar { - window_handle(app, ui, bar, sr); + window_handle(ui, bar); } ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 4.0; diff --git a/crates/ui-egui/src/tests_titlebar.rs b/crates/ui-egui/src/tests_titlebar.rs index 36598190c..8d40f212e 100644 --- a/crates/ui-egui/src/tests_titlebar.rs +++ b/crates/ui-egui/src/tests_titlebar.rs @@ -169,3 +169,25 @@ fn full_screen_moves_the_left_buttons_into_the_traffic_lights_space() { assert!(!h.app.window_is_fullscreen); assert_eq!(widget(&h, "icon:sidebar").left(), windowed, "leaving full screen restores the room for the traffic lights"); } + +// Scenario: a panel floating over the bar keeps a drag on it from moving the window (macOS's +// title-bar band would otherwise take the drag); a bar control does too +#[test] +fn a_panel_floating_over_the_bar_keeps_the_window_from_moving() { + let mut h = integrated(); + let bar = widget(&h, "region:titlebar"); + let empty = empty_spot(&h); + let hover = |h: &mut Headless, p: egui::Pos2| { + h.events.push(egui::Event::PointerMoved(p)); + play(h); + craft_window::title_bar::window_movable(&h.view.ctx) + }; + assert!(hover(&mut h, empty), "the empty bar moves the window"); + let help = widget(&h, "icon:help").center(); + assert!(!hover(&mut h, help), "a bar control"); + let params = json!({"operation": "float", "panel": "tools", "rect": [bar.left(), bar.top() + 4.0, 330.0, 500.0]}); + let r = h.request("engine.execute", json!({"command": "ui.dock", "params": params}), T); + assert_eq!(r["ok"], true, "{r}"); + play(&mut h); + assert!(!hover(&mut h, empty), "the floating panel covers the empty bar there"); +} diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs deleted file mode 100644 index 40c8975cd..000000000 --- a/crates/ui-egui/src/titlebar.rs +++ /dev/null @@ -1,76 +0,0 @@ -//! The integrated title bar's window gestures (macOS: the native title bar is hidden and the top -//! bar takes its place, so dragging and double-click-to-zoom have to be done here). -//! -//! Pure policy, no egui widgets: [`command_for`] maps a gesture on the bar's empty space and the -//! window's current state to the viewport command the host should run. - -use egui::ViewportCommand; - -/// What the user did on the bar's empty space. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Gesture { - /// A primary-button drag began: the window follows the pointer. - DragStarted, - /// A primary-button double-click: zoom the window, or restore it when already zoomed. - DoubleClicked, -} - -/// The window as the host reports it. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct WindowState { - /// Zoomed (macOS) / maximized (elsewhere). - pub maximized: bool, - /// In the OS's full-screen mode. - pub fullscreen: bool, -} - -/// The viewport command for `gesture`, if any. Full screen has no title bar to drag or zoom. -pub fn command_for(gesture: Gesture, window: WindowState) -> Option { - if window.fullscreen { - return None; - } - match gesture { - Gesture::DragStarted => Some(ViewportCommand::StartDrag), - Gesture::DoubleClicked => Some(ViewportCommand::Maximized(!window.maximized)), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - const NORMAL: WindowState = WindowState { maximized: false, fullscreen: false }; - const ZOOMED: WindowState = WindowState { maximized: true, fullscreen: false }; - const FULL: WindowState = WindowState { maximized: false, fullscreen: true }; - - // Scenario: dragging the title bar moves the window - #[test] - fn given_a_normal_window_when_dragging_then_the_window_follows_the_pointer() { - assert_eq!(command_for(Gesture::DragStarted, NORMAL), Some(ViewportCommand::StartDrag)); - } - - // Scenario: a zoomed window can still be dragged (macOS lets you pull it free) - #[test] - fn given_a_zoomed_window_when_dragging_then_the_window_follows_the_pointer() { - assert_eq!(command_for(Gesture::DragStarted, ZOOMED), Some(ViewportCommand::StartDrag)); - } - - // Scenario: double-click zooms - #[test] - fn given_a_normal_window_when_double_clicking_then_it_zooms() { - assert_eq!(command_for(Gesture::DoubleClicked, NORMAL), Some(ViewportCommand::Maximized(true))); - } - - // Scenario: double-click again restores - #[test] - fn given_a_zoomed_window_when_double_clicking_then_it_is_restored() { - assert_eq!(command_for(Gesture::DoubleClicked, ZOOMED), Some(ViewportCommand::Maximized(false))); - } - - // Scenario: full screen ignores both - #[test] - fn given_a_full_screen_window_then_neither_gesture_does_anything() { - assert_eq!(command_for(Gesture::DragStarted, FULL), None); - assert_eq!(command_for(Gesture::DoubleClicked, FULL), None); - } -} From 19ab8214779e65c4c9858bf747a9ef17df392a3d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 12:41:42 +0000 Subject: [PATCH 19/24] Top bar: repin craft-window to craft-libs 4093ea4; the title bar is announced from empty_area's response --- Cargo.lock | 14 +++++++------- Cargo.toml | 2 +- crates/ui-egui/src/panels/topbar.rs | 7 ++----- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7b7dbfb41..6d9311c48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1098,7 +1098,7 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=8177bad411b577f3204deb0a0e074516d76a0b93#8177bad411b577f3204deb0a0e074516d76a0b93" +source = "git+https://github.com/storytold/craft-libs?rev=4093ea468c6691a0498849ff11683c5243d56272#4093ea468c6691a0498849ff11683c5243d56272" dependencies = [ "egui", "objc2 0.6.4", @@ -1684,7 +1684,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3764,7 +3764,7 @@ dependencies = [ "once_cell", "png", "thiserror 2.0.21", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5270,7 +5270,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5804,7 +5804,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6003,7 +6003,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6591,7 +6591,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b641f471a..712ea59d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ 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-window = { git = "https://github.com/storytold/craft-libs", rev = "8177bad411b577f3204deb0a0e074516d76a0b93", features = ["egui"] } +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "4093ea468c6691a0498849ff11683c5243d56272", features = ["egui"] } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 7d1cf6530..acee425ed 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -12,11 +12,8 @@ use crate::widgets::{icon_button, register}; /// double-click acts as the system setting says. Made before the bar's widgets so they win the click. fn window_handle(ui: &mut egui::Ui, bar: Rect) { register(ui.ctx(), "region:titlebar", bar); - craft_window::title_bar::empty_area(ui, bar); - // (the empty area is the widget `empty_area` makes under this id) - if let Some(resp) = ui.ctx().read_response(ui.id().with("title_bar_empty")) { - crate::widgets::announce(&resp, crate::i18n::tr("Title bar")); - } + let resp = craft_window::title_bar::empty_area(ui, bar); + crate::widgets::announce(&resp, crate::i18n::tr("Title bar")); } pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { From 901a115bf77fda6237ac8f1194e85bd1670b684d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 13:53:10 +0000 Subject: [PATCH 20/24] Top bar: repin craft-window to craft-libs main ea4c823 --- Cargo.lock | 10 +++++----- Cargo.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6d9311c48..eb2d9597a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1098,7 +1098,7 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=4093ea468c6691a0498849ff11683c5243d56272#4093ea468c6691a0498849ff11683c5243d56272" +source = "git+https://github.com/storytold/craft-libs?rev=ea4c823abb46fa9d21361604ed1c6d21c54e9ca8#ea4c823abb46fa9d21361604ed1c6d21c54e9ca8" dependencies = [ "egui", "objc2 0.6.4", @@ -1684,7 +1684,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5270,7 +5270,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5804,7 +5804,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6591,7 +6591,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 712ea59d6..4932b96d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ 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-window = { git = "https://github.com/storytold/craft-libs", rev = "4093ea468c6691a0498849ff11683c5243d56272", features = ["egui"] } +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "ea4c823abb46fa9d21361604ed1c6d21c54e9ca8", features = ["egui"] } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } From 1291d391d981b7a39059b181f5781e0c39bbccda Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 16:25:25 +0000 Subject: [PATCH 21/24] Docking: repin craft-ui and craft-docpath to craft-libs main 5bbe9f6 --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e7f385f62..c6bb9c9d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,12 +1084,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b6d35e28f4d19145c9c7595742cc3ea6fb8352d5#b6d35e28f4d19145c9c7595742cc3ea6fb8352d5" +source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5" [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b6d35e28f4d19145c9c7595742cc3ea6fb8352d5#b6d35e28f4d19145c9c7595742cc3ea6fb8352d5" +source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5" dependencies = [ "egui", "log", diff --git a/Cargo.toml b/Cargo.toml index 12cfba073..72d732c6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = "b6d35e28f4d19145c9c7595742cc3ea6fb8352d5", features = ["serde"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b6d35e28f4d19145c9c7595742cc3ea6fb8352d5" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } From 649e84b0ed6cedfbf395a277e834b8b0e1b56a8d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 16:25:27 +0000 Subject: [PATCH 22/24] Top bar: repin craft-window to craft-libs main 5bbe9f6 --- Cargo.lock | 23 +++++++---------------- Cargo.toml | 2 +- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3abafa245..f3e4b5bf7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1004,7 +1004,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -1098,7 +1098,7 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=ea4c823abb46fa9d21361604ed1c6d21c54e9ca8#ea4c823abb46fa9d21361604ed1c6d21c54e9ca8" +source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5" dependencies = [ "egui", "objc2 0.6.4", @@ -1684,7 +1684,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -5257,7 +5257,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -5270,7 +5270,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -5804,7 +5804,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6591,7 +6591,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6704,15 +6704,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index f0ff5da6e..a41d0d9ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ 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-window = { git = "https://github.com/storytold/craft-libs", rev = "ea4c823abb46fa9d21361604ed1c6d21c54e9ca8", features = ["egui"] } +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5", features = ["egui"] } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } From d4cfd2e6d54b0cf474104dd726f0245fb47a24d8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 17:07:16 +0000 Subject: [PATCH 23/24] LR-BEHAV-TITLEBAR: the checklist row follows craft-window's title_bar --- docs/parity-checklist.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 080680b9f..afbed8675 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -485,7 +485,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | | LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights | | LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | | -| LR-BEHAV-TITLEBAR | Title bar drag and double-click zoom (macOS) | P2 | 🟡 | `crates/ui-egui/src/titlebar.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/tests_titlebar.rs` | the native title bar is hidden on macOS, so the top bar's empty space (including the traffic-light margin) moves the window and a double-click zooms or restores it; buttons and the search field keep their clicks; ignored in full screen, where the traffic lights are gone and the bar's left buttons move into their space (#1763). Zoom is winit's `Maximized`: it does not follow System Settings › “Double-click a window's title bar to” (Fill / Minimize). a press that starts on a button or the search field never moves the window. Known gap: egui-winit reads the zoomed state only at start-up and after our own `Maximized` command, so a window zoomed another way (green button, Window ▸ Zoom, snapping) makes the first double-click a no-op before the toggle catches up. Checked by hand on a real macOS window (drag, zoom, restore) | +| LR-BEHAV-TITLEBAR | Title bar drag and double-click zoom (macOS) | P2 | 🟡 | `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/tests_titlebar.rs` | the native title bar is hidden on macOS, so the top bar's empty space (including the traffic-light margin) moves the window and a double-click does what System Settings › “Double-click a window's title bar to” says (zoom or restore, minimize, nothing; craft-window's `title_bar`); buttons and the search field keep their clicks; ignored in full screen, where the traffic lights are gone and the bar's left buttons move into their space (#1763). A press that starts on a button, the search field or a panel floating over the bar never moves the window (AppKit's `movable` is off while the pointer is there). Known gap: egui-winit reads the zoomed state only at start-up and after our own `Maximized` command, so a window zoomed another way (green button, Window ▸ Zoom, snapping) makes the first double-click a no-op before the toggle catches up. Checked by hand on a real macOS window (drag, zoom, restore) | ## Y. Menus @@ -799,6 +799,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-25 | patch | LR-BEHAV-TITLEBAR: the top bar's title-bar gestures come from craft-window's `title_bar`: a double-click follows the system setting, and a press on a button, the search field or a panel floating over the bar never moves the window; status counts unchanged | | 2026-10-24 | minor | KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 (`keyword.nextSet`, `keyword.previousSet`) go to the next / previous keyword set; status counts unchanged (KEYC-METADATA stays 🟡) | | 2026-10-24 | minor | KEYC-METADATA: ⌘K / Ctrl+K (`panel.addKeywords`, Add Keywords) shows the Keywords panel, opens Keywording and puts the cursor in the Add keyword box; status counts unchanged (KEYC-METADATA stays 🟡) | | 2026-10-24 | patch | LR-LIB-RATING, LR-LIB-DELETE: a slider drag still open when a rating, flag or label takes the photo out of the view, or when the photo is deleted, ends as its own undo step before the selection moves on, as moving to the next photo does; LR-BEHAV-LOCALIZE lists all 18 languages; status unchanged | From df05e59474d9a1afb42904c788a543a7154985ac Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 17:08:39 +0000 Subject: [PATCH 24/24] Docking: the Info fields test window is tall enough for the place fields in the dock column --- crates/ui-egui/src/headless.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 8686dbdf5..1d71e0b88 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2860,7 +2860,8 @@ mod tests { /// accessibility and place fields reach the photo. #[test] fn info_fields_keep_typing_and_save() { - let mut h = demo([1300.0, 1000.0]); + // (tall enough for the place fields to show without scrolling) + let mut h = demo([1300.0, 1200.0]); let t = Duration::from_secs(10); h.request("ui.set", json!({"view": "detail", "right": "info"}), t); h.settle(SETTLE);