mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 14:07:34 -06:00
Merge remote-tracking branch 'origin/main' into pr/launch-opens-library
# Conflicts: # docs/parity-checklist.md
This commit is contained in:
Generated
+26
-10
@@ -1175,21 +1175,25 @@ 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=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f"
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -1211,6 +1215,17 @@ dependencies = [
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.3"
|
||||
@@ -1799,7 +1814,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3714,6 +3729,7 @@ version = "0.14.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
"craft-window",
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
"lightcraft-catalog",
|
||||
@@ -4612,7 +4628,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5550,7 +5566,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5607,7 +5623,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6174,10 +6190,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7077,7 +7093,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.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+4
-3
@@ -10,9 +10,10 @@ rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/lightcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["serde"] }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", features = ["serde"] }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", features = ["egui"] }
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", features = ["egui"] }
|
||||
# internal
|
||||
lightcraft-denoise-core = { path = "crates/denoise-core" }
|
||||
lightcraft-denoise = { path = "crates/denoise", default-features = false }
|
||||
|
||||
@@ -481,7 +481,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::<UiState>(&ui_written) {
|
||||
// an arrangement from an older docking version starts again from the default
|
||||
let mut saved = serde_json::from_slice::<serde_json::Value>(&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::<UiState>(saved) {
|
||||
app.ui = ui;
|
||||
}
|
||||
app.ui = app.ui.sanitized();
|
||||
|
||||
@@ -261,13 +261,15 @@ fn load_prefs(in_memory: bool) -> (Option<UiState>, Option<String>, 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::<Option<lightcraft_ui_egui::docking::Workspace>>(value.clone()) {
|
||||
Ok(Some(workspace)) => workspace.validate().is_err(),
|
||||
Ok(Some(workspace)) => lightcraft_ui_egui::docking::validate(&workspace).is_err(),
|
||||
Ok(None) => false,
|
||||
Err(_) => true,
|
||||
});
|
||||
@@ -378,7 +380,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 {
|
||||
@@ -1371,7 +1373,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();
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+427
-265
@@ -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, Column, ColumnLocation, DockArea, DockCommand, DockError, DockOperation, DockStyle, FloatSpace, Group, GroupLocation, Layout, Location,
|
||||
Node, PanelLimits, PanelState, Permissions, Placement, Side, UiDock, Zone,
|
||||
};
|
||||
use craft_ui::layout::{SplitAxis, SplitSize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
@@ -33,21 +36,41 @@ impl Panel {
|
||||
Self::Presets => "Presets",
|
||||
}
|
||||
}
|
||||
fn default_side(self) -> Zone {
|
||||
match self {
|
||||
Self::Canvas => Zone::Right,
|
||||
Self::Library => Zone::Left,
|
||||
Self::Tools => Zone::Right,
|
||||
Self::Presets => Zone::Right,
|
||||
}
|
||||
/// 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 }
|
||||
}
|
||||
fn parse(id: &str) -> Result<Self, String> {
|
||||
Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}"))
|
||||
/// The width of the panel's own column in the default workspace.
|
||||
fn home_width(self) -> f32 {
|
||||
match self {
|
||||
Self::Canvas => crate::state::MIN_PHOTO_WIDTH,
|
||||
Self::Library => crate::state::LEFT_WIDTH.default,
|
||||
Self::Tools => crate::state::RIGHT_WIDTH.default,
|
||||
Self::Presets => PRESETS_WIDTH,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PRESETS_WIDTH: f32 = 300.0;
|
||||
|
||||
/// The arrangement generation saved as `UiState::docking_version`. Settings without it hold the
|
||||
/// side panels in split frames; they start again from [`default_workspace`].
|
||||
pub const DOCKING_VERSION: u32 = 1;
|
||||
|
||||
/// Drops saved settings' arrangement from before [`DOCKING_VERSION`], with the places of its
|
||||
/// closed panels, so the app starts from the default workspace; every other setting stays.
|
||||
/// Returns true when an arrangement was dropped.
|
||||
pub fn drop_outdated(settings: &mut serde_json::Map<String, Value>) -> bool {
|
||||
if settings.get("dockingVersion").and_then(Value::as_u64).is_some_and(|version| version >= u64::from(DOCKING_VERSION)) {
|
||||
return false;
|
||||
}
|
||||
settings.insert("dockingVersion".into(), json!(DOCKING_VERSION));
|
||||
settings.remove("docking").is_some_and(|docking| !docking.is_null())
|
||||
}
|
||||
|
||||
fn permissions(panel: &Panel) -> Permissions {
|
||||
if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() }
|
||||
// Panels open and split beside the photo view, but never join it as tabs.
|
||||
if *panel == Panel::Canvas { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() }
|
||||
}
|
||||
|
||||
fn tabs(panel: Panel) -> Node<Panel> {
|
||||
@@ -58,246 +81,162 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node<Panel>, second: Node<Pane
|
||||
Node::Split { axis, size, first: Box::new(first), second: Box::new(second) }
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Workspace {
|
||||
pub layout: Layout<Panel>,
|
||||
/// Closed panels retain their most recent group, side, size or floating rectangle.
|
||||
pub hidden: Vec<(Panel, Location<Panel>)>,
|
||||
/// The saved arrangement. Closed panels keep their last group, side, size or floating rect.
|
||||
pub type Workspace = craft_ui::docking::Workspace<Panel>;
|
||||
|
||||
fn column(panel: Panel, width: f32) -> Column<Panel> {
|
||||
Column::new(vec![Group::new(vec![panel], 0)], width)
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
/// Library in a start dock and Tools in an end dock beside the photo. Presets has a column of
|
||||
/// its own on Tools' photo side rather than a group under Tools: both are tall lists that are
|
||||
/// read side by side while trying presets, and stacking them would halve both.
|
||||
fn docked(left: f32, right: f32) -> Workspace {
|
||||
let start = Node::Dock { side: Side::Start, columns: vec![column(Panel::Library, left)] };
|
||||
let end = Node::Dock { side: Side::End, columns: vec![column(Panel::Tools, right), column(Panel::Presets, PRESETS_WIDTH)] };
|
||||
Workspace::new(Layout {
|
||||
root: Some(split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedFirst(left),
|
||||
start,
|
||||
split(SplitAxis::Horizontal, SplitSize::FixedSecond(right + PRESETS_WIDTH), tabs(Panel::Canvas), end),
|
||||
)),
|
||||
floating: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn default_workspace() -> Workspace {
|
||||
docked(crate::state::LEFT_WIDTH.default, crate::state::RIGHT_WIDTH.default)
|
||||
}
|
||||
|
||||
/// Where a panel without a usable recorded place opens: its own column at its dock's window
|
||||
/// edge, or Presets on the photo side of Tools while Tools is in the end dock.
|
||||
fn home(workspace: &Workspace, panel: Panel) -> Location<Panel> {
|
||||
let column = ColumnLocation { side: panel.home_side(), width: panel.home_width(), iconic: false, iconic_width: None };
|
||||
let docked_at =
|
||||
|other: Panel| workspace.layout.location(&other).ok().and_then(|location| location.group).and_then(|group| group.column).map(|c| c.side);
|
||||
let beside = (panel == Panel::Presets && docked_at(Panel::Tools) == Some(Side::End)).then_some(Panel::Tools);
|
||||
Location {
|
||||
floating: None,
|
||||
anchor: beside,
|
||||
placement: if beside.is_some() { Placement::Column { after: true } } else { Placement::Tab { before: None } },
|
||||
size: None,
|
||||
stack: None,
|
||||
sibling: Vec::new(),
|
||||
group: Some(GroupLocation { minimized: false, height: None, column: Some(column) }),
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
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(())
|
||||
}
|
||||
fn error(error: DockError) -> String {
|
||||
error.to_string()
|
||||
}
|
||||
|
||||
pub fn apply(&mut self, action: Action<Panel>) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
let mut next = self.clone();
|
||||
if let Action::Close { panel } = &action {
|
||||
let location = next.layout.location(panel).map_err(|error| error.to_string())?;
|
||||
next.hidden.retain(|(id, _)| id != panel);
|
||||
next.hidden.push((*panel, location));
|
||||
} else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action {
|
||||
next.hidden.retain(|(id, _)| id != panel);
|
||||
/// 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],
|
||||
_ => {}
|
||||
}
|
||||
next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?;
|
||||
next.validate()?;
|
||||
*self = next;
|
||||
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 set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> {
|
||||
if self.layout.contains(&panel) == visible {
|
||||
fn apply(workspace: &mut Workspace, action: Action<Panel>) -> Result<(), String> {
|
||||
let mut next = workspace.clone();
|
||||
next.apply(action, permissions).map_err(error)?;
|
||||
validate(&next)?;
|
||||
*workspace = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shows a panel at its recorded place, else in its dock column ([`home`]). A panel that
|
||||
/// already has the asked visibility keeps its tab selection.
|
||||
fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result<(), String> {
|
||||
if workspace.layout.contains(&panel) == visible {
|
||||
return Ok(());
|
||||
}
|
||||
if !visible {
|
||||
return apply(workspace, Action::Close { panel });
|
||||
}
|
||||
validate(workspace)?;
|
||||
// A recorded place that can't be rebuilt falls back to joining the fallback's tabs. The
|
||||
// photo view takes no tabs, so that is refused and the panel goes to its home column below.
|
||||
if workspace.hidden.iter().any(|(hidden, _)| *hidden == panel) {
|
||||
let mut next = workspace.clone();
|
||||
if next.set_visible(panel, true, Some(Panel::Canvas), permissions).is_ok() && validate(&next).is_ok() {
|
||||
*workspace = next;
|
||||
return Ok(());
|
||||
}
|
||||
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(())
|
||||
}
|
||||
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.
|
||||
pub fn reveal(&mut self, panel: Panel) -> Result<(), String> {
|
||||
self.set_visible(panel, true)?;
|
||||
self.apply(Action::Activate { panel })
|
||||
/// Membership is passive state; an explicit command also exposes the panel's body.
|
||||
fn reveal(workspace: &mut Workspace, panel: Panel) -> Result<(), String> {
|
||||
set_visible(workspace, panel, true)?;
|
||||
apply(workspace, Action::Activate { panel })?;
|
||||
if workspace.layout.panel_state(&panel) != PanelState::Front {
|
||||
// A minimized window or group opens again; where nothing is minimized the action is
|
||||
// refused and the layout stays as it is.
|
||||
let _ = apply(workspace, Action::SetMinimized { panel, minimized: false });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn exposed(&self, panel: Panel) -> bool {
|
||||
if self.layout.floating.iter().any(|group| group.panels.get(group.active) == Some(&panel)) {
|
||||
return true;
|
||||
}
|
||||
let mut nodes = self.layout.root.as_ref().map(|node| vec![node]).unwrap_or_default();
|
||||
while let Some(node) = nodes.pop() {
|
||||
match node {
|
||||
Node::Tabs { panels, active } if panels.get(*active) == Some(&panel) => return true,
|
||||
Node::Stack { entries } if entries.iter().any(|entry| entry.panel == panel && entry.open) => return true,
|
||||
Node::Split { first, second, .. } => {
|
||||
nodes.push(first);
|
||||
nodes.push(second);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
pub fn exposed_in(workspace: &Workspace, panel: Panel) -> bool {
|
||||
workspace.layout.panel_state(&panel) == PanelState::Front
|
||||
}
|
||||
|
||||
fn command(&mut self, params: &Value) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
if let Some(action) = params.get("action") {
|
||||
let action: Action<Panel> = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?;
|
||||
return self.apply(action);
|
||||
}
|
||||
let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?;
|
||||
if operation == "resizeSplit" {
|
||||
let path: Vec<bool> =
|
||||
serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?;
|
||||
let size: SplitSize =
|
||||
serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?;
|
||||
return self.apply(Action::ResizeSplit { path, size });
|
||||
}
|
||||
let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?;
|
||||
let action = match operation {
|
||||
"open" => return self.reveal(panel),
|
||||
"close" => Action::Close { panel },
|
||||
"activate" => Action::Activate { panel },
|
||||
"setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? },
|
||||
"resizeStack" => {
|
||||
let value = params.get("height").ok_or("height is required")?;
|
||||
let height = if value.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
value
|
||||
.as_f64()
|
||||
.filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height))
|
||||
.ok_or("height must be finite and bounded")? as f32,
|
||||
)
|
||||
};
|
||||
Action::ResizeStack { panel, height }
|
||||
}
|
||||
"float" | "moveFloating" => {
|
||||
let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?;
|
||||
let mut values = [0.0f32; 4];
|
||||
for (output, value) in values.iter_mut().zip(rect) {
|
||||
*output = value
|
||||
.as_f64()
|
||||
.filter(|number| number.is_finite() && number.abs() <= 1_000_000.0)
|
||||
.ok_or("rect values must be finite and bounded")? as f32;
|
||||
}
|
||||
if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } }
|
||||
}
|
||||
"move" => {
|
||||
let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?;
|
||||
let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") {
|
||||
"center" => Placement::Split(Zone::Center),
|
||||
"left" => Placement::Split(Zone::Left),
|
||||
"right" => Placement::Split(Zone::Right),
|
||||
"top" => Placement::Split(Zone::Top),
|
||||
"bottom" => Placement::Split(Zone::Bottom),
|
||||
_ => return Err("zone must be center, left, right, top or bottom".into()),
|
||||
};
|
||||
let placement = if let Some(before) = params.get("before") {
|
||||
if !matches!(placement, Placement::Split(Zone::Center)) {
|
||||
return Err("before only applies to a center move".into());
|
||||
}
|
||||
Placement::Tab {
|
||||
before: if before.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?)
|
||||
},
|
||||
}
|
||||
} else {
|
||||
placement
|
||||
};
|
||||
Action::Move { panel, anchor, placement }
|
||||
}
|
||||
_ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()),
|
||||
};
|
||||
self.apply(action)
|
||||
/// `ui.dock` params, parsed by the shared command vocabulary.
|
||||
fn run(workspace: &mut Workspace, params: &Value) -> Result<(), String> {
|
||||
validate(workspace)?;
|
||||
let mut tagged = params.clone();
|
||||
let fields = tagged.as_object_mut().ok_or("ui.dock params must be an object")?;
|
||||
fields.insert("command".into(), json!("ui.dock"));
|
||||
let command: DockCommand<Panel> = serde_json::from_value(tagged).map_err(|error| error.to_string())?;
|
||||
if let DockCommand::UiDock(UiDock::Operation(DockOperation::Open { panel })) = command {
|
||||
return reveal(workspace, panel);
|
||||
}
|
||||
let mut next = workspace.clone();
|
||||
// `ui.dock` operations carry their own rects; only `window.panel.float` reads the space.
|
||||
command.apply(&mut next, false, &FloatSpace::new(egui::Rect::ZERO), permissions).map_err(error)?;
|
||||
validate(&next)?;
|
||||
*workspace = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn from_ui(app: &crate::LightcraftApp) -> Workspace {
|
||||
let mut workspace = Workspace::default();
|
||||
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> {
|
||||
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) {
|
||||
@@ -314,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,
|
||||
};
|
||||
@@ -328,7 +269,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> {
|
||||
@@ -338,6 +279,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
|
||||
@@ -349,7 +291,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 +323,13 @@ pub fn command(app: &mut crate::LightcraftApp, reset: bool, params: &Value) -> R
|
||||
fn command_impl(app: &mut crate::LightcraftApp, reset: bool, params: &Value, renderer_action: bool) -> Result<Value, String> {
|
||||
let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app));
|
||||
if reset {
|
||||
workspace = Workspace::default();
|
||||
workspace = default_workspace();
|
||||
// The usual LightCraft starting workspace shows its current tool beside the photo.
|
||||
workspace.set_visible(Panel::Library, false)?;
|
||||
workspace.set_visible(Panel::Presets, false)?;
|
||||
set_visible(&mut workspace, Panel::Library, false)?;
|
||||
set_visible(&mut workspace, Panel::Presets, false)?;
|
||||
} else {
|
||||
sync(&mut workspace, app)?;
|
||||
workspace.command(params)?;
|
||||
run(&mut workspace, params)?;
|
||||
}
|
||||
if !renderer_action {
|
||||
cancel_replaced_gesture(app, Some(&workspace));
|
||||
@@ -404,7 +346,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}"), app.tick_now() + 8.0, None));
|
||||
workspace = from_ui(app);
|
||||
let _ = sync(&mut workspace, app);
|
||||
@@ -415,12 +357,32 @@ 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;
|
||||
// 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_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();
|
||||
style.move_group_label = crate::i18n::tr("Move tab group").into();
|
||||
style.move_group_to_label = crate::i18n::tr("Move group to").into();
|
||||
style.float_group_label = crate::i18n::tr("Float tab group").into();
|
||||
style.close_group_label = crate::i18n::tr("Close tab group").into();
|
||||
style.close_others_label = crate::i18n::tr("Close other panels in group").into();
|
||||
style.minimize_label = crate::i18n::tr("Minimize").into();
|
||||
style.expand_label = crate::i18n::tr("Expand").into();
|
||||
style.move_column_label = crate::i18n::tr("Move panel column").into();
|
||||
style.collapse_to_icons_label = crate::i18n::tr("Collapse to Icons").into();
|
||||
style.expand_panels_label = crate::i18n::tr("Expand Panels").into();
|
||||
style.collapse_column_label = crate::i18n::tr("Collapse column").into();
|
||||
style.expand_column_label = crate::i18n::tr("Expand column").into();
|
||||
style.auto_collapse_label = crate::i18n::tr("Auto-Collapse Iconic Panels").into();
|
||||
style.move_window_label = crate::i18n::tr("Move panel window").into();
|
||||
style.close_window_label = crate::i18n::tr("Close panel window").into();
|
||||
style.float_hint_label = crate::i18n::tr("Hold {key} to undock").into();
|
||||
style.background = t.chrome;
|
||||
style.tab_background = t.chrome;
|
||||
style.active_background = t.canvas;
|
||||
@@ -431,7 +393,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::unique("lightcraft-docking")).show_with_limits(
|
||||
let output = DockArea::new(egui::Id::unique("lightcraft-docking")).locked(workspace.locked).show_with_limits(
|
||||
ui,
|
||||
&workspace.layout,
|
||||
&style,
|
||||
@@ -497,14 +459,15 @@ 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);
|
||||
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;
|
||||
@@ -513,6 +476,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)),
|
||||
@@ -520,10 +487,14 @@ 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;
|
||||
}
|
||||
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();
|
||||
@@ -533,7 +504,7 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(
|
||||
let contains = |node: &Node<Panel>| match node {
|
||||
Node::Tabs { panels, .. } => panels.contains(&panel),
|
||||
Node::Stack { entries } => entries.iter().any(|entry| entry.panel == panel),
|
||||
Node::Split { .. } => false,
|
||||
_ => false,
|
||||
};
|
||||
if *axis == SplitAxis::Horizontal {
|
||||
if contains(first) {
|
||||
@@ -552,7 +523,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(())
|
||||
@@ -568,6 +539,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<Vec<Panel>>)> {
|
||||
let mut nodes: Vec<_> = workspace.layout.root.iter().collect();
|
||||
while let Some(node) = nodes.pop() {
|
||||
match node {
|
||||
Node::Split { first, second, .. } => nodes.extend([&**first, &**second]),
|
||||
Node::Dock { side: dock, columns } if *dock == side => {
|
||||
return columns.iter().map(|column| (column.width, column.groups.iter().map(|group| group.panels.clone()).collect())).collect();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -587,7 +575,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]
|
||||
@@ -598,23 +586,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]
|
||||
@@ -626,7 +611,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}");
|
||||
@@ -634,15 +619,15 @@ 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();
|
||||
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);
|
||||
@@ -650,12 +635,189 @@ 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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_workspace_docks_library_at_the_start_and_tools_and_presets_at_the_end() {
|
||||
let workspace = default_workspace();
|
||||
validate(&workspace).unwrap();
|
||||
assert_eq!(columns(&workspace, Side::Start), [(crate::state::LEFT_WIDTH.default, vec![vec![Panel::Library]])]);
|
||||
assert_eq!(
|
||||
columns(&workspace, Side::End),
|
||||
[(crate::state::RIGHT_WIDTH.default, vec![vec![Panel::Tools]]), (PRESETS_WIDTH, vec![vec![Panel::Presets]])],
|
||||
"Presets has its own column on Tools' photo side"
|
||||
);
|
||||
let Some(Node::Split { second, .. }) = &workspace.layout.root else { panic!("{:?}", workspace.layout.root) };
|
||||
let Node::Split { first, .. } = &**second else { panic!("{second:?}") };
|
||||
assert_eq!(**first, tabs(Panel::Canvas), "the photo sits between the docks");
|
||||
assert!(workspace.layout.floating.is_empty());
|
||||
let app = app_with_widths();
|
||||
let workspace = from_ui(&app);
|
||||
assert_eq!(columns(&workspace, Side::Start).first().map(|column| column.0), Some(333.0));
|
||||
assert_eq!(columns(&workspace, Side::End).first().map(|column| column.0), Some(410.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_column_widths_publish_and_follow_width_controls() {
|
||||
let mut app = app_with_widths();
|
||||
let workspace = docked(300.0, 310.0);
|
||||
publish_visibility(&mut app, &workspace);
|
||||
assert_eq!((app.ui.left_width, app.ui.right_width), (300.0, 310.0));
|
||||
let mut state = crate::UiState { docking: Some(workspace), left_width: 350.0, right_width: 400.0, ..Default::default() };
|
||||
set_visible(state.docking.as_mut().unwrap(), Panel::Library, false).unwrap();
|
||||
set_widths(&mut state, &json!({"leftWidth":350.0, "rightWidth":400.0})).unwrap();
|
||||
let workspace = state.docking.as_mut().unwrap();
|
||||
assert_eq!(columns(workspace, Side::End).first().map(|column| column.0), Some(400.0), "Tools' column");
|
||||
set_visible(workspace, Panel::Library, true).unwrap();
|
||||
assert_eq!(columns(workspace, Side::Start), [(350.0, vec![vec![Panel::Library]])], "the closed Library's column");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panel_without_a_recorded_place_opens_in_its_dock() {
|
||||
let root = split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedSecond(300.0),
|
||||
tabs(Panel::Canvas),
|
||||
Node::Dock { side: Side::End, columns: vec![column(Panel::Tools, 300.0)] },
|
||||
);
|
||||
let mut workspace = Workspace::new(Layout { root: Some(root), floating: Vec::new() });
|
||||
set_visible(&mut workspace, Panel::Presets, true).unwrap();
|
||||
set_visible(&mut workspace, Panel::Library, true).unwrap();
|
||||
validate(&workspace).unwrap();
|
||||
assert_eq!(columns(&workspace, Side::Start), [(crate::state::LEFT_WIDTH.default, vec![vec![Panel::Library]])]);
|
||||
assert_eq!(columns(&workspace, Side::End), [(300.0, vec![vec![Panel::Tools]]), (PRESETS_WIDTH, vec![vec![Panel::Presets]])]);
|
||||
// Without Tools in the end dock, Presets takes the dock's window edge.
|
||||
let mut workspace = default_workspace();
|
||||
set_visible(&mut workspace, Panel::Presets, false).unwrap();
|
||||
workspace.hidden.clear();
|
||||
apply(&mut workspace, Action::Float { panel: Panel::Tools, rect: [12.0, 34.0, 390.0, 450.0] }).unwrap();
|
||||
set_visible(&mut workspace, Panel::Presets, true).unwrap();
|
||||
assert_eq!(columns(&workspace, Side::End), [(PRESETS_WIDTH, vec![vec![Panel::Presets]])]);
|
||||
}
|
||||
|
||||
fn saved_settings(docking: Value, version: Option<u32>) -> serde_json::Map<String, Value> {
|
||||
let ui = crate::UiState { thumb_size: 210.0, filmstrip: false, presets: true, ..Default::default() };
|
||||
let mut saved = serde_json::to_value(&ui).unwrap();
|
||||
let fields = saved.as_object_mut().unwrap();
|
||||
fields.insert("docking".into(), docking);
|
||||
match version {
|
||||
Some(version) => fields.insert("dockingVersion".into(), json!(version)),
|
||||
None => fields.remove("dockingVersion"),
|
||||
};
|
||||
fields.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_split_frame_layout_without_a_docking_version_loads_as_the_default_docks() {
|
||||
let frames = split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedFirst(333.0),
|
||||
tabs(Panel::Library),
|
||||
split(SplitAxis::Horizontal, SplitSize::FixedSecond(410.0), tabs(Panel::Canvas), tabs(Panel::Tools)),
|
||||
);
|
||||
let frames = Workspace::new(Layout { root: Some(frames), floating: Vec::new() });
|
||||
validate(&frames).unwrap();
|
||||
for version in [None, Some(0)] {
|
||||
let mut fields = saved_settings(serde_json::to_value(&frames).unwrap(), version);
|
||||
assert!(drop_outdated(&mut fields));
|
||||
let ui: crate::UiState = serde_json::from_value(Value::Object(fields)).unwrap();
|
||||
assert!(ui.docking.is_none());
|
||||
assert_eq!(ui.docking_version, DOCKING_VERSION);
|
||||
assert_eq!((ui.thumb_size, ui.filmstrip, ui.presets), (210.0, false, true), "other settings stay");
|
||||
let mut app = app_with_widths();
|
||||
app.ui = ui;
|
||||
command(&mut app, false, &json!({"operation":"activate","panel":"tools"})).unwrap();
|
||||
let workspace = app.ui.docking.as_ref().unwrap();
|
||||
assert!(!workspace.layout.contains(&Panel::Library), "the Library panel stays closed");
|
||||
assert_eq!(
|
||||
columns(workspace, Side::End),
|
||||
[(crate::state::RIGHT_WIDTH.default, vec![vec![Panel::Tools]]), (PRESETS_WIDTH, vec![vec![Panel::Presets]])]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_dock_layout_round_trips() {
|
||||
let mut workspace = default_workspace();
|
||||
apply(&mut workspace, Action::ResizeColumn { panel: Panel::Tools, width: 333.0 }).unwrap();
|
||||
apply(
|
||||
&mut workspace,
|
||||
Action::Move { panel: Panel::Presets, anchor: Panel::Library, placement: Placement::Group { below: true, height: None } },
|
||||
)
|
||||
.unwrap();
|
||||
set_visible(&mut workspace, Panel::Library, false).unwrap();
|
||||
let mut fields = saved_settings(serde_json::to_value(&workspace).unwrap(), Some(DOCKING_VERSION));
|
||||
assert!(!drop_outdated(&mut fields));
|
||||
let ui: crate::UiState = serde_json::from_value(Value::Object(fields)).unwrap();
|
||||
let loaded = ui.docking.unwrap();
|
||||
validate(&loaded).unwrap();
|
||||
assert_eq!(loaded, workspace);
|
||||
assert_eq!(serde_json::to_value(crate::UiState::default()).unwrap()["dockingVersion"], DOCKING_VERSION, "saves write the version");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panel_whose_saved_place_is_gone_opens_in_its_dock() {
|
||||
let root = split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Tools));
|
||||
let mut workspace = Workspace::new(Layout { root: Some(root), floating: Vec::new() });
|
||||
// Presets, the panel Library went back beside, is neither shown nor hidden.
|
||||
let location: craft_ui::docking::Location<Panel> = serde_json::from_value(json!({
|
||||
"floating": null,
|
||||
"anchor": "presets",
|
||||
"placement": {"Split": "Left"},
|
||||
"size": {"FixedFirst": 280.0},
|
||||
"stack": null,
|
||||
"sibling": ["presets"]
|
||||
}))
|
||||
.unwrap();
|
||||
workspace.hidden.push((Panel::Library, location));
|
||||
validate(&workspace).unwrap();
|
||||
set_visible(&mut workspace, Panel::Library, true).unwrap();
|
||||
validate(&workspace).unwrap();
|
||||
assert_eq!(columns(&workspace, Side::Start), [(crate::state::LEFT_WIDTH.default, vec![vec![Panel::Library]])]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2924,7 +2924,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);
|
||||
|
||||
@@ -49,7 +49,6 @@ pub mod sync;
|
||||
pub mod tasks;
|
||||
pub mod text_field;
|
||||
pub mod theme;
|
||||
pub mod titlebar;
|
||||
pub mod updates_ui;
|
||||
pub mod widgets;
|
||||
pub mod window_place;
|
||||
@@ -395,6 +394,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<bool>,
|
||||
/// 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
|
||||
@@ -611,6 +612,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,
|
||||
@@ -2674,6 +2676,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);
|
||||
if notice_blocks {
|
||||
// Keep the real press/release state for the next pass: an ordinary click spans host
|
||||
// frames, and resetting it above must only hide it from this pass's dialogs.
|
||||
|
||||
@@ -893,7 +893,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
app.ui.tool.clear();
|
||||
}
|
||||
let _ = app.session.end_interaction();
|
||||
} else {
|
||||
} else if app.ui.right == RightPanel::None {
|
||||
let p = match std::mem::take(&mut app.ui.right_hidden) {
|
||||
RightPanel::None => RightPanel::Edit,
|
||||
p => p,
|
||||
@@ -906,6 +906,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
app.ui.tool = "remove".into();
|
||||
}
|
||||
}
|
||||
// A right panel that is open but not showing (minimized, or behind another tab)
|
||||
// keeps its mode; reveal_command brings it to the front.
|
||||
Ok(json!({"open": app.ui.right != RightPanel::None}))
|
||||
}
|
||||
"view.beforeAfter" => {
|
||||
|
||||
@@ -9,40 +9,11 @@ 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, "region:titlebar", bar);
|
||||
let handle = egui::Id::unique("titlebar-handle");
|
||||
let resp = ui.interact(bar, handle, Sense::click_and_drag());
|
||||
let resp = craft_window::title_bar::empty_area(ui, bar);
|
||||
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::unique("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::<bool>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the top bar: the titlebar drag area, the in-window menus when there is no native menu
|
||||
@@ -78,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;
|
||||
|
||||
@@ -929,6 +929,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};
|
||||
|
||||
@@ -782,6 +782,9 @@ pub struct UiState {
|
||||
pub window: Option<crate::window_place::WindowPlace>,
|
||||
/// Shared panel arrangement; absent in legacy settings until their first layout.
|
||||
pub docking: Option<crate::docking::Workspace>,
|
||||
/// 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?).
|
||||
@@ -1484,6 +1487,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,
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
};
|
||||
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();
|
||||
@@ -53,7 +77,7 @@ fn inactive_same_mode_commands_reveal_without_closing_or_editing() {
|
||||
let undo = h.app.session.undo.len();
|
||||
let photos = serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::<Vec<_>>()).unwrap();
|
||||
h.app.run(command, json!({})).unwrap();
|
||||
assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Tools), "{command}");
|
||||
assert!(crate::docking::exposed_in(h.app.ui.docking.as_ref().unwrap(), Panel::Tools), "{command}");
|
||||
assert_ne!(h.app.ui.right, RightPanel::None);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert_eq!(serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::<Vec<_>>()).unwrap(), photos);
|
||||
@@ -64,10 +88,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 +105,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 +118,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));
|
||||
@@ -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::unique("lightcraft-docking").with(("split", Vec::<bool>::new()));
|
||||
let from = h.view.ctx.read_response(divider).expect("the custom workspace root divider is rendered").rect.center();
|
||||
let divider = egui::Id::unique("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::unique("lightcraft-docking").with(("split", Vec::<bool>::new()));
|
||||
let divider = egui::Id::unique("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::<Vec<_>>()).unwrap();
|
||||
h.app.synthetic.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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<ViewportCommand> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Real library/tool panel moves preserve the selected tool and saved arrangement.
|
||||
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};
|
||||
@@ -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,61 @@ fn real_drag_float_close_reopen_and_state_reload_keep_photo_tool_mode() {
|
||||
assert_eq!(reloaded.state().ui.right, RightPanel::Crop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_group_by_its_bar_moves_the_whole_group() {
|
||||
let mut h = harness(None, 1400.0, 1.0);
|
||||
h.state_mut().ui.presets = true;
|
||||
h.run_steps(4);
|
||||
h.state_mut().run("ui.dock", json!({"operation":"move","panel":"presets","target":"tools","zone":"center"})).unwrap();
|
||||
h.run_steps(6);
|
||||
let bar = DockArea::<Panel>::hit_rects(&h.ctx, egui::Id::unique("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 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);
|
||||
}
|
||||
|
||||
/// The start dock's columns from the window edge inwards, each as its groups' panels.
|
||||
fn start_dock(h: &Harness<'_, LightcraftApp>) -> Vec<Vec<Vec<Panel>>> {
|
||||
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 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<Vec<Vec<Panel>>>); 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 source = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").rect().center();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_docking_requests_preserve_layout_and_selected_tool() {
|
||||
let mut h = harness(None, 1400.0, 1.0);
|
||||
@@ -115,7 +171,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();
|
||||
|
||||
@@ -373,11 +373,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|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]}}`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -800,6 +800,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-25 | minor | LRC-LIB-CATALOG-SWITCH: a library's `catalog.snap` or folder given at launch (double-clicked in Explorer or Finder) opens that library instead of the last one, and is never imported as photos; status unchanged (✅) |
|
||||
| 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 |
|
||||
|
||||
Reference in New Issue
Block a user