diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 4b38673804..df4e9a2905 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -42,7 +42,7 @@ next to them. | `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `history-brush.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.svg`, `vertical-align-*.svg` (115 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `history-brush.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.svg`, `vertical-align-*.svg` (116 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | diff --git a/assets/icons/panel-left.svg b/assets/icons/panel-left.svg new file mode 100644 index 0000000000..60642c396f --- /dev/null +++ b/assets/icons/panel-left.svg @@ -0,0 +1,14 @@ + + + + diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index c30d43be82..04d63960e1 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -75,6 +75,7 @@ impl PhotocraftApp { vector_ui::finish_pen_if_left(self); transform_tool::end_if_left(self); shape_stroke_ui::sync_editor(self, ctx); + crate::dock::persist_pending(self, ctx); prefs_ui::tick(self, ctx); updates_ui::tick(self, ctx); monitor_status::poll_frame(self, ctx, frame); @@ -300,21 +301,44 @@ impl PhotocraftApp { if chrome && self.ui.panels.status_bar && !home { panels::status_bar(self, ui); } - if ((chrome && self.ui.panels.toolbar) || tabbed_in) && !home { - panels::toolbar(self, ui); - } + let toolbar_shown = ((chrome && self.ui.panels.toolbar) || tabbed_in) && !home; // craft-ui's layout holds the canvas as its protected `canvas` panel. let docking = ((chrome && self.ui.panels.dock) || tabbed_in) && !home; - if docking { - panels::dock_area(self, ui); - } else { + let toolbar_side = self.ui.panels.toolbar_side.resolve(); + let toolbar_right = toolbar_side == crate::state::ToolbarSide::Right; + // The compact layout stacks the dock, rail and toolbar on the dock's physical edge. + let dock_right = !crate::direction::is_rtl(); + let toolbar_at_dock_edge = toolbar_right == dock_right; + // The dock-edge layout drops the canvas from craft-ui's split, which is only safe while + // nothing else is docked beside it. A panel already beside the canvas keeps the standard + // layout so it stays visible and dockable. The layout must not change once a drag starts: + // craft-ui records the areas on the press, and swapping the shape cancels the drag. + let dock_edge_layout = toolbar_shown && docking && toolbar_at_dock_edge && crate::panel_docking::canvas_alone(self); + if dock_edge_layout { + // Dock outermost, then its rail, then the toolbox, then the canvas: the toolbox sits + // between the canvas and the dock, not outside the dock at the window edge. + panels::dock_panel(self, ui); + panels::toolbar(self, ui); let t = theme::Tokens::get(&ctx); let backdrop = if chrome { prefs_ui::pasteboard_color(self).unwrap_or(t.canvas) } else { egui::Color32::BLACK }; egui::CentralPanel::default().frame(egui::Frame::NONE.fill(backdrop)).show(ui, |ui| { canvas::document_area(self, ui); }); - if !home { - panels::properties_window(self, &ctx); + } else { + if toolbar_shown { + panels::toolbar(self, ui); + } + if docking { + panels::dock_area(self, ui); + } else { + let t = theme::Tokens::get(&ctx); + let backdrop = if chrome { prefs_ui::pasteboard_color(self).unwrap_or(t.canvas) } else { egui::Color32::BLACK }; + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(backdrop)).show(ui, |ui| { + canvas::document_area(self, ui); + }); + if !home { + panels::properties_window(self, &ctx); + } } } // The floating editor windows belong to the editor: Home covers them (dialogs and notices diff --git a/crates/ui-egui/src/control/dispatch.rs b/crates/ui-egui/src/control/dispatch.rs index 6e7e14c87e..f11fe56135 100644 --- a/crates/ui-egui/src/control/dispatch.rs +++ b/crates/ui-egui/src/control/dispatch.rs @@ -614,6 +614,10 @@ fn crop_fill_param(p: &Value) -> std::result::Result, String> { /// keeps a pin and `{"pinned": true}` keeps a dragged position (#10890). fn panels_patch(app: &PhotocraftApp, patch: Option<&Value>) -> std::result::Result, String> { let Some(mut patch) = patch.cloned() else { return Ok(None) }; + // Saved panels read an unknown side as the default; a control request is still refused. + if let Some(side) = patch.get("toolbar_side") { + serde_json::from_value::(side.clone()).map_err(|e| format!("panels.toolbar_side: {e}"))?; + } if let Some(obj) = patch.as_object_mut() && let Some(bar) = obj.get("task_bar") { @@ -725,11 +729,12 @@ impl PanelSet { } } + /// Applies the Color panel state and queues the requested end-dock width for the next frame. fn apply_color_and_width(&mut self, app: &mut PhotocraftApp, ctx: &egui::Context) { if let Some(v) = self.color_panel.take() { app.ui.color_panel = v; } - // Right dock width in points (clamped to the dock's 250..=520 range), applied next frame. + // End dock width in points, clamped to its window-dependent range and applied next frame. if let Some(w) = self.dock_width { crate::panels::request_dock_width(ctx, w as f32); } diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index bad7f725c3..ab1b090124 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -352,6 +352,23 @@ pub(crate) fn keep_newer(app: &PhotocraftApp, layout: &mut Value) { } } +/// Writes the current panels/layout snapshot now, for a discrete change such as the toolbar side, +/// whether or not the dock was drawn this frame. Only while Remember Workspace Changes is on. +pub(crate) fn persist_panels(app: &mut PhotocraftApp, ctx: &egui::Context) { + if !app.session.prefs().workspace.remember_workspace_changes { + return; + } + // Geometry still settling (OS window move, dock-edge drag, or a held pointer): keep the + // toolbar-side change pending and save it once stable, even if the dock is not drawn. + if panels_moving(app, ctx) { + set_panels_pending(ctx, true); + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + return; + } + persist_panels_now(app); + set_panels_pending(ctx, false); +} + /// Remember the layout in the preferences once the user lets go of the mouse (Workspace › /// Remember Workspace Changes), and once panel windows the OS moves or resizes keep still. Cheap: /// a small JSON compare per frame. @@ -360,7 +377,7 @@ pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { if app.ui.docking.is_some() { app.ui.docking_kept = None; } - if !app.session.prefs().workspace.remember_workspace_changes || ctx.input(|i| i.pointer.any_down()) { + if !app.session.prefs().workspace.remember_workspace_changes || ctx.input(|i| i.pointer.any_down()) || crate::panels::dock_edge_drag_active(ctx) { return; } if native_moved(app, ctx) { @@ -375,18 +392,88 @@ pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { /// A floating panel's own window has another place than last frame. A move or resize by its OS /// title bar or edges changes the place each frame with no button down in any of the app's -/// windows. +/// windows. The answer is cached for the frame so the dock pass and `persist_panels` agree even +/// though only the first call can observe the change. fn native_moved(app: &PhotocraftApp, ctx: &egui::Context) -> bool { let places: Vec<(u64, [f32; 4])> = app.ui.docking.iter().flat_map(|workspace| workspace.layout.floating.iter().filter_map(|window| Some((window.id, window.native?)))).collect(); - let id = egui::Id::unique("dock-native-places"); + let places_id = egui::Id::unique("dock-native-places"); + let cache_id = egui::Id::unique("dock-native-moved-frame"); + let frame = ctx.cumulative_frame_nr(); ctx.data_mut(|data| { - let moved = data.get_temp::>(id).is_some_and(|last| last != places); - data.insert_temp(id, places); + // Keep any movement seen earlier in the frame, but still compare the bounds now: the + // docking UI can update them after persist_pending's early check, and the later dock save + // must not trust a stale `false`. + let cached = match data.get_temp::<(u64, bool)>(cache_id) { + Some((at, moved)) if at == frame => Some(moved), + _ => None, + }; + let previous = data.get_temp::>(places_id); + let changed = previous.as_ref().is_some_and(|last| last != &places); + let moved = cached.unwrap_or(false) || changed; + if changed || previous.is_none() { + data.insert_temp(places_id, places); + } + data.insert_temp(cache_id, (frame, moved)); moved }) } +/// Writes the panels/layout snapshot if it changed. +fn persist_panels_now(app: &mut PhotocraftApp) { + let now = snapshot(app); + if app.session.prefs().panel_layout != now { + app.session.prefs.edit(|p| p.panel_layout = now); + } +} + +/// True while a persistence pass must not record geometry: an in-progress dock-edge drag, a +/// pointer button down, or a native panel window that moved this frame. +fn panels_moving(app: &PhotocraftApp, ctx: &egui::Context) -> bool { + crate::panels::dock_edge_drag_active(ctx) || ctx.input(|i| i.pointer.any_down()) || native_moved(app, ctx) +} + +/// Whether a toolbar-side save is waiting for the dock geometry to settle. +fn panels_pending(ctx: &egui::Context) -> bool { + ctx.data(|data| data.get_temp::(egui::Id::unique("dock-persist-panels-pending")).unwrap_or(false)) +} + +/// Marks or clears the deferred toolbar-side save that [`persist_pending`] retries. +fn set_panels_pending(ctx: &egui::Context, pending: bool) { + let id = egui::Id::unique("dock-persist-panels-pending"); + ctx.data_mut(|data| { + if pending { + data.insert_temp(id, true); + } else { + data.remove::(id); + } + }); +} + +/// Retries a toolbar-side save that was deferred while geometry was moving; called every frame so +/// the save happens once the geometry is stable even when the dock and toolbar were not drawn. +pub(crate) fn persist_pending(app: &mut PhotocraftApp, ctx: &egui::Context) { + // If the dock-edge layout stopped rendering (Shift+Tab hides the dock mid-drag), its stored + // drag start cannot be cleared by the panel; drop it so deferred saves can resume. + let rendered = ctx.data(|data| data.get_temp::(egui::Id::unique("dock-edge-rendered-frame"))); + if rendered.is_none_or(|at| at + 1 < ctx.cumulative_frame_nr()) { + crate::panels::clear_dock_edge_drag(ctx); + } + if !app.session.prefs().workspace.remember_workspace_changes { + set_panels_pending(ctx, false); + return; + } + if !panels_pending(ctx) { + return; + } + if panels_moving(app, ctx) { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + return; + } + persist_panels_now(app); + set_panels_pending(ctx, false); +} + /// Restore the remembered layout at launch. Unreadable parts keep their defaults. pub fn restore(app: &mut PhotocraftApp) { if !app.session.prefs().workspace.remember_workspace_changes { @@ -423,10 +510,15 @@ pub fn apply(app: &mut PhotocraftApp, v: &Value) { Some(_) => v.get("dockingCollapsed").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(), None => Vec::new(), }; - if let Some(mut p) = v.get("panels").and_then(|p| serde_json::from_value::(p.clone()).ok()) { + if let Some(saved) = v.get("panels") + && let Ok(mut p) = serde_json::from_value::(saved.clone()) + { // The embedding host owns these flags, including for layouts that serialize them. p.menu_bar = app.ui.panels.menu_bar; p.rail = app.ui.panels.rail; + // A layout saved before the toolbar could move (or with an unreadable side) keeps the + // current side; a valid saved side still belongs to the workspace. + p.toolbar_side = saved.get("toolbar_side").and_then(|side| serde_json::from_value(side.clone()).ok()).unwrap_or(app.ui.panels.toolbar_side); app.ui.panels = p; } if let Some(t) = v.get("dockTabs").and_then(|t| serde_json::from_value(t.clone()).ok()) { diff --git a/crates/ui-egui/src/dock/tests.rs b/crates/ui-egui/src/dock/tests.rs index d3884e20fa..08ba525e61 100644 --- a/crates/ui-egui/src/dock/tests.rs +++ b/crates/ui-egui/src/dock/tests.rs @@ -839,6 +839,35 @@ fn an_unreadable_docking_layout_is_dropped_and_the_rest_applies() { assert!(app.ui.timeline.open, "the rest of the layout still applies"); } +/// `toolbar_side` belongs to the workspace when the layout saved one; a layout saved before the +/// toolbar could move, or with an unreadable side, keeps the current side instead of `Start`. +#[test] +fn applying_a_workspace_keeps_the_toolbar_side_unless_it_saved_one() { + use crate::state::ToolbarSide; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let saved = snapshot(&app); + let cases = [ + (None, ToolbarSide::Right), + (Some(json!("sideways")), ToolbarSide::Right), + (Some(json!("left")), ToolbarSide::Left), + (Some(json!("start")), ToolbarSide::Start), + ]; + for (side, expected) in cases { + app.ui.panels.toolbar_side = ToolbarSide::Right; + app.ui.panels.brush_settings = false; + let mut saved = saved.clone(); + let panels = saved["panels"].as_object_mut().unwrap(); + panels.insert("brush_settings".into(), json!(true)); + match &side { + Some(side) => panels.insert("toolbar_side".into(), side.clone()), + None => panels.remove("toolbar_side"), + }; + apply(&mut app, &saved); + assert_eq!(app.ui.panels.toolbar_side, expected, "{side:?}"); + assert!(app.ui.panels.brush_settings, "the rest of the saved panels still apply: {side:?}"); + } +} + /// Layers panel options live in the preferences on their own (like Photoshop's Panel Options), /// so a workspace layout neither saves nor restores them. #[test] diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 4d2306eeae..3815641718 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -3618,6 +3618,8 @@ curves Smooth تنعيم Auto-size Canvas تحديد حجم مساحة الرسم تلقائيًا Keep Relative Position During Layer Reordering الإبقاء على الموضع النسبي عند إعادة ترتيب الطبقات Shrinkwrap Canvas on Save تقليص مساحة الرسم إلى المحتوى عند الحفظ + Move Toolbar to the Right نقل شريط الأدوات إلى اليمين + Move Toolbar to the Left نقل شريط الأدوات إلى اليسار # RAW Develop feature labels Saved RAW Develop settings to {file} تم حفظ إعدادات معالجة RAW في {file} diff --git a/crates/ui-egui/src/i18n/az.tsv b/crates/ui-egui/src/i18n/az.tsv index 8014d5f99a..743f45a0c3 100644 --- a/crates/ui-egui/src/i18n/az.tsv +++ b/crates/ui-egui/src/i18n/az.tsv @@ -4627,6 +4627,8 @@ smartSvg SVG editor opened. Save the SVG to update this Smart Object. SVG redakt Auto-size Canvas Kətanın ölçüsünü avtomatik təyin edin Keep Relative Position During Layer Reordering Qatları yenidən sıralayarkən nisbi mövqeyi saxlayın Shrinkwrap Canvas on Save Saxlayarkən kətanı məzmuna sıxın + Move Toolbar to the Right Alət panelini sağa köçür + Move Toolbar to the Left Alət panelini sola köçür # RAW Develop feature labels Saved RAW Develop settings to {file} RAW emalı ayarları {file} faylında saxlanıldı diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 9de214dd15..037be0d21c 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4641,6 +4641,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Auto-size Canvas Automatski promijeni veličinu platna Keep Relative Position During Layer Reordering Zadrži relativni položaj pri promjeni redoslijeda slojeva Shrinkwrap Canvas on Save Smanji platno na sadržaj pri sačuvanju + Move Toolbar to the Right Premjesti traku s alatima desno + Move Toolbar to the Left Premjesti traku s alatima lijevo # RAW Develop feature labels Saved RAW Develop settings to {file} Postavke RAW obrade sačuvane u {file} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index feab402a72..ee3799500f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4370,3 +4370,5 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Auto-size Canvas Automaticky měnit velikost plátna Keep Relative Position During Layer Reordering Zachovat relativní polohu při změně pořadí vrstev Shrinkwrap Canvas on Save Při uložení zmenšit plátno na obsah + Move Toolbar to the Right Přesunout panel nástrojů doprava + Move Toolbar to the Left Přesunout panel nástrojů doleva diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 78467514d2..37fa7f9ecd 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4825,6 +4825,8 @@ cameraRaw White Balance: As Shot Hvidbalance: Som fotograferet Auto-size Canvas Tilpas lærredsstørrelse automatisk Keep Relative Position During Layer Reordering Bevar relativ placering ved omorganisering af lag Shrinkwrap Canvas on Save Beskær lærredet til indholdet ved lagring + Move Toolbar to the Right Flyt værktøjslinjen til højre + Move Toolbar to the Left Flyt værktøjslinjen til venstre # RAW Develop feature labels Saved RAW Develop settings to {file} RAW-fremkaldelsesindstillinger gemt i {file} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5d04caa42e..ec3da1eb70 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3665,6 +3665,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Auto-size Canvas Zeichenflächengröße automatisch anpassen Keep Relative Position During Layer Reordering Relative Position beim Neuanordnen von Ebenen beibehalten Shrinkwrap Canvas on Save Zeichenfläche beim Speichern auf den Inhalt verkleinern + Move Toolbar to the Right Werkzeugleiste nach rechts verschieben + Move Toolbar to the Left Werkzeugleiste nach links verschieben # RAW Develop feature labels RAW Develop RAW-Entwicklung diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e355ed39ca..f92e5b4a1b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4328,3 +4328,5 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Auto-size Canvas Αυτόματη αλλαγή μεγέθους καμβά Keep Relative Position During Layer Reordering Διατήρηση σχετικής θέσης κατά την αναδιάταξη επιπέδων Shrinkwrap Canvas on Save Σμίκρυνση καμβά στο περιεχόμενο κατά την αποθήκευση + Move Toolbar to the Right Μετακίνηση γραμμής εργαλείων δεξιά + Move Toolbar to the Left Μετακίνηση γραμμής εργαλείων αριστερά diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6e2c269f02..4cb003c370 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4526,3 +4526,5 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Auto-size Canvas Ajustar tamaño de lienzo automáticamente Keep Relative Position During Layer Reordering Mantener la posición relativa al reordenar capas Shrinkwrap Canvas on Save Reducir el lienzo al contenido al guardar + Move Toolbar to the Right Mover la barra de herramientas a la derecha + Move Toolbar to the Left Mover la barra de herramientas a la izquierda diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 2c8376ebab..7b3073240e 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4826,6 +4826,8 @@ cameraRaw White Balance: As Shot Valkotasapaino: kuvauksen mukainen Auto-size Canvas Muuta kankaan kokoa automaattisesti Keep Relative Position During Layer Reordering Säilytä suhteellinen sijainti tasojen järjestystä muutettaessa Shrinkwrap Canvas on Save Pienennä kangas sisältöön tallennettaessa + Move Toolbar to the Right Siirrä työkalupalkki oikealle + Move Toolbar to the Left Siirrä työkalupalkki vasemmalle # RAW Develop feature labels Saved RAW Develop settings to {file} RAW-kehityksen asetukset tallennettu tiedostoon {file} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 7d869a71da..f66fdea173 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4647,3 +4647,5 @@ shape Appearance Apparence Auto-size Canvas Redimensionner le canevas automatiquement Keep Relative Position During Layer Reordering Conserver la position relative lors de la réorganisation des calques Shrinkwrap Canvas on Save Réduire le canevas au contenu à l'enregistrement + Move Toolbar to the Right Déplacer la barre d'outils à droite + Move Toolbar to the Left Déplacer la barre d'outils à gauche diff --git a/crates/ui-egui/src/i18n/he.tsv b/crates/ui-egui/src/i18n/he.tsv index 8b58e34c6c..d6bb149798 100644 --- a/crates/ui-egui/src/i18n/he.tsv +++ b/crates/ui-egui/src/i18n/he.tsv @@ -4568,6 +4568,8 @@ navigator Zoom תקריב @plural {n} action file loaded|{n} action files loaded נטען {n} קובץ פעולות|נטענו {n} קובצי פעולות @plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} לא ניתן היה לטעון {n} קובץ: {errors}|לא ניתן היה לטעון {n} קבצים: {errors} @plural {what}: {n} Image Assets output failed|{what}: {n} Image Assets outputs failed {what}: {n} פלט נכסי תמונה נכשל|{what}: {n} פלטי נכסי תמונה נכשלו + Move Toolbar to the Right העבר את סרגל הכלים ימינה + Move Toolbar to the Left העבר את סרגל הכלים שמאלה # RAW Develop feature labels Saved RAW Develop settings to {file} הגדרות פיתוח RAW נשמרו ב־{file} diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 1f5871b96e..897c6c3440 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -693,6 +693,8 @@ cameraRaw Yellow पीला Auto-size Canvas कैनवास का आकार स्वचालित रूप से बदलें Keep Relative Position During Layer Reordering लेयर्स का क्रम बदलते समय सापेक्ष स्थिति बनाए रखें Shrinkwrap Canvas on Save सहेजते समय कैनवास को सामग्री के अनुसार छोटा करें + Move Toolbar to the Right टूलबार को दाएँ ले जाएँ + Move Toolbar to the Left टूलबार को बाएँ ले जाएँ RAW Defaults RAW डिफ़ॉल्ट # RAW Develop feature labels diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 54078a6d01..cfcce4a24f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4533,3 +4533,5 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Auto-size Canvas Vászonméret automatikus módosítása Keep Relative Position During Layer Reordering Relatív pozíció megtartása a rétegek átrendezésekor Shrinkwrap Canvas on Save Vászon összehúzása a tartalomra mentéskor + Move Toolbar to the Right Eszköztár áthelyezése jobbra + Move Toolbar to the Left Eszköztár áthelyezése balra diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index a4ebf1d3cf..e5a4539eae 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3649,6 +3649,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Auto-size Canvas Ubah ukuran kanvas otomatis Keep Relative Position During Layer Reordering Pertahankan posisi relatif saat mengurutkan ulang layer Shrinkwrap Canvas on Save Kecilkan kanvas sesuai konten saat menyimpan + Move Toolbar to the Right Pindahkan bilah alat ke kanan + Move Toolbar to the Left Pindahkan bilah alat ke kiri # RAW Develop feature labels RAW Develop Pengembangan RAW diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 099e53c102..f2028e9e75 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4723,3 +4723,5 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Auto-size Canvas Ridimensiona quadro automaticamente Keep Relative Position During Layer Reordering Mantieni la posizione relativa durante il riordino dei livelli Shrinkwrap Canvas on Save Riduci il quadro al contenuto al salvataggio + Move Toolbar to the Right Sposta la barra degli strumenti a destra + Move Toolbar to the Left Sposta la barra degli strumenti a sinistra diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 068537833b..ff4a65e0a5 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4297,3 +4297,5 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Auto-size Canvas カンバスサイズを自動調整 Keep Relative Position During Layer Reordering レイヤーの並べ替え中も相対位置を保持 Shrinkwrap Canvas on Save 保存時にカンバスを内容に合わせて縮小 + Move Toolbar to the Right ツールバーを右へ移動 + Move Toolbar to the Left ツールバーを左へ移動 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 2927c4b009..6fea5cc544 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4711,6 +4711,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. თითოეული არხისთვის სხვა დოკუმენტი აირჩიეთ. A picked document is no longer open. ერთ-ერთი არჩეული დოკუმენტი აღარ არის გახსნილი. A picked document is no longer a grayscale document of the same size. ერთ-ერთი არჩეული დოკუმენტი აღარ არის იმავე ზომის ნაცრისფერი ტონების დოკუმენტი. + Move Toolbar to the Right ხელსაწყოების პანელის მარჯვნივ გადატანა + Move Toolbar to the Left ხელსაწყოების პანელის მარცხნივ გადატანა # RAW Develop feature labels Saved RAW Develop settings to {file} RAW დამუშავების პარამეტრები შენახულია {file}-ში diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3ccf990c46..9f591db831 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4202,3 +4202,5 @@ shape Appearance 화면 모양 Auto-size Canvas 캔버스 크기 자동 조정 Keep Relative Position During Layer Reordering 레이어 순서 변경 시 상대 위치 유지 Shrinkwrap Canvas on Save 저장 시 캔버스를 내용에 맞게 축소 + Move Toolbar to the Right 도구 모음을 오른쪽으로 이동 + Move Toolbar to the Left 도구 모음을 왼쪽으로 이동 diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index d2b94a6adb..abb2371fe9 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -4825,6 +4825,8 @@ cameraRaw White Balance: As Shot Hvitbalanse: som fotografert Auto-size Canvas Endre lerretstørrelse automatisk Keep Relative Position During Layer Reordering Behold relativ posisjon ved omordning av lag Shrinkwrap Canvas on Save Krymp lerretet til innholdet ved lagring + Move Toolbar to the Right Flytt verktøylinjen til høyre + Move Toolbar to the Left Flytt verktøylinjen til venstre # RAW Develop feature labels Saved RAW Develop settings to {file} RAW-fremkallingsinnstillinger lagret i {file} diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 47239e95f6..edcf31b20e 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4380,3 +4380,5 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Auto-size Canvas Canvasgrootte automatisch aanpassen Keep Relative Position During Layer Reordering Relatieve positie behouden bij het wijzigen van de laagvolgorde Shrinkwrap Canvas on Save Canvas bij opslaan verkleinen tot de inhoud + Move Toolbar to the Right Werkbalk naar rechts verplaatsen + Move Toolbar to the Left Werkbalk naar links verplaatsen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 0581f3376a..c2efc53089 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4244,3 +4244,5 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Auto-size Canvas Automatycznie zmieniaj rozmiar płótna Keep Relative Position During Layer Reordering Zachowaj położenie względne przy zmianie kolejności warstw Shrinkwrap Canvas on Save Przytnij płótno do zawartości przy zapisie + Move Toolbar to the Right Przenieś pasek narzędzi w prawo + Move Toolbar to the Left Przenieś pasek narzędzi w lewo diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 74b6f4ef87..402245ed55 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4540,3 +4540,5 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Auto-size Canvas Ajustar tamanho da tela automaticamente Keep Relative Position During Layer Reordering Manter a posição relativa ao reordenar camadas Shrinkwrap Canvas on Save Reduzir a tela ao conteúdo ao salvar + Move Toolbar to the Right Mover a barra de ferramentas para a direita + Move Toolbar to the Left Mover a barra de ferramentas para a esquerda diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index d21935d19e..73e2427adf 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -4610,6 +4610,8 @@ navigator Zoom Zoom @plural {n} action file loaded|{n} action files loaded {n} fișier de acțiuni încărcat|{n} fișiere de acțiuni încărcate|{n} de fișiere de acțiuni încărcate @plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} fișier nu a putut fi încărcat: {errors}|{n} fișiere nu au putut fi încărcate: {errors}|{n} de fișiere nu au putut fi încărcate: {errors} @plural {what}: {n} Image Assets output failed|{what}: {n} Image Assets outputs failed {what}: {n} ieșire Resurse imagine a eșuat|{what}: {n} ieșiri Resurse imagine au eșuat|{what}: {n} de ieșiri Resurse imagine au eșuat + Move Toolbar to the Right Mută bara de instrumente la dreapta + Move Toolbar to the Left Mută bara de instrumente la stânga # RAW Develop feature labels Saved RAW Develop settings to {file} Setările de developare RAW au fost salvate în {file} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 67b7761ee5..41f8ad54f9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4809,3 +4809,5 @@ shape Appearance Оформление Auto-size Canvas Автоматически менять размер холста Keep Relative Position During Layer Reordering Сохранять относительное положение при изменении порядка слоев Shrinkwrap Canvas on Save Уменьшать холст до содержимого при сохранении + Move Toolbar to the Right Переместить панель инструментов вправо + Move Toolbar to the Left Переместить панель инструментов влево diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 5e142c87ac..e4c9c511b0 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4690,3 +4690,5 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Auto-size Canvas Automatski promeni veličinu platna Keep Relative Position During Layer Reordering Zadrži relativni položaj pri promeni redosleda slojeva Shrinkwrap Canvas on Save Smanji platno na sadržaj pri čuvanju + Move Toolbar to the Right Premesti traku sa alatkama desno + Move Toolbar to the Left Premesti traku sa alatkama levo diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 867dac9efe..112a91ca45 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4823,6 +4823,8 @@ cameraRaw White Balance: As Shot Vitbalans: som tagen Auto-size Canvas Ändra storlek på arbetsytan automatiskt Keep Relative Position During Layer Reordering Behåll relativ position vid ändring av lagerordning Shrinkwrap Canvas on Save Krymp arbetsytan till innehållet vid sparning + Move Toolbar to the Right Flytta verktygsfältet till höger + Move Toolbar to the Left Flytta verktygsfältet till vänster # RAW Develop feature labels Saved RAW Develop settings to {file} RAW-framkallningsinställningar sparade i {file} diff --git a/crates/ui-egui/src/i18n/ta.tsv b/crates/ui-egui/src/i18n/ta.tsv index b280a109a1..328cddde41 100644 --- a/crates/ui-egui/src/i18n/ta.tsv +++ b/crates/ui-egui/src/i18n/ta.tsv @@ -4269,6 +4269,8 @@ navigator Zoom பெரிதாக்கல் Transparent (default) ஒளிஊடுருவும் (இயல்புநிலை) The path was kept as the Work Path பாதை பணிப் பாதையாக வைக்கப்பட்டது The path was discarded பாதை நிராகரிக்கப்பட்டது + Move Toolbar to the Right கருவிப்பட்டியை வலப்புறம் நகர்த்து + Move Toolbar to the Left கருவிப்பட்டியை இடப்புறம் நகர்த்து # RAW Develop feature labels Saved RAW Develop settings to {file} RAW உருவாக்க அமைப்புகள் {file} இல் சேமிக்கப்பட்டன diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index bcb6a917bb..77b3fedd67 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -4767,6 +4767,8 @@ cameraRaw White Balance: As Shot สมดุลแสงขาว: เท่า Transparent (default) โปร่งใส (ค่าเริ่มต้น) The path was kept as the Work Path เก็บเส้นทางไว้เป็นเส้นทางการทำงานแล้ว The path was discarded ทิ้งเส้นทางแล้ว + Move Toolbar to the Right ย้ายแถบเครื่องมือไปทางขวา + Move Toolbar to the Left ย้ายแถบเครื่องมือไปทางซ้าย # RAW Develop feature labels Saved RAW Develop settings to {file} บันทึกการตั้งค่าการปรับแต่ง RAW ไปยัง {file} แล้ว diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6674cfdfcb..80cb929d78 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4357,3 +4357,5 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Auto-size Canvas Tuvali Otomatik Boyutlandır Keep Relative Position During Layer Reordering Katmanları Yeniden Sıralarken Göreli Konumu Koru Shrinkwrap Canvas on Save Kaydederken Tuvali İçeriğe Göre Küçült + Move Toolbar to the Right Araç çubuğunu sağa taşı + Move Toolbar to the Left Araç çubuğunu sola taşı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index bb02201b34..ef8def435f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4337,3 +4337,5 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Auto-size Canvas Автоматично змінювати розмір полотна Keep Relative Position During Layer Reordering Зберігати відносне положення під час зміни порядку шарів Shrinkwrap Canvas on Save Зменшувати полотно до вмісту під час збереження + Move Toolbar to the Right Перемістити панель інструментів праворуч + Move Toolbar to the Left Перемістити панель інструментів ліворуч diff --git a/crates/ui-egui/src/i18n/ur.tsv b/crates/ui-egui/src/i18n/ur.tsv index 75d773c7c4..2851d7d070 100644 --- a/crates/ui-egui/src/i18n/ur.tsv +++ b/crates/ui-egui/src/i18n/ur.tsv @@ -4568,6 +4568,8 @@ navigator Zoom زوم @plural {n} action file loaded|{n} action files loaded {n} ایکشن فائل لوڈ ہوئی|{n} ایکشن فائلیں لوڈ ہوئیں @plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} فائل لوڈ نہیں ہو سکی: {errors}|{n} فائلیں لوڈ نہیں ہو سکیں: {errors} @plural {what}: {n} Image Assets output failed|{what}: {n} Image Assets outputs failed {what}: {n} تصویری اثاثہ آؤٹ پٹ ناکام ہوا|{what}: {n} تصویری اثاثے آؤٹ پٹس ناکام ہوئے + Move Toolbar to the Right ٹول بار کو دائیں منتقل کریں + Move Toolbar to the Left ٹول بار کو بائیں منتقل کریں # RAW Develop feature labels Saved RAW Develop settings to {file} RAW ڈیولپمنٹ کی ترتیبات {file} میں محفوظ ہو گئیں diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 684e4efd51..00cde2af6c 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4132,3 +4132,5 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Auto-size Canvas Tự động đổi kích thước vùng vẽ Keep Relative Position During Layer Reordering Giữ vị trí tương đối khi sắp xếp lại lớp Shrinkwrap Canvas on Save Thu nhỏ vùng vẽ theo nội dung khi lưu + Move Toolbar to the Right Di chuyển thanh công cụ sang phải + Move Toolbar to the Left Di chuyển thanh công cụ sang trái diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 7df8f0f93b..593a09ab2b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3320,6 +3320,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Auto-size Canvas 自动调整画布大小 Keep Relative Position During Layer Reordering 重新排列图层时保持相对位置 Shrinkwrap Canvas on Save 保存时将画布缩至内容大小 + Move Toolbar to the Right 将工具栏移到右侧 + Move Toolbar to the Left 将工具栏移到左侧 # RAW Develop feature labels RAW Develop RAW 显影 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 39906afad6..85d6d589df 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3278,6 +3278,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Auto-size Canvas 自動調整版面尺寸 Keep Relative Position During Layer Reordering 重新排列圖層時保持相對位置 Shrinkwrap Canvas on Save 儲存時將版面縮至內容大小 + Move Toolbar to the Right 將工具列移到右側 + Move Toolbar to the Left 將工具列移到左側 # RAW Develop feature labels RAW Develop RAW 顯影 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 5b89a68d20..5934e11352 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -110,6 +110,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("paint-bucket", include_bytes!("../../../assets/icons/paint-bucket.svg")), ("painting-symmetry", include_bytes!("../../../assets/icons/painting-symmetry.svg")), ("palette", include_bytes!("../../../assets/icons/palette.svg")), + ("panel-left", include_bytes!("../../../assets/icons/panel-left.svg")), ("panel-right", include_bytes!("../../../assets/icons/panel-right.svg")), ("panels-top-left", include_bytes!("../../../assets/icons/panels-top-left.svg")), ("path-op-combine", include_bytes!("../../../assets/icons/path-op-combine.svg")), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index c4a8601769..4705eb5b64 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -72,8 +72,11 @@ pub(crate) use color::{color_field, color_field_fill_id, field_chips, field_hsva pub(crate) use color::{hsva_color32, hsva_srgb, srgb_bytes}; #[cfg(test)] pub(crate) use dock::DOCK_MIN_WIDTH; +#[cfg(test)] +pub(crate) use dock::dock_panel_width; pub(crate) use dock::{canvas_panel, dock_body, dock_width_id, dock_width_range}; -pub use dock::{dock_area, request_dock_width}; +pub(crate) use dock::{clear_dock_edge_drag, dock_edge_drag_active}; +pub use dock::{dock_area, dock_panel, request_dock_width}; #[cfg(test)] use history::history; #[cfg(test)] diff --git a/crates/ui-egui/src/panels/dock.rs b/crates/ui-egui/src/panels/dock.rs index 5784e0042d..9613b0e065 100644 --- a/crates/ui-egui/src/panels/dock.rs +++ b/crates/ui-egui/src/panels/dock.rs @@ -19,6 +19,205 @@ pub fn dock_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::color_panel_ui::persist(app); } +/// The dock and its icon rail as one right panel, for the layout with the toolbox between the +/// canvas and the dock. The canvas is drawn separately afterwards. Keeping the rail inside this +/// panel means the toolbox cannot cover it when it is one column wide. +pub fn dock_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + // Lets the frame tick expire a drag start if this panel stops rendering (dock hidden). + let frame = ui.ctx().cumulative_frame_nr(); + ui.ctx().data_mut(|data| data.insert_temp(egui::Id::unique("dock-edge-rendered-frame"), frame)); + let t = Tokens::get(ui.ctx()); + let (rail_w, _) = rail_widths(t.pro); + let rail_shown = app.ui.panels.rail; + let rw = if rail_shown { rail_w } else { 0.0 }; + let content = ui.available_rect_before_wrap(); + let range = crate::panels::dock_width_range(content.width()); + // The dock must leave the toolbox and the canvas's 200-point minimum room: the standard + // layout enforces the canvas minimum inside craft-ui, the fixed-size dock-edge panel does not. + let toolbar_w = super::toolbar::toolbar_width(t.pro, app.ui.panels.toolbar_double); + let max_dock = (*range.end()).min((content.width() - toolbar_w - crate::panel_docking::CANVAS_MIN_WIDTH - rw).max(1.0)); + let min_dock = (*range.start()).min(max_dock); + let locked = app.session.prefs().workspace_locked; + let modal = crate::panel_docking::modal_open(app, ui.ctx()); + crate::panel_docking::apply_pending_dock_width(app, ui.ctx()); + let metrics = crate::panel_docking::dock_metrics(app); + let empty = metrics.empty; + if empty { + // Nothing to resize or draw in the dock column, but Studio draws its floating Properties + // group from the docking pass: keep that pass, on the full remaining area, with no column. + if rail_shown { + crate::direction::end_panel("dock-column").resizable(false).show_separator_line(false).exact_size(rw).frame(egui::Frame::NONE.fill(t.chrome)).show( + ui, + |ui| { + ui.spacing_mut().item_spacing.x = 0.0; + let rect = ui.max_rect(); + let inner = rect.shrink2(egui::vec2(4.0, 8.0)); + let mut rail_ui = ui.new_child(egui::UiBuilder::new().max_rect(inner)); + rail_body(app, &mut rail_ui, &t); + }, + ); + } + crate::panels::clear_dock_edge_drag(ui.ctx()); + // A child that does not consume the parent's layout space: the docking pass still draws + // floating windows, but the toolbox and canvas keep their room. + let mut dock_ui = ui.new_child(egui::UiBuilder::new().max_rect(ui.max_rect())); + crate::panel_docking::show_dock(app, &mut dock_ui); + crate::dock::persist(app, ui.ctx()); + crate::color_panel_ui::persist(app); + return; + } + let iconic = metrics.iconic_only; + let natural_min = if iconic { metrics.minimum } else { metrics.minimum.max(min_dock) }; + let max_dock = max_dock.min(if metrics.maximum.is_finite() { metrics.maximum.max(natural_min) } else { max_dock }); + let feasible = natural_min <= max_dock + 0.5; + let min_visible = natural_min.min(max_dock); + // Draw the width the layout actually has, not the minimum the gestures enforce. + let mut stored = metrics.width.clamp(1.0, max_dock); + let generation = app.ui.docking_generation; + let drag_id = egui::Id::unique("dock-edge-drag"); + // Resizing is unavailable while locked, modal, when the parent UI is disabled (screen picking + // disables it), or when the window cannot fit the column minima. A disabled handle never + // receives drag_stopped, so drop any stored start here; a start from another workspace + // generation is stale and must not touch this one. + let resizable = !locked && !modal && ui.is_enabled() && feasible; + let mut drag = match if resizable { ui.ctx().data(|d| d.get_temp::<(f32, f32, u64)>(drag_id)) } else { None } { + Some((start_x, start_w, saved)) if saved == generation => Some((start_x, start_w)), + Some(_) => { + crate::panels::clear_dock_edge_drag(ui.ctx()); + None + } + None => { + if !resizable { + crate::panels::clear_dock_edge_drag(ui.ctx()); + } + None + } + }; + let lower = (min_visible + rw).min(max_dock + rw); + let upper = max_dock + rw; + // A gesture applies its minimum even without motion; idle may retain a narrower saved width. + let pointer_width = |start_x: f32, start_w: f32| { + let pointer = ui.ctx().input(|i| i.pointer.latest_pos()).map_or(start_x, |pos| pos.x); + let dx = if crate::direction::is_rtl() { pointer - start_x } else { start_x - pointer }; + (start_w + dx).clamp(lower, upper) + }; + // The release frame still carries the final pointer position; apply it before dropping the + // drag, so the dock keeps the width the user released at instead of the previous frame's. + if let Some((start_x, start_w)) = drag + && !ui.ctx().input(|i| i.pointer.any_down()) + { + let final_w = pointer_width(start_x, start_w); + let requested = (final_w - rw).clamp(min_visible, max_dock); + stored = crate::panel_docking::set_end_dock_total(app, requested).unwrap_or(requested).clamp(1.0, max_dock); + drag = None; + ui.ctx().data_mut(|d| d.remove::<(f32, f32, u64)>(drag_id)); + } + let mut panel_w = if let Some((start_x, start_w)) = drag { pointer_width(start_x, start_w) } else { stored + rw }; + if drag.is_some() && resizable { + let requested = (panel_w - rw).clamp(min_visible, max_dock); + if let Some(actual) = crate::panel_docking::set_end_dock_total(app, requested) { + // The model can land somewhere other than the pointer request (minimums, iconic + // caps); draw the panel at the width that was actually stored. + panel_w = (actual + rw).clamp(lower, upper); + } + } + let panel = + crate::direction::end_panel("dock-column").resizable(false).show_separator_line(false).exact_size(panel_w).frame(egui::Frame::NONE.fill(t.card)).show( + ui, + |ui| { + ui.spacing_mut().item_spacing.x = 0.0; + let height = ui.available_height(); + let mut dock_rect = ui.max_rect(); + if rail_shown { + // The rail sits between the dock (at the window edge) and the toolbox, with the + // chrome fill and inset the standalone rail panel gives it. + let rail_rect = if crate::direction::is_rtl() { + egui::Rect::from_min_size(egui::pos2(dock_rect.right() - rw, dock_rect.top()), egui::vec2(rw, height)) + } else { + egui::Rect::from_min_size(dock_rect.min, egui::vec2(rw, height)) + }; + ui.painter().rect_filled(rail_rect, 0.0, t.chrome); + ui.allocate_rect(rail_rect, egui::Sense::hover()); + let inner = rail_rect.shrink2(egui::vec2(4.0, 8.0)); + let mut rail_ui = ui.new_child(egui::UiBuilder::new().max_rect(inner)); + rail_body(app, &mut rail_ui, &t); + if crate::direction::is_rtl() { + dock_rect.max.x = rail_rect.left(); + } else { + dock_rect.min.x = rail_rect.right(); + } + } + let mut dock_ui = ui.new_child(egui::UiBuilder::new().max_rect(dock_rect)); + // The docking pass also renders floating windows (Studio floats Properties over the + // canvas), so it runs even when the dock column itself is empty. + crate::panel_docking::show_dock(app, &mut dock_ui); + // Block the docking layout's column-edge handle at the dock's content edge. It is + // registered before the resize handle so the handle wins where they overlap when the + // rail is hidden and both edges coincide; one point either side keeps tab strips and + // scrollbars clear. + let dock_edge = if crate::direction::is_rtl() { dock_rect.right() } else { dock_rect.left() }; + let cover = egui::Rect::from_min_max(egui::pos2(dock_edge - 1.0, dock_rect.top()), egui::pos2(dock_edge + 1.0, dock_rect.bottom())); + let _ = ui.interact(cover, egui::Id::unique("dock-content-edge"), egui::Sense::click_and_drag()); + // The panel's inner edge (between the toolbox and the rail) is the resize handle. + let panel_rect = ui.max_rect(); + let panel_edge = if crate::direction::is_rtl() { panel_rect.right() } else { panel_rect.left() }; + let handle = egui::Rect::from_min_max(egui::pos2(panel_edge - 4.0, panel_rect.top()), egui::pos2(panel_edge + 4.0, panel_rect.bottom())); + // A locked workspace, an empty dock (nothing to resize), a modal dialog or a disabled + // UI leaves the handle inert. + let sense = if resizable { egui::Sense::drag() } else { egui::Sense::hover() }; + let response = ui.interact(handle, egui::Id::unique("dock-edge-handle"), sense); + // The bar is always visible, on the foreground layer so no panel, rail or dock + // content can hide it; it brightens under the pointer. + let active = resizable && (response.hovered() || response.dragged()); + if active { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + let line = egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("dock-edge-line")); + // The theme's separator colour at rest, the accent while the edge is hovered or + // dragged: a dark divider that lights up, not a permanently lit bar. + let color = if active { t.accent } else { t.separator }; + ui.ctx().layer_painter(line).vline(panel_edge, panel_rect.y_range(), Stroke::new(1.0, color)); + if response.drag_started() { + let start_x = response.interact_pointer_pos().map_or(panel_edge, |pos| pos.x); + ui.ctx().data_mut(|d| d.insert_temp(drag_id, (start_x, panel_w, generation))); + } + if response.dragged() { + ui.ctx().request_repaint(); + } + if response.drag_stopped() { + ui.ctx().data_mut(|d| d.remove::<(f32, f32, u64)>(drag_id)); + } + }, + ); + crate::panel_docking::record::dock_panel_rect(ui.ctx(), panel.response.rect); + crate::dock::persist(app, ui.ctx()); + crate::color_panel_ui::persist(app); +} + +/// True while the dock-edge resize drag has a stored start: a synthetic/accessibility click can +/// arrive with no pointer button down, but the in-progress dock width must not be persisted. +pub(crate) fn dock_edge_drag_active(ctx: &egui::Context) -> bool { + ctx.data(|data| data.get_temp::<(f32, f32, u64)>(egui::Id::unique("dock-edge-drag")).is_some()) +} + +/// Drops a dock-edge drag start that can no longer be completed (its panel stopped rendering). +pub(crate) fn clear_dock_edge_drag(ctx: &egui::Context) { + ctx.data_mut(|data| data.remove::<(f32, f32, u64)>(egui::Id::unique("dock-edge-drag"))); +} + +/// The width the dock column took in the last frame (tests: an iconic-only dock must take its +/// narrow width and return the rest to the canvas). +#[cfg(test)] +pub(crate) fn dock_panel_width(ctx: &egui::Context) -> f32 { + dock_panel_rect(ctx).width() +} + +/// The actual allocated dock panel rectangle, not its requested pre-render width (tests). +#[cfg(test)] +pub(crate) fn dock_panel_rect(ctx: &egui::Context) -> egui::Rect { + ctx.data(|data| data.get_temp::(egui::Id::unique("photo-dock-panel-rect")).unwrap_or(egui::Rect::NOTHING)) +} + /// The docking layout's protected canvas panel: the document area, with the icon rail on its end /// side, beside the dock, in the Pro themes. pub(crate) fn canvas_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { @@ -30,43 +229,52 @@ pub(crate) fn canvas_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { egui::CentralPanel::default().frame(egui::Frame::NONE.fill(backdrop)).show(ui, |ui| crate::canvas::document_area(app, ui)); } +/// The icon rail's width and its buttons' size, in points. +fn rail_widths(pro: bool) -> (f32, f32) { + if pro { (36.0, 28.0) } else { (44.0, 32.0) } +} + /// Narrow icon rail, unless an embedding app hides it: shows, expands or collapses panel groups. fn rail(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: &Tokens) { - let customized = app.ui.docking.is_some(); - let p = app.ui.panels.clone(); - let (rw, rb) = if t.pro { (36.0, 28.0) } else { (44.0, 32.0) }; + let (rw, _) = rail_widths(t.pro); crate::direction::end_panel("rail") .resizable(false) .exact_size(rw) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(4, 8))) - .show(ui, |ui| { - // The separator is on the canvas side. - let r = ui.max_rect(); - let x = if crate::direction::is_rtl() { r.right() + 6.0 } else { r.left() - 6.0 }; - ui.painter().line_segment([pos2(x, r.top() - 8.0), pos2(x, r.bottom() + 8.0)], Stroke::new(1.0, t.separator)); - ui.spacing_mut().item_spacing.y = 4.0; - use crate::dock::Group; - let entries: [(&str, &str, Group); 6] = [ - ("sliders-horizontal", tl!("Properties"), Group::Properties), - ("navigation", tl!("Navigator"), Group::Navigator), - ("palette", tl!("Color & Swatches"), Group::Color), - ("layers", tl!("Layers"), Group::Layers), - ("clock", tl!("History"), Group::History), - ("brush", tl!("Brushes"), Group::Brushes), - ]; - for (icon, name, g) in entries { - // Studio floats Properties outside the dock. - let docked = t.pro || g != Group::Properties; - let on = if customized { crate::panel_docking::rail_on(app, g) } else { g.shown(&p) && !(docked && app.ui.dock.is_collapsed(g)) }; - if icons::rail_button(ui, icon, rb, on, name).clicked() { - if customized { - crate::panel_docking::rail_click(app, g); - } else { - crate::dock::rail_click(app, g, docked); - } - } + .show(ui, |ui| rail_body(app, ui, t)); +} + +/// The rail's buttons, without the panel that normally holds them. +fn rail_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: &Tokens) { + let customized = app.ui.docking.is_some(); + let p = app.ui.panels.clone(); + let (_, rb) = rail_widths(t.pro); + // The separator is on the canvas side, inside the rail's inset. + let r = ui.max_rect(); + let x = if crate::direction::is_rtl() { r.right() } else { r.left() }; + ui.painter().line_segment([pos2(x, r.top()), pos2(x, r.bottom())], Stroke::new(1.0, t.separator)); + ui.spacing_mut().item_spacing.y = 4.0; + use crate::dock::Group; + let entries: [(&str, &str, Group); 6] = [ + ("sliders-horizontal", tl!("Properties"), Group::Properties), + ("navigation", tl!("Navigator"), Group::Navigator), + ("palette", tl!("Color & Swatches"), Group::Color), + ("layers", tl!("Layers"), Group::Layers), + ("clock", tl!("History"), Group::History), + ("brush", tl!("Brushes"), Group::Brushes), + ]; + for (icon, name, g) in entries { + // Studio floats Properties outside the dock. + let docked = t.pro || g != Group::Properties; + let on = if customized { crate::panel_docking::rail_on(app, g) } else { g.shown(&p) && !(docked && app.ui.dock.is_collapsed(g)) }; + if icons::rail_button(ui, icon, rb, on, name).clicked() { + if customized { + crate::panel_docking::rail_click(app, g); + } else { + crate::dock::rail_click(app, g, docked); } - }); + } + } } /// The dock's narrowest width (points). It is the right dock, on the left in a right-to-left UI. @@ -87,8 +295,10 @@ pub(crate) fn dock_width_id() -> egui::Id { egui::Id::unique("dock-width-request") } -/// Set the right dock's width on the next frame (`ui.set {dockWidth}`); clamped to its range. +/// Set the end dock's width on the next frame (`ui.set {dockWidth}`); clamped to its +/// window-dependent range. An external request supersedes an in-progress outer resize. pub fn request_dock_width(ctx: &egui::Context, w: f32) { + clear_dock_edge_drag(ctx); let range = dock_width_range(ctx.content_rect().width()); let w = if w.is_finite() { w.clamp(*range.start(), *range.end()) } else { *range.start() }; ctx.data_mut(|d| d.insert_temp(dock_width_id(), w)); diff --git a/crates/ui-egui/src/panels/panel_docking.rs b/crates/ui-egui/src/panels/panel_docking.rs index 6029ae52b4..6e962cc553 100644 --- a/crates/ui-egui/src/panels/panel_docking.rs +++ b/crates/ui-egui/src/panels/panel_docking.rs @@ -90,6 +90,179 @@ fn default_width(pro: bool) -> f32 { if pro { 290.0 } else { 300.0 } } +/// The end dock of `layout`: follow only the second child of horizontal splits, as craft-ui +/// keeps a side's dock at its window edge; this app builds that dock as `Side::End`. `None` when +/// the layout has no end dock. +fn end_dock_node(node: &Node) -> Option<&Node> { + match node { + Node::Dock { .. } => Some(node), + Node::Split { axis: SplitAxis::Horizontal, second, .. } => end_dock_node(second), + _ => None, + } +} + +/// The end dock's layout facts, computed from one layout snapshot per frame. +pub(crate) struct DockMetrics { + pub(crate) empty: bool, + pub(crate) iconic_only: bool, + pub(crate) width: f32, + pub(crate) minimum: f32, + pub(crate) maximum: f32, +} + +/// The end dock's displayed width, whether it has no panels, and whether every column is iconic. +/// A layout built from the native dock is materialized once here rather than in each helper. +pub(crate) fn dock_metrics(app: &PhotocraftApp) -> DockMetrics { + let layout = match &app.ui.docking { + Some(workspace) => workspace.layout.clone(), + None => legacy(app), + }; + match layout.root.as_ref().and_then(end_dock_node) { + Some(Node::Dock { columns, .. }) if !columns.is_empty() => DockMetrics { + empty: columns.iter().all(|column| column.groups.iter().all(|group| group.panels.is_empty())), + iconic_only: columns.iter().all(|column| column.iconic), + width: columns.iter().map(display_width).sum(), + minimum: columns_minimum(columns), + maximum: columns_maximum(columns), + }, + _ => DockMetrics { empty: true, iconic_only: false, width: default_width(app.ui.theme.is_pro()), minimum: 0.0, maximum: f32::INFINITY }, + } +} + +/// The end dock's displayed width (tests). +#[cfg(test)] +pub(crate) fn dock_width(app: &PhotocraftApp) -> f32 { + dock_metrics(app).width +} + +/// True when the end dock holds no panels at all (tests). +#[cfg(test)] +pub(crate) fn dock_is_empty(app: &PhotocraftApp) -> bool { + dock_metrics(app).empty +} + +/// craft-ui's iconic column width without a saved width, and its renderer's clamp. +pub(crate) const ICONIC_WIDTH: f32 = 36.0; +const ICONIC_MAX_WIDTH: f32 = 240.0; + +/// A column's width as craft-ui draws it: `iconic_width` (default 36, clamped to 36..240) while +/// collapsed to icons, else the expanded width. +fn display_width(column: &Column) -> f32 { + if column.iconic { column.iconic_width.unwrap_or(ICONIC_WIDTH).clamp(ICONIC_WIDTH, ICONIC_MAX_WIDTH) } else { column.width } +} + +/// The canvas panel's least width in points, for the docking layout and the dock-edge layout. +pub(crate) const CANVAS_MIN_WIDTH: f32 = 200.0; + +/// The narrowest column, in points, that still draws a panel's body; a narrower one shows only +/// its chrome. Deliberately independent of [`CANVAS_MIN_WIDTH`], although both are 200. +const PANEL_BODY_MIN_WIDTH: f32 = 200.0; + +/// The least displayed width of a column: the app's 220-point panel minimum, unless the column is +/// iconic (its icons fit in the narrow width) or has no panels. +const MIN_COLUMN_WIDTH: f32 = 220.0; + +/// The least displayed width of `column`: [`ICONIC_WIDTH`] when iconic, one point when it +/// holds no panels, else [`MIN_COLUMN_WIDTH`]. +fn column_minimum(column: &Column) -> f32 { + if column.iconic { + ICONIC_WIDTH + } else if column.groups.iter().all(|group| group.panels.is_empty()) { + 1.0 + } else { + MIN_COLUMN_WIDTH + } +} + +/// The sum of the columns' displayed-width minimums. +fn columns_minimum(columns: &[Column]) -> f32 { + columns.iter().map(column_minimum).sum() +} + +/// The greatest displayed width of a column: iconic columns stop at the renderer's cap, expanded +/// columns can use whatever the window gives them. +fn column_maximum(column: &Column) -> f32 { + if column.iconic { ICONIC_MAX_WIDTH } else { f32::INFINITY } +} + +/// The sum of the columns' displayed-width maximums; infinite while any column is expanded. +fn columns_maximum(columns: &[Column]) -> f32 { + columns.iter().map(column_maximum).sum() +} + +/// Sets a column's displayed width. An iconic column keeps its expanded width for when the user +/// expands it again; only `iconic_width` changes. +fn set_display_width(column: &mut Column, width: f32) { + let width = width.max(1.0); + if column.iconic { + column.iconic_width = Some(width.clamp(ICONIC_WIDTH, ICONIC_MAX_WIDTH)); + } else { + column.width = width; + } +} + +/// Scales every column so their displayed widths add up to `total`, respecting the per-column +/// panel minimum. Returns the resulting displayed total (rounding can differ by a point). +fn scale_columns(columns: &mut [Column], total: f32) -> f32 { + if columns.is_empty() { + return 0.0; + } + // Bring the existing widths into their bounds, then move the total toward the request in + // passes, freezing columns that hit a bound so their refused space reaches the others. + for column in columns.iter_mut() { + let width = display_width(column).clamp(column_minimum(column), column_maximum(column)); + set_display_width(column, width); + } + let minimum = columns_minimum(columns); + let maximum = columns_maximum(columns); + let target = total.clamp(minimum, maximum.max(minimum)).max(1.0); + let mut sum: f32 = columns.iter().map(display_width).sum(); + // Every unfinished pass freezes at least one column; allow a final convergence pass. + for _ in 0..=columns.len() { + let diff = target - sum; + if diff.abs() < 0.5 { + break; + } + let candidates: Vec = columns + .iter() + .enumerate() + .filter( + |(_, column)| { + if diff > 0.0 { display_width(column) < column_maximum(column) - 0.01 } else { display_width(column) > column_minimum(column) + 0.01 } + }, + ) + .map(|(index, _)| index) + .collect(); + if candidates.is_empty() { + break; + } + let share = diff / candidates.len() as f32; + let mut changed = false; + for index in candidates { + let old = display_width(&columns[index]); + let width = (old + share).clamp(column_minimum(&columns[index]), column_maximum(&columns[index])); + if (width - old).abs() > 0.001 { + set_display_width(&mut columns[index], width); + changed = true; + } + } + sum = columns.iter().map(display_width).sum(); + if !changed { + break; + } + } + sum +} + +/// The end dock's columns, following the same horizontal second-child path as [`end_dock_node`]. +fn dock_columns_mut(node: &mut Node) -> Option<&mut Vec>> { + match node { + Node::Dock { columns, .. } => Some(columns), + Node::Split { axis: SplitAxis::Horizontal, second, .. } => dock_columns_mut(second), + _ => None, + } +} + /// The window id of the Studio themes' floating Properties group in a layout built each frame: /// the same every time, with the top bit set, where craft-ui's fresh ids never reach. const PROPERTIES_WINDOW: u64 = (1 << 63) | 0x5052_4f50; @@ -524,9 +697,85 @@ fn app_owned(app: &mut PhotocraftApp, command: DockCommand) -> Result u32 { + ctx.data(|data| data.get_temp::(egui::Id::unique("photo-dock-show-calls")).unwrap_or(0)) +} + +/// The dock column of a canvas-plus-dock layout, with the canvas removed. +fn without_canvas(layout: Layout) -> Layout { + match layout.root { + Some(Node::Split { second, .. }) => Layout { root: Some(*second), floating: layout.floating }, + _ => layout, + } +} + +/// True when the layout's canvas side holds nothing but the canvas. +/// +/// The dock-edge toolbox layout presents the dock column without its canvas so the toolbox can sit +/// between them. That presentation drops the canvas side of the root split, so it is only safe +/// while the canvas is alone there: a panel docked beside the canvas keeps the standard layout, +/// where craft-ui draws it and accepts drops beside it. +pub(crate) fn canvas_alone(app: &PhotocraftApp) -> bool { + match &app.ui.docking { + Some(workspace) => canvas_alone_layout(&workspace.layout), + // The layout built from the native dock always has the canvas alone on its side. + None => true, + } +} + +/// True when the layout is the canvas-beside-dock shape: a horizontal root split whose first +/// child holds only the canvas and whose second child is the dock itself. A trailing split or +/// stack is not an end dock: resizing it would snap back on release, so it keeps the standard +/// layout. +fn canvas_alone_layout(layout: &Layout) -> bool { + match &layout.root { + Some(Node::Split { axis: SplitAxis::Horizontal, first, second, .. }) => { + matches!(first.as_ref(), Node::Tabs { panels, .. } if panels.len() == 1 && panels[0] == CANVAS) && matches!(second.as_ref(), Node::Dock { .. }) + } + _ => false, + } +} + +/// Apply an external width request before the dock-edge panel measures its allocation. +/// Standard rendering consumes the same queue. Without an explicit request, native PanelState +/// migration stays in `show` with its existing canvas-readiness guard. +pub(crate) fn apply_pending_dock_width(app: &mut PhotocraftApp, ctx: &egui::Context) { + let id = crate::panels::dock_width_id(); + let Some(width) = ctx.data(|data| data.get_temp::(id)) else { return }; + // Let `show` restore an invalid workspace before consuming its queued request, as before. + if app.ui.docking.as_ref().is_some_and(|workspace| !valid_workspace(workspace)) { + return; + } + // An explicit request supersedes one-time native migration, including a legacy no-op + // that must not create a custom workspace or wait for the canvas to become ready. + let native_id = crate::panels::panel_state_id(ctx, "dock"); + ctx.data_mut(|data| { + data.remove::(id); + data.remove::(native_id); + }); + let range = crate::panels::dock_width_range(ctx.content_rect().width()); + set_dock_width(app, width.clamp(*range.start(), *range.end())); +} + /// Draw the layout, the canvas included, in the space `ui` has left: the customized workspace, /// else the native dock's arrangement. pub(crate) fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + show_layout(app, ui, false); +} + +/// Draw the workspace layout; `dock_only` drops the canvas so [`show_dock`] draws only the dock +/// column and the toolbox can sit between them. +fn show_layout(app: &mut PhotocraftApp, ui: &mut egui::Ui, dock_only: bool) { let area = area_id(); let changed_workspace = ui.ctx().data_mut(|data| { let previous = data.get_temp::(area.with("generation")); @@ -542,15 +791,13 @@ pub(crate) fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let message = "Invalid panel layout; restored the default dock"; app.ui.status = tl!(message).into(); } - if let Some(width) = ui.ctx().data_mut(|data| data.remove_temp::(crate::panels::dock_width_id())) { - let range = crate::panels::dock_width_range(ui.ctx().content_rect().width()); - set_dock_width(app, width.clamp(*range.start(), *range.end())); - } + apply_pending_dock_width(app, ui.ctx()); native_dock_width(app, ui.ctx()); let layout = match &app.ui.docking { Some(workspace) => workspace.layout.clone(), None => legacy(app), }; + let layout = if dock_only { without_canvas(layout) } else { layout }; let collapsed = collapsed_members(app, &layout); let layout = presented(layout, &collapsed); let fillers = fillers(&layout); @@ -587,8 +834,7 @@ pub(crate) fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let strip = style.tab_height; let card: std::collections::HashSet = layout.floating.iter().filter(|window| window.id == PROPERTIES_WINDOW).flat_map(|window| window.panels().cloned()).collect(); - #[cfg(test)] - ui.ctx().data_mut(|data| data.insert_temp(bodies_id(), Vec::<(String, egui::Rect)>::new())); + record::clear_bodies(ui.ctx()); // The panel windows' passes, inside this call, note their keyboard focus again. keyboard_focus(ui.ctx()).lock().unwrap_or_else(|e| e.into_inner()).clear(); let mut output = dock.show_customized( @@ -599,7 +845,7 @@ pub(crate) fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { permissions, |id| { if id == CANVAS { - craft_ui::docking::PanelLimits { min: egui::vec2(200.0, 120.0), ..Default::default() } + craft_ui::docking::PanelLimits { min: egui::vec2(CANVAS_MIN_WIDTH, 120.0), ..Default::default() } } else if collapsed.contains(id) { craft_ui::docking::PanelLimits { min: egui::vec2(220.0, 0.0), max: egui::vec2(1_000_000.0, 0.0) } } else { @@ -748,25 +994,83 @@ fn pin_group_heights(ctx: &egui::Context, layout: &Layout, actions: &mut actions.splice(0..0, pins); } -/// `ui.set {dockWidth}`: the width of the end dock's outer column. -fn set_dock_width(app: &mut PhotocraftApp, width: f32) { - let Ok(mut workspace) = workspace_of(app) else { return }; - let outer = match workspace.layout.root.as_ref() { - Some(Node::Split { second, .. }) => match second.as_ref() { - Node::Dock { columns, .. } => columns.first().map(|column| (column.width, column.groups.iter().flat_map(|g| g.panels.first()).next().cloned())), - _ => None, - }, - _ => None, - }; - let Some((current, Some(panel))) = outer else { return }; - if current == width { +/// `ui.set {dockWidth}`: the whole end dock width, distributing changes across its columns +/// within their individual minimums and iconic caps. +/// An empty end dock has no column to store the width in; the request is a no-op until the +/// dock gets a column. +pub(crate) fn set_dock_width(app: &mut PhotocraftApp, width: f32) { + if !width.is_finite() || width <= 0.0 { return; } - workspace.locked = false; - if workspace.apply(Action::ResizeColumn { panel, width }, permissions).is_ok() { - workspace.locked = app.session.prefs().workspace_locked; - let returns = app.ui.docking_returns.clone(); - commit(app, workspace, returns); + let Ok(mut workspace) = workspace_of(app) else { return }; + let Some(root) = workspace.layout.root.as_mut() else { return }; + let Some(columns) = dock_columns_mut(root) else { return }; + if columns.is_empty() { + return; + } + // A request for the width the dock already has must stay a no-op: committing the legacy + // layout here would turn a later `ui.set {dockTabs}` into a customized-workspace update. + let current: f32 = columns.iter().map(display_width).sum(); + let compliant = columns.iter().all(|column| (column_minimum(column)..=column_maximum(column)).contains(&display_width(column))); + if compliant && (current - width).abs() < 0.5 { + return; + } + let actual = scale_columns(columns, width); + if actual <= 0.0 { + return; + } + // The columns already carry the requested total; this only brings the split holding the dock + // in step with it. + let mut split_width = actual; + resize_end_dock(root, actual, &mut split_width); + workspace.locked = app.session.prefs().workspace_locked; + let returns = app.ui.docking_returns.clone(); + commit(app, workspace, returns); +} + +/// Set the end dock's total width in place, distributing changes within each column's bounds and +/// keeping its horizontal split in step. Creates the custom workspace from the legacy +/// dock the first time a change is needed. Returns the resulting displayed width, including a +/// legacy no-op, or `None` for rejected input, a locked workspace, or no end-dock column. +pub(crate) fn set_end_dock_total(app: &mut PhotocraftApp, total: f32) -> Option { + if !total.is_finite() || total <= 0.0 || app.session.prefs().workspace_locked { + return None; + } + if app.ui.docking.is_none() { + set_dock_width(app, total); + if app.ui.docking.is_none() { + // A compliant same-width legacy request must not customize the workspace. + let metrics = dock_metrics(app); + return (!metrics.empty).then_some(metrics.width); + } + } + let workspace = app.ui.docking.as_mut()?; + let root = workspace.layout.root.as_mut()?; + let mut actual = total; + if !resize_end_dock(root, total, &mut actual) { + return None; + } + Some(actual) +} + +/// The end dock's columns at `total`, and the split that holds the dock at its window edge. +fn resize_end_dock(node: &mut Node, total: f32, actual: &mut f32) -> bool { + match node { + Node::Dock { columns, .. } => { + if columns.is_empty() { + return false; + } + *actual = scale_columns(columns, total); + true + } + Node::Split { axis: SplitAxis::Horizontal, second, size, .. } => { + let found = resize_end_dock(second, total, actual); + if found && matches!(&**second, Node::Dock { .. }) { + *size = SplitSize::FixedSecond(*actual); + } + found + } + _ => false, } } @@ -850,6 +1154,55 @@ fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: &str) { } } +/// What the drawing code notes for the tests to read back; every write is a no-op outside tests. +pub(crate) mod record { + #[cfg(test)] + use super::bodies_id; + + /// Counts a [`super::show_dock`] call, for `show_dock_calls`. + #[cfg(test)] + pub(crate) fn show_dock_call(ctx: &egui::Context) { + ctx.data_mut(|data| { + let id = egui::Id::unique("photo-dock-show-calls"); + data.insert_temp(id, data.get_temp::(id).unwrap_or(0) + 1); + }); + } + + /// Starts this pass's list of drawn panel bodies, for `last_bodies`. + #[cfg(test)] + pub(crate) fn clear_bodies(ctx: &egui::Context) { + ctx.data_mut(|data| data.insert_temp(bodies_id(), Vec::<(String, egui::Rect)>::new())); + } + + /// Notes the body of panel `id`, for `last_bodies`. + #[cfg(test)] + pub(crate) fn body(ctx: &egui::Context, id: &str, rect: egui::Rect) { + ctx.data_mut(|data| data.get_temp_mut_or_default::>(bodies_id()).push((id.to_owned(), rect))); + } + + /// Notes the dock-edge layout's allocated dock rectangle, for `dock_panel_rect`. + #[cfg(test)] + pub(crate) fn dock_panel_rect(ctx: &egui::Context, rect: egui::Rect) { + ctx.data_mut(|data| data.insert_temp(egui::Id::unique("photo-dock-panel-rect"), rect)); + } + + /// Outside tests nothing is noted. + #[cfg(not(test))] + pub(crate) fn show_dock_call(_: &egui::Context) {} + + /// Outside tests nothing is noted. + #[cfg(not(test))] + pub(crate) fn clear_bodies(_: &egui::Context) {} + + /// Outside tests nothing is noted. + #[cfg(not(test))] + pub(crate) fn body(_: &egui::Context, _: &str, _: egui::Rect) {} + + /// Outside tests nothing is noted. + #[cfg(not(test))] + pub(crate) fn dock_panel_rect(_: &egui::Context, _: egui::Rect) {} +} + /// Where tests find panels and docking controls as drawn last frame. #[cfg(test)] pub(crate) mod probe { @@ -1215,9 +1568,10 @@ fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui, panel: &str) { } impl craft_ui::docking::DockContent for PanelContent<'_> { + /// Draws panel `id`'s body in `ui`: the canvas, or an expanded panel's contents when the + /// column leaves it at least 200 points. fn body(&mut self, ui: &mut egui::Ui, id: &String) { - #[cfg(test)] - ui.ctx().data_mut(|data| data.get_temp_mut_or_default::>(bodies_id()).push((id.clone(), ui.max_rect()))); + record::body(ui.ctx(), id, ui.max_rect()); if id == CANVAS { crate::panels::canvas_panel(self.app, ui); return; @@ -1228,7 +1582,7 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { } // A window too small for the dock squeezes its column below the panels' minimum width, // which their layouts do not take. - if !self.collapsed.contains(id) && ui.available_width() >= 200.0 { + if !self.collapsed.contains(id) && ui.available_width() >= PANEL_BODY_MIN_WIDTH { let pro = crate::theme::Tokens::get(ui.ctx()).pro; ui.spacing_mut().item_spacing.y = if pro { 0.0 } else { 6.0 }; egui::Frame::NONE.inner_margin(crate::widgets::body_margin(pro)).show(ui, |ui| body(self.app, ui, id)); diff --git a/crates/ui-egui/src/panels/panel_docking/tests.rs b/crates/ui-egui/src/panels/panel_docking/tests.rs index 886e6f0f8a..ef17aa37bd 100644 --- a/crates/ui-egui/src/panels/panel_docking/tests.rs +++ b/crates/ui-egui/src/panels/panel_docking/tests.rs @@ -1,5 +1,5 @@ use super::*; -use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; use egui_kittest::{Harness, kittest::Queryable}; fn app() -> PhotocraftApp { @@ -931,3 +931,1436 @@ fn the_native_dock_width_sizes_the_default_column() { assert_eq!(columns[0].width, 380.0); assert!(h.ctx.data_mut(|data| data.get_persisted::(key)).is_none(), "taken once"); } + +/// The dock-edge layout is only chosen for a horizontal root split whose canvas side is the lone +/// canvas and whose other side is the end dock itself. +#[test] +fn the_dock_edge_layout_only_applies_with_the_canvas_alone() { + let tabs = |panel: &str| Node::Tabs { panels: vec![panel.to_owned()], active: 0 }; + let dock = |panels: Vec<&str>, width: f32| Node::Dock { + side: Side::End, + columns: vec![Column::new(vec![craft_ui::docking::Group::new(panels.into_iter().map(str::to_owned).collect(), 0)], width)], + }; + let aside = |first: Node| Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedSecond(280.0), + first: Box::new(first), + second: Box::new(dock(vec!["color"], 280.0)), + }; + + let alone = Layout { root: Some(aside(tabs(CANVAS))), floating: Vec::new() }; + assert!(canvas_alone_layout(&alone), "{alone:?}"); + + // A panel docked beside the canvas splits the canvas side: not alone any more. + let beside = + Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(400.0), first: Box::new(tabs("layers")), second: Box::new(tabs(CANVAS)) }; + let layout = Layout { root: Some(aside(beside)), floating: Vec::new() }; + assert!(!canvas_alone_layout(&layout), "{layout:?}"); + + // The second child must be the end dock itself: a trailing split or tabs cannot be resized, + // so the handle would snap back on release. + let not_a_dock = Layout { + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedSecond(280.0), + first: Box::new(tabs(CANVAS)), + second: Box::new(tabs("color")), + }), + floating: Vec::new(), + }; + assert!(!canvas_alone_layout(¬_a_dock), "{not_a_dock:?}"); + + // A vertical root split with the canvas above another panel is not the canvas-beside-dock shape. + let vertical = Layout { + root: Some(Node::Split { + axis: SplitAxis::Vertical, + size: SplitSize::FixedSecond(280.0), + first: Box::new(tabs(CANVAS)), + second: Box::new(dock(vec!["color"], 280.0)), + }), + floating: Vec::new(), + }; + assert!(!canvas_alone_layout(&vertical), "{vertical:?}"); +} + +/// The end dock is followed through horizontal splits only; a vertical or tabs sibling is not an +/// end dock, so resizing it returns false instead of half-applying. +#[test] +fn the_end_dock_is_only_the_horizontal_split_s_second_child() { + let tabs = |panel: &str| Node::Tabs { panels: vec![panel.to_owned()], active: 0 }; + let dock = Node::Dock { side: Side::End, columns: vec![Column::new(vec![craft_ui::docking::Group::new(vec!["color".to_owned()], 0)], 260.0)] }; + let mut vertical = + Node::Split { axis: SplitAxis::Vertical, size: SplitSize::FixedSecond(300.0), first: Box::new(tabs(CANVAS)), second: Box::new(dock.clone()) }; + assert!(end_dock_node(&vertical).is_none(), "a vertical split has no end dock"); + let mut actual = 0.0; + assert!(!resize_end_dock(&mut vertical, 320.0, &mut actual), "resizing a vertical dock must not follow the split"); + + let mut horizontal = + Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedSecond(300.0), first: Box::new(tabs(CANVAS)), second: Box::new(dock) }; + assert!(end_dock_node(&horizontal).is_some()); + let mut actual = 0.0; + assert!(resize_end_dock(&mut horizontal, 320.0, &mut actual)); + assert!((actual - 320.0).abs() < 0.5, "the end dock takes the requested width, got {actual}"); +} + +/// The dock-only mode is an argument of one call, so a dock-only frame never leaves a later +/// standard layout without its canvas. +#[test] +fn dock_only_frames_never_leak_into_the_standard_layout() { + let mut ready = false; + let dock_only = std::rc::Rc::new(std::cell::Cell::new(false)); + let mode = dock_only.clone(); + let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); + ready = true; + return; + } + if mode.get() { show_dock(app, ui) } else { show(app, ui) } + }, + app(), + ); + let canvas_drawn = |h: &Harness<'static, PhotocraftApp>| last_bodies(&h.ctx).iter().any(|(id, _)| id == CANVAS); + h.run_steps(2); + for _ in 0..3 { + dock_only.set(true); + h.step(); + assert!(!canvas_drawn(&h), "show_dock draws no canvas"); + dock_only.set(false); + h.step(); + assert!(canvas_drawn(&h), "the standard layout after a dock-only frame keeps its canvas"); + } +} + +/// `ui.set` still refuses an unknown toolbar side, though saved panels read one as `Start`. +#[test] +fn ui_set_refuses_an_unknown_toolbar_side() { + let mut app = app(); + let ctx = egui::Context::default(); + for bad in [json!("diagonal"), json!(5), json!(null)] { + let reply = control_set(&mut app, &ctx, json!({"panels": {"toolbar_side": bad, "layers": false}})); + assert_ne!(reply["ok"], true, "{bad}: {reply}"); + assert_eq!(app.ui.panels.toolbar_side, crate::state::ToolbarSide::Start); + assert!(app.ui.panels.layers, "{bad}: a refused request changes nothing"); + } + let reply = control_set(&mut app, &ctx, json!({"panels": {"toolbar_side": "right"}})); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(app.ui.panels.toolbar_side, crate::state::ToolbarSide::Right); +} + +/// The toolbar lands on the requested physical side in every interface theme. +#[test] +fn the_toolbar_side_toggle_works_in_every_interface_theme() { + use crate::state::ToolbarSide; + + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); + ready = true; + return; + } + crate::panels::toolbar(app, ui); + }, + app(), + ); + for kind in crate::theme::ThemeKind::ALL { + crate::theme::apply(&h.ctx, kind); + h.state_mut().ui.panels.toolbar_side = ToolbarSide::Left; + h.run_steps(2); + // The header's chevron carries the toolbar's label; its screen X is the panel's side. + let left = h.get_by_label("Toolbar").rect().center().x; + assert!(left < 640.0, "{kind:?}: left toolbar at {left}"); + + // The dock button itself moves the toolbar, rather than the test setting the state. + h.get_by_label("Move Toolbar to the Right").click(); + h.run_steps(2); + assert_eq!(h.state().ui.panels.toolbar_side, ToolbarSide::Right, "{kind:?}: the button moves the toolbar right"); + let right = h.get_by_label("Toolbar").rect().center().x; + assert!(right > 640.0, "{kind:?}: right toolbar at {right}"); + // A grouped tool's flyout opens towards the canvas, to the left of its button. + h.query_by_label_contains("Rectangular Marquee Tool").expect("the marquee slot").click_secondary(); + h.run_steps(2); + let (key, button) = h.ctx.data(|d| d.get_temp::<(egui::Id, egui::Rect)>(egui::Id::unique("tool-flyout"))).expect("the flyout is open"); + let flyout = h.ctx.memory(|m| m.area_rect(key.with("flyout"))).expect("the flyout is drawn"); + assert!(flyout.right() <= button.left(), "{kind:?}: flyout {flyout:?} left of its button {button:?}"); + h.query_by_label_contains("Rectangular Marquee Tool").expect("the marquee slot").click_secondary(); + h.run_steps(2); + + h.get_by_label("Move Toolbar to the Left").click(); + h.run_steps(2); + assert_eq!(h.state().ui.panels.toolbar_side, ToolbarSide::Left, "{kind:?}: the button moves the toolbar left"); + let back = h.get_by_label("Toolbar").rect().center().x; + assert!(back < 640.0, "{kind:?}: left toolbar again at {back}"); + } +} + +/// Moving the toolbar persists at click time, even when the dock is not drawn. +#[test] +fn moving_the_toolbar_persists_without_the_dock() { + use crate::state::ToolbarSide; + let mut app = app(); + app.session.prefs.edit(|p| p.workspace.remember_workspace_changes = true); + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); + ready = true; + return; + } + crate::panels::toolbar(app, ui); + }, + app, + ); + h.run_steps(2); + h.get_by_label("Move Toolbar to the Right").click(); + h.run_steps(1); + assert_eq!(h.state().ui.panels.toolbar_side, ToolbarSide::Right); + let saved = &h.state().session.prefs().panel_layout; + assert_eq!(saved["panels"]["toolbar_side"], "right", "the toolbar side is persisted without the dock: {saved}"); +} + +/// A dock-edge harness: draw only the dock column, as the dock-edge frame layout does. +fn dock_panel_harness(app: PhotocraftApp, size: egui::Vec2, theme: crate::theme::ThemeKind) -> Harness<'static, PhotocraftApp> { + let mut ready = false; + let mut harness = Harness::builder().with_size(size).with_step_dt(1.0 / 60.0).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + PhotocraftApp::setup_context(ui.ctx(), theme); + ready = true; + return; + } + crate::panels::dock_panel(app, ui); + }, + app, + ); + harness.run_steps(4); + harness +} + +/// [`dock_panel_harness`] in the Pro Medium theme. +fn dock_edge_harness(app: PhotocraftApp, size: egui::Vec2) -> Harness<'static, PhotocraftApp> { + dock_panel_harness(app, size, crate::theme::ThemeKind::ProMedium) +} + +/// The dock-edge handle resizes the dock and keeps the new width after release; a locked +/// workspace makes the same drag a no-op. +#[test] +fn the_dock_edge_handle_resizes_and_a_locked_workspace_refuses_it() { + let drag_once = |locked: bool| -> (f32, f32, f32) { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers"})).unwrap(); + if locked { + app.session.prefs.edit(|p| p.workspace_locked = true); + } + let mut h = dock_edge_harness(app, vec2(1280.0, 800.0)); + let stored = dock_width(h.state()); + let rail = if h.state().ui.panels.rail { if h.state().ui.theme.is_pro() { 36.0 } else { 44.0 } } else { 0.0 }; + let edge = 1280.0 - (stored + rail); + drag(&mut h, pos2(edge, 400.0), pos2(edge + 60.0, 400.0)); + let resized = dock_width(h.state()); + h.run_steps(4); + let settled = dock_width(h.state()); + (stored, resized, settled) + }; + + let (_, resized, settled) = drag_once(false); + assert!(resized < 290.0, "dragging right should shrink the dock, got {resized}"); + assert!((settled - resized).abs() < 1.0, "the resized width must not snap back: {resized} then {settled}"); + + let (locked_stored, locked_resized, locked_settled) = drag_once(true); + assert!((locked_resized - locked_settled).abs() < 1.0); + assert!((locked_resized - locked_stored).abs() < 1.0, "a locked workspace keeps its pre-drag width: {locked_stored} then {locked_resized}"); +} + +/// Asking for the width the dock already has is a no-op and must not create a customized +/// workspace: a later `ui.set {dockTabs}` then keeps its legacy collapse handling. +#[test] +fn setting_the_current_dock_width_keeps_the_legacy_layout() { + let mut app = app(); + let current = dock_width(&app); + assert!(app.ui.docking.is_none(), "the app starts on the native dock"); + set_dock_width(&mut app, current); + assert!(app.ui.docking.is_none(), "a same-width request must not create a workspace"); + + // The next legacy operation must still take the legacy path: selecting a tab in a collapsed + // group selects it without expanding the group or customizing the workspace. + app.ui.dock.set_collapsed(crate::dock::Group::Properties, true); + let ctx = egui::Context::default(); + let (req, _rx) = crate::control::ControlRequest::new("ui.set", json!({"dockTabs": {"properties": 1}})); + crate::control::handle(&mut app, &ctx, &req); + assert_eq!(app.ui.dock_tabs.properties, 1, "the requested tab is selected"); + assert!(app.ui.dock.is_collapsed(crate::dock::Group::Properties), "the collapsed group stays collapsed"); + assert!(app.ui.docking.is_none(), "tab selection still did not customize the workspace"); +} + +/// Screen picking disables the parent UI mid-drag, so the handle never sees the release. The +/// stored drag must be dropped, saves must resume, and a later pointer must not apply the old +/// start. +#[test] +fn disabling_the_ui_cancels_a_dock_edge_drag() { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers"})).unwrap(); + app.session.prefs.edit(|p| p.workspace.remember_workspace_changes = true); + let disabled = Arc::new(AtomicBool::new(false)); + let flag = disabled.clone(); + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); + ready = true; + return; + } + if flag.load(Ordering::Relaxed) { + ui.disable(); + } + crate::panels::dock_panel(app, ui); + }, + app, + ); + h.run_steps(4); + let stored = dock_width(h.state()); + let before = h.state().session.prefs().panel_layout.clone(); + let rail = if h.state().ui.panels.rail { if h.state().ui.theme.is_pro() { 36.0 } else { 44.0 } } else { 0.0 }; + let edge = 1280.0 - (stored + rail); + h.event(Event::PointerMoved(pos2(edge, 400.0))); + h.run_steps(1); + h.event(Event::PointerButton { pos: pos2(edge, 400.0), button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerMoved(pos2(edge + 40.0, 400.0))); + h.run_steps(2); + assert!(crate::panels::dock_edge_drag_active(&h.ctx), "the resize drag started"); + assert_eq!(h.state().session.prefs().panel_layout, before, "a held drag is not saved"); + + disabled.store(true, Ordering::Relaxed); + h.run_steps(1); + assert!(!crate::panels::dock_edge_drag_active(&h.ctx), "disabling cancels the held drag before any release"); + h.event(Event::PointerButton { pos: pos2(edge + 40.0, 400.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(!crate::panels::dock_edge_drag_active(&h.ctx), "a disabled UI clears the stored drag"); + // Saves resume once the drag is dropped, even while the UI stays disabled: the width the + // held drag reached, which was not saved while held, is saved now. + let saved = &h.state().session.prefs().panel_layout; + assert_ne!(*saved, before, "the cancelled drag's width is saved"); + assert_eq!(*saved, crate::dock::snapshot(h.state()), "the saved layout is the live one after the cancel"); + let settled = dock_width(h.state()); + + disabled.store(false, Ordering::Relaxed); + h.run_steps(3); + h.event(Event::PointerMoved(pos2(edge - 120.0, 400.0))); + h.run_steps(2); + assert!((dock_width(h.state()) - settled).abs() < 0.5, "a stale drag start must not resize the dock after re-enable"); + // Saves resume: a width change made after re-enabling reaches the saved layout. + let resumed = h.state().session.prefs().panel_layout.clone(); + let ctx = h.ctx.clone(); + assert_eq!(control_set(h.state_mut(), &ctx, json!({"dockWidth": settled + 60.0}))["ok"], true); + h.run_steps(3); + assert!((dock_width(h.state()) - settled - 60.0).abs() < 0.5, "the new width is applied, got {}", dock_width(h.state())); + let saved = &h.state().session.prefs().panel_layout; + assert_ne!(*saved, resumed, "the width change after re-enable is saved"); + assert_eq!(*saved, crate::dock::snapshot(h.state()), "the saved layout is the new live one"); +} + +/// `ui.set {dockWidth}` sets the whole end dock, scaling every column rather than only the outer +/// one; the split holding the dock follows the new total. +#[test] +fn ui_set_dock_width_scales_every_column() { + let mut app = app(); + let mut layout = legacy(&app); + let used: std::collections::HashSet = layout.panels().into_iter().map(|panel| panel.to_string()).collect(); + let extra = PANELS.iter().map(|(id, _, _)| *id).find(|id| !used.contains(*id)).expect("a free panel id"); + { + let root = layout.root.as_mut().expect("the legacy layout has a root"); + let dock = match root { + Node::Split { second, .. } => second.as_mut(), + _ => root, + }; + let Node::Dock { columns, .. } = dock else { panic!("the end dock is missing") }; + columns.push(Column::new(vec![craft_ui::docking::Group::new(vec![extra.to_owned()], 0)], 180.0)); + } + app.ui.docking = Some(Workspace::new(layout)); + // The extra 180-point column first comes up to its 220-point minimum; the rest of the change + // is shared equally between the two columns. + let share = (520.0 - end_columns(&app)[0].width - 220.0) / 2.0; + let split = [end_columns(&app)[0].width + share, 220.0 + share]; + let total = |app: &PhotocraftApp| -> f32 { + let root = app.ui.docking.as_ref().expect("a customized workspace").layout.root.as_ref().expect("root"); + let dock = match root { + Node::Split { second, .. } => second.as_ref(), + _ => root, + }; + match dock { + Node::Dock { columns, .. } => columns.iter().map(|column| column.width).sum(), + _ => 0.0, + } + }; + let before = total(&app); + set_dock_width(&mut app, 520.0); + let after = total(&app); + assert!((after - 520.0).abs() < 1.0, "the whole dock should be 520 points, got {after} (was {before})"); + let widths: Vec = { + let root = app.ui.docking.as_ref().unwrap().layout.root.as_ref().unwrap(); + let dock = match root { + Node::Split { second, .. } => second.as_ref(), + _ => root, + }; + match dock { + Node::Dock { columns, .. } => columns.iter().map(|column| column.width).collect(), + _ => Vec::new(), + } + }; + assert_eq!(widths.len(), 2, "both columns are kept"); + assert!(widths.iter().all(|width| *width > 0.0), "every column keeps a positive width: {widths:?}"); + assert!(widths.iter().zip(split).all(|(width, expected)| (width - expected).abs() < 1.0), "both columns share the change: {widths:?}, expected {split:?}"); + + // A target below the sum of the columns' 220-point panel minima must stop at that minimum, + // not starve a column while leaving the other untouched. + set_dock_width(&mut app, 250.0); + let after = total(&app); + assert!((after - 440.0).abs() < 1.0, "two columns keep the 440-point minimum, got {after}"); + let widths: Vec = { + let root = app.ui.docking.as_ref().unwrap().layout.root.as_ref().unwrap(); + let dock = match root { + Node::Split { second, .. } => second.as_ref(), + _ => root, + }; + match dock { + Node::Dock { columns, .. } => columns.iter().map(|column| column.width).collect(), + _ => Vec::new(), + } + }; + assert!(widths.iter().all(|width| *width >= 219.5), "every column keeps the panel minimum: {widths:?}"); + assert!(widths.iter().all(|width| *width < 221.0), "the minimum is shared between the columns: {widths:?}"); +} + +/// `ui.set {dockWidth}` travels through the queued request and the rendered dock pass, scaling a +/// multi-column dock to the requested total. +#[test] +fn ui_set_dock_width_queues_and_scales_every_column_when_drawn() { + let mut app = app(); + let mut layout = legacy(&app); + let used: std::collections::HashSet = layout.panels().into_iter().map(|panel| panel.to_string()).collect(); + let extra = PANELS.iter().map(|(id, _, _)| *id).find(|id| !used.contains(*id)).expect("a free panel id"); + { + let root = layout.root.as_mut().expect("the legacy layout has a root"); + let dock = match root { + Node::Split { second, .. } => second.as_mut(), + _ => root, + }; + let Node::Dock { columns, .. } = dock else { panic!("the end dock is missing") }; + columns.push(Column::new(vec![craft_ui::docking::Group::new(vec![extra.to_owned()], 0)], 180.0)); + } + app.ui.docking = Some(Workspace::new(layout)); + let mut h = dock_panel_harness(app, vec2(1280.0, 800.0), crate::theme::ThemeKind::ProMedium); + // As in `ui_set_dock_width_scales_every_column`: the extra column comes up to its 220-point + // minimum and the rest of the change is shared equally. + let share = (520.0 - end_columns(h.state())[0].width - 220.0) / 2.0; + let split = [end_columns(h.state())[0].width + share, 220.0 + share]; + crate::panels::request_dock_width(&h.ctx, 520.0); + h.run_steps(2); + let root = h.state().ui.docking.as_ref().expect("a workspace").layout.root.as_ref().expect("root"); + let dock = match root { + Node::Split { second, .. } => second.as_ref(), + _ => root, + }; + let Node::Dock { columns, .. } = dock else { panic!("the end dock is missing") }; + let widths: Vec = columns.iter().map(|column| column.width).collect(); + assert_eq!(widths.len(), 2, "both columns are kept"); + assert!(widths.iter().all(|width| *width > 0.0), "every column keeps a positive width: {widths:?}"); + let total: f32 = widths.iter().sum(); + assert!((total - 520.0).abs() < 1.0, "the queued request sets the whole dock to 520 points, got {total} from {widths:?}"); + assert!(widths.iter().zip(split).all(|(width, expected)| (width - expected).abs() < 1.0), "both columns share the change: {widths:?}, expected {split:?}"); +} + +/// A narrow window must still give the canvas its 200-point minimum; the dock shrinks instead. +#[test] +fn a_narrow_window_keeps_the_canvas_minimum() { + let mut app = app(); + let mut layout = legacy(&app); + { + let root = layout.root.as_mut().expect("the legacy layout has a root"); + let dock = match root { + Node::Split { second, .. } => second.as_mut(), + _ => root, + }; + let Node::Dock { columns, .. } = dock else { panic!("the end dock is missing") }; + columns[0].width = 520.0; + } + app.ui.docking = Some(Workspace::new(layout)); + app.ui.panels.rail = true; + let h = dock_panel_harness(app, vec2(700.0, 600.0), crate::theme::ThemeKind::ProMedium); + let panel = crate::panels::dock_panel_width(&h.ctx); + let toolbar = crate::panels::toolbar::toolbar_width(h.state().ui.theme.is_pro(), h.state().ui.panels.toolbar_double); + let canvas = 700.0 - panel - toolbar; + assert!(canvas >= 199.0, "the canvas keeps its 200-point minimum, got {canvas} (dock {panel}, toolbar {toolbar})"); +} + +/// The empty dock still renders its docking pass but must not consume the parent layout, so the +/// toolbar and canvas keep their space. +#[test] +fn an_empty_dock_keeps_the_parent_layout_for_toolbar_and_canvas() { + let mut app = app(); + let layout = Layout { + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedSecond(0.0), + first: Box::new(Node::Tabs { panels: vec![CANVAS.to_owned()], active: 0 }), + second: Box::new(Node::Dock { side: Side::End, columns: Vec::new() }), + }), + floating: Vec::new(), + }; + app.ui.docking = Some(Workspace::new(layout)); + app.ui.panels.rail = false; + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); + ready = true; + return; + } + crate::panels::dock_panel(app, ui); + let remaining = ui.available_width(); + ui.ctx().data_mut(|data| data.insert_temp(egui::Id::unique("empty-dock-remaining"), remaining)); + crate::panels::toolbar(app, ui); + egui::CentralPanel::default().show(ui, |_| {}); + }, + app, + ); + h.run_steps(4); + let remaining = h.ctx.data(|data| data.get_temp::(egui::Id::unique("empty-dock-remaining")).unwrap_or(0.0)); + assert!(remaining > 1200.0, "the empty dock must not consume the parent layout, left {remaining}"); + assert!(h.get_by_label("Toolbar").rect().is_positive(), "the toolbar still draws"); +} + +/// An empty dock must still run the docking pass, because Studio draws its floating Properties +/// group from it, and the zero-width dock must not lose it with or without the rail. +#[test] +fn an_empty_dock_still_draws_studio_floating_properties() { + for rail in [true, false] { + let mut app = app(); + let layout = Layout { + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedSecond(0.0), + first: Box::new(Node::Tabs { panels: vec![CANVAS.to_owned()], active: 0 }), + second: Box::new(Node::Dock { side: Side::End, columns: Vec::new() }), + }), + floating: vec![Floating::single(vec!["properties".to_owned()], 0, [120.0, 160.0, 320.0, 440.0])], + }; + app.ui.docking = Some(Workspace::new(layout)); + app.ui.panels.rail = rail; + let h = dock_panel_harness(app, vec2(1280.0, 800.0), crate::theme::ThemeKind::Studio); + assert!(dock_is_empty(h.state()), "rail={rail}: the dock is empty"); + assert!(show_dock_calls(&h.ctx) > 0, "rail={rail}: the docking pass must still run for floating windows"); + let rect = floating_rect(&h, "properties"); + assert!(rect.is_positive(), "rail={rail}: floating Properties must be drawn, got {rect:?}"); + } +} + +/// A column collapsed to icons reserves its `iconic_width`, not the expanded width; resizing the +/// edge keeps that iconic width in step. +#[test] +fn an_iconic_dock_column_takes_its_iconic_width() { + let mut app = app(); + let mut layout = legacy(&app); + { + let root = layout.root.as_mut().expect("the legacy layout has a root"); + let dock = match root { + Node::Split { second, .. } => second.as_mut(), + _ => root, + }; + let Node::Dock { columns, .. } = dock else { panic!("the end dock is missing") }; + columns[0].iconic = true; + columns[0].iconic_width = Some(36.0); + } + let expanded = { + let root = layout.root.as_ref().expect("the legacy layout has a root"); + let dock = match root { + Node::Split { second, .. } => second.as_ref(), + _ => root, + }; + let Node::Dock { columns, .. } = dock else { panic!("the end dock is missing") }; + columns[0].width + }; + app.ui.docking = Some(Workspace::new(layout)); + assert!((dock_width(&app) - 36.0).abs() < 0.5, "the iconic column displays 36 points, got {}", dock_width(&app)); + assert!(set_end_dock_total(&mut app, 220.0).is_some(), "the dock-edge resize accepts an iconic dock"); + assert!((dock_width(&app) - 220.0).abs() < 1.0, "after the resize the dock displays 220 points, got {}", dock_width(&app)); + let root = app.ui.docking.as_ref().unwrap().layout.root.as_ref().unwrap(); + let dock = match root { + Node::Split { second, .. } => second.as_ref(), + _ => root, + }; + let Node::Dock { columns, .. } = dock else { panic!("the end dock is missing") }; + assert!(columns[0].iconic_width.unwrap_or(0.0) > 200.0, "iconic_width follows the resize: {:?}", columns[0].iconic_width); + assert!((columns[0].width - expanded).abs() < 0.5, "resizing while iconic keeps the expanded width: {} vs {expanded}", columns[0].width); + + let root = app.ui.docking.as_mut().unwrap().layout.root.as_mut().unwrap(); + let dock = match root { + Node::Split { second, .. } => second.as_mut(), + _ => root, + }; + let Node::Dock { columns, .. } = dock else { panic!("the end dock is missing") }; + columns[0].iconic = false; + assert!((dock_width(&app) - expanded).abs() < 0.5, "expanding restores the expanded width, got {} (was {expanded})", dock_width(&app)); +} + +/// Collapsing a column through craft-ui's action leaves `iconic_width` unset; the dock must use +/// the renderer's 36-point default rather than the expanded width. +#[test] +fn an_iconic_only_dock_keeps_its_narrow_panel() { + let mut app = app(); + let layout = legacy(&app); + let panel = { + let root = layout.root.as_ref().expect("the legacy layout has a root"); + let dock = match root { + Node::Split { second, .. } => second.as_ref(), + _ => root, + }; + let Node::Dock { columns, .. } = dock else { panic!("the end dock is missing") }; + columns[0].groups[0].panels[0].clone() + }; + let mut workspace = Workspace::new(layout); + workspace.apply(Action::SetIconic { panel, iconic: true }, permissions).expect("the column collapses"); + { + let root = workspace.layout.root.as_ref().expect("root"); + let dock = match root { + Node::Split { second, .. } => second.as_ref(), + _ => root, + }; + let Node::Dock { columns, .. } = dock else { panic!("the end dock is missing") }; + assert!(columns[0].iconic); + assert!(columns[0].iconic_width.is_none(), "the collapse action leaves the stored width unset"); + } + app.ui.docking = Some(workspace); + assert!((dock_width(&app) - 36.0).abs() < 0.5, "an unset iconic width displays 36 points, got {}", dock_width(&app)); + let h = dock_panel_harness(app, vec2(1280.0, 800.0), crate::theme::ThemeKind::ProMedium); + let rail = if h.state().ui.panels.rail { 36.0 } else { 0.0 }; + let drawn = crate::panels::dock_panel_width(&h.ctx); + assert!((drawn - (36.0 + rail)).abs() < 2.0, "the iconic panel is its 36 points plus the rail ({rail}), got {drawn}"); + assert!(drawn < 120.0, "the iconic panel must not take the expanded minimum, got {drawn}"); +} + +/// Stored iconic widths are clamped to the renderer's 36..240 bounds. +#[test] +fn iconic_widths_follow_the_renderer_clamp() { + let mut app = app(); + let mut layout = legacy(&app); + { + let root = layout.root.as_mut().expect("the legacy layout has a root"); + let dock = match root { + Node::Split { second, .. } => second.as_mut(), + _ => root, + }; + let Node::Dock { columns, .. } = dock else { panic!("the end dock is missing") }; + columns[0].iconic = true; + columns[0].iconic_width = Some(500.0); + } + app.ui.docking = Some(Workspace::new(layout)); + assert!((dock_width(&app) - 240.0).abs() < 0.5, "a stored 500 displays at most 240, got {}", dock_width(&app)); + let root = app.ui.docking.as_mut().unwrap().layout.root.as_mut().unwrap(); + let dock = match root { + Node::Split { second, .. } => second.as_mut(), + _ => root, + }; + let Node::Dock { columns, .. } = dock else { panic!("the end dock is missing") }; + columns[0].iconic_width = Some(10.0); + assert!((dock_width(&app) - 36.0).abs() < 0.5, "a stored 10 displays at least 36, got {}", dock_width(&app)); + let root = app.ui.docking.as_mut().unwrap().layout.root.as_mut().unwrap(); + let dock = match root { + Node::Split { second, .. } => second.as_mut(), + _ => root, + }; + let Node::Dock { columns, .. } = dock else { panic!("the end dock is missing") }; + columns[0].iconic_width = None; + assert!((dock_width(&app) - ICONIC_WIDTH).abs() < 0.5, "no saved width displays craft-ui's default 36, got {}", dock_width(&app)); + let style = craft_ui::docking::DockStyle::default(); + assert_eq!((style.iconic_width, style.iconic_max_width), (ICONIC_WIDTH, ICONIC_MAX_WIDTH), "the renderer's defaults"); +} +/// Closing every panel leaves the dock with nothing in it, which the dock-edge layout uses to +/// take no width for the column. +#[test] +fn an_empty_dock_has_no_panels() { + let mut app = app(); + assert!(!dock_is_empty(&app), "the default dock shows its panels"); + app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers"})).unwrap(); + { + let root = app.ui.docking.as_mut().expect("a customized workspace").layout.root.as_mut().expect("root"); + let Node::Split { second, .. } = root else { panic!("the canvas is beside the dock") }; + **second = Node::Dock { side: Side::End, columns: Vec::new() }; + } + assert!(dock_is_empty(&app), "a dock with no columns has nothing to show"); +} + +/// Width refused by the iconic 240-point cap reaches a column that can still grow. +#[test] +fn iconic_cap_surplus_goes_to_expandable_columns() { + let mut app = app(); + let mut layout = legacy(&app); + let used: std::collections::HashSet = layout.panels().into_iter().map(|panel| panel.to_string()).collect(); + let extra = PANELS.iter().map(|(id, _, _)| *id).find(|id| !used.contains(*id)).expect("a free panel id"); + { + let root = layout.root.as_mut().expect("the legacy layout has a root"); + let dock = match root { + Node::Split { second, .. } => second.as_mut(), + _ => root, + }; + let Node::Dock { columns, .. } = dock else { panic!("the end dock is missing") }; + columns[0].iconic = true; + columns[0].iconic_width = Some(240.0); + columns.push(Column::new(vec![craft_ui::docking::Group::new(vec![extra.to_owned()], 0)], 220.0)); + } + app.ui.docking = Some(Workspace::new(layout)); + set_dock_width(&mut app, 600.0); + let root = app.ui.docking.as_ref().expect("a workspace").layout.root.as_ref().expect("root"); + let dock = match root { + Node::Split { second, .. } => second.as_ref(), + _ => root, + }; + let Node::Dock { columns, .. } = dock else { panic!("the end dock is missing") }; + let displayed: Vec = + columns.iter().map(|column| if column.iconic { column.iconic_width.unwrap_or(36.0).clamp(36.0, 240.0) } else { column.width }).collect(); + let total: f32 = displayed.iter().sum(); + assert!((total - 600.0).abs() < 1.0, "the requested total is reached: {displayed:?}"); + assert!((displayed[0] - 240.0).abs() < 0.5, "the iconic column stays at its cap: {displayed:?}"); + assert!((displayed[1] - 360.0).abs() < 1.0, "the expandable column takes the surplus: {displayed:?}"); +} + +/// The rendered panel uses the width the model actually stored when a drag asks for less than the +/// column minimum, during the drag and after release. +#[test] +fn dock_panel_draws_the_actual_minimum_during_and_after_a_drag() { + let mut app = app(); + let mut layout = legacy(&app); + let used: std::collections::HashSet = layout.panels().into_iter().map(|panel| panel.to_string()).collect(); + let extra = PANELS.iter().map(|(id, _, _)| *id).find(|id| !used.contains(*id)).expect("a free panel id"); + { + let root = layout.root.as_mut().expect("the legacy layout has a root"); + let dock = match root { + Node::Split { second, .. } => second.as_mut(), + _ => root, + }; + let Node::Dock { columns, .. } = dock else { panic!("the end dock is missing") }; + columns.push(Column::new(vec![craft_ui::docking::Group::new(vec![extra.to_owned()], 0)], 180.0)); + } + app.ui.docking = Some(Workspace::new(layout)); + let mut h = dock_edge_harness(app, vec2(1280.0, 800.0)); + let rail = if h.state().ui.panels.rail { 36.0 } else { 0.0 }; + let start = begin_outer_resize(&mut h); + let edge = start.x; + h.event(Event::PointerMoved(pos2(edge + 240.0, start.y))); + h.run_steps(2); + let during = dock_width(h.state()); + let drawn = crate::panels::dock_panel_width(&h.ctx); + assert!((during - 440.0).abs() < 1.0, "the model stops at the 440-point minimum, got {during}"); + assert!((drawn - (during + rail)).abs() < 2.0, "the panel follows the stored width, got {drawn} for {during}+{rail}"); + h.event(Event::PointerButton { pos: pos2(edge + 240.0, start.y), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + let after = dock_width(h.state()); + assert!((after - during).abs() < 1.0, "the release keeps the model width, got {after} then {during}"); + let drawn = crate::panels::dock_panel_width(&h.ctx); + assert!((drawn - (after + rail)).abs() < 2.0, "the release frame follows the stored width, got {drawn} for {after}+{rail}"); +} + +/// A workspace replacement during a drag invalidates the stored gesture before it can write. +#[test] +fn replacing_the_workspace_cancels_the_dock_drag() { + for release_first in [false, true] { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers"})).unwrap(); + let mut h = dock_edge_harness(app, vec2(1280.0, 800.0)); + let start = begin_outer_resize(&mut h); + h.event(Event::PointerMoved(start + vec2(20.0, 0.0))); + h.run_steps(1); + let mut replacement = legacy(h.state()); + dock_columns_mut(replacement.root.as_mut().expect("root")).expect("end dock")[0].width = 520.0; + let generation = h.state().ui.docking_generation; + crate::dock::apply(h.state_mut(), &json!({"docking": Workspace::new(replacement)})); + assert_eq!(h.state().ui.docking_generation, generation.wrapping_add(1)); + assert!((dock_width(h.state()) - 520.0).abs() < 0.5, "the installed baseline is known before any frame"); + if release_first { + release_outer_resize(&mut h, start + vec2(100.0, 0.0)); + } else { + h.run_steps(1); + } + assert!(!crate::panels::dock_edge_drag_active(&h.ctx), "the first frame clears the stale gesture"); + assert!((dock_width(h.state()) - 520.0).abs() < 0.5, "the first frame must not overwrite the replacement"); + if !release_first { + release_outer_resize(&mut h, start + vec2(100.0, 0.0)); + } + h.run_steps(2); + assert!((dock_width(h.state()) - 520.0).abs() < 0.5, "release and idle retain the installed width"); + } +} + +/// A window too narrow for the column minima does not panic while resizing and keeps the panel +/// inside the available space, also when the window shrinks to it in the middle of a drag. +#[test] +fn constrained_width_does_not_panic_while_resizing() { + let two_columns = || { + let mut app = app(); + let mut layout = legacy(&app); + let used: std::collections::HashSet = layout.panels().into_iter().map(|panel| panel.to_string()).collect(); + let extra = PANELS.iter().map(|(id, _, _)| *id).find(|id| !used.contains(*id)).expect("a free panel id"); + { + let root = layout.root.as_mut().expect("the legacy layout has a root"); + let dock = match root { + Node::Split { second, .. } => second.as_mut(), + _ => root, + }; + let Node::Dock { columns, .. } = dock else { panic!("the end dock is missing") }; + columns.push(Column::new(vec![craft_ui::docking::Group::new(vec![extra.to_owned()], 0)], 180.0)); + } + app.ui.docking = Some(Workspace::new(layout)); + app + }; + // A drag started in a wide window is cancelled once the shrunk window cannot fit the columns. + let mut h = dock_edge_harness(two_columns(), vec2(1280.0, 600.0)); + let start = begin_outer_resize(&mut h); + h.event(Event::PointerMoved(start + vec2(-30.0, 0.0))); + h.run_steps(1); + assert!(crate::panels::dock_edge_drag_active(&h.ctx), "the drag is held in the wide window"); + let stored: Vec = end_columns(h.state()).iter().map(|column| column.width).collect(); + h.set_size(vec2(300.0, 600.0)); + h.event(Event::PointerMoved(pos2(200.0, 300.0))); + h.run_steps(3); + assert!(!crate::panels::dock_edge_drag_active(&h.ctx), "shrinking the window below the column minima cancels the held drag"); + let drawn = crate::panels::dock_panel_width(&h.ctx); + assert!(drawn <= 80.0, "the dock stays within the shrunk window mid-drag, got {drawn}"); + let widths: Vec = end_columns(h.state()).iter().map(|column| column.width).collect(); + assert_eq!(widths, stored, "the cancel keeps the stored column widths"); + h.event(Event::PointerButton { pos: pos2(200.0, 300.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(crate::panels::dock_panel_width(&h.ctx) <= 80.0, "the release after the cancel keeps the dock in the window"); + let widths: Vec = end_columns(h.state()).iter().map(|column| column.width).collect(); + assert_eq!(widths, stored, "the release after the cancel keeps the stored column widths"); + + let mut h = dock_edge_harness(two_columns(), vec2(300.0, 600.0)); + let drawn = crate::panels::dock_panel_width(&h.ctx); + assert!(drawn <= 80.0, "the dock stays within the narrow window, got {drawn}"); + let start = outer_handle(&h).center(); + let edge = start.x; + h.event(Event::PointerMoved(start)); + h.run_steps(1); + h.event(Event::PointerButton { pos: start, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerMoved(pos2(edge + 40.0, 300.0))); + h.run_steps(2); + h.event(Event::PointerButton { pos: pos2(edge + 40.0, 300.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert!(crate::panels::dock_panel_width(&h.ctx) <= 80.0, "the panel stays within the narrow window after release"); +} + +/// The rendered dock-edge resize handle's interaction rectangle. +fn outer_handle(h: &Harness<'static, PhotocraftApp>) -> egui::Rect { + h.ctx.read_response(egui::Id::unique("dock-edge-handle")).expect("the rendered resize handle").rect +} + +/// The rectangle the dock-edge panel actually allocated on the last frame. +fn allocated_dock(h: &Harness<'static, PhotocraftApp>) -> egui::Rect { + h.ctx.data(|data| data.get_temp::(egui::Id::unique("photo-dock-panel-rect")).expect("the allocated dock rectangle")) +} + +/// Presses on the dock-edge handle, asserts the drag started that frame and returns the press +/// position. +fn begin_outer_resize(h: &mut Harness<'static, PhotocraftApp>) -> Pos2 { + let start = outer_handle(h).center(); + h.event(Event::PointerMoved(start)); + h.run_steps(1); + h.event(Event::PointerButton { pos: start, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + assert!(crate::panels::dock_edge_drag_active(&h.ctx), "Sense::drag starts on the press frame"); + start +} + +/// Releases the pointer at `pos` with no preceding move, so the release alone carries the final +/// position. +fn release_outer_resize(h: &mut Harness<'static, PhotocraftApp>, pos: Pos2) { + // No preceding PointerMoved: the release event alone supplies the final position. + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(1); +} + +/// An app whose saved workspace has one end-dock column per width, each holding one panel; iconic +/// columns get distinct expanded widths so tests can check they are preserved. +fn app_with_columns(widths: &[f32], iconic: bool) -> PhotocraftApp { + assert!(widths.len() <= PANELS.len()); + let columns = widths + .iter() + .zip(PANELS.iter()) + .enumerate() + .map(|(index, (width, (id, _, _)))| { + let mut column = Column::new(vec![craft_ui::docking::Group::new(vec![(*id).to_owned()], 0)], *width); + if iconic { + column.iconic = true; + column.iconic_width = Some(*width); + column.width = 290.0 + index as f32; + } + column + }) + .collect(); + let root = with_canvas(Node::Dock { side: Side::End, columns }, widths.iter().sum::().min(520.0)); + let mut app = app(); + app.ui.docking = Some(Workspace::new(Layout { root: Some(root), floating: Vec::new() })); + assert!(valid_workspace(app.ui.docking.as_ref().unwrap()), "fixtures must be accepted saved workspaces"); + app +} + +/// The end dock's columns in the customized workspace. +fn end_columns(app: &PhotocraftApp) -> &[Column] { + let Some(Node::Dock { columns, .. }) = lay(app).root.as_ref().and_then(end_dock_node) else { panic!("the end dock is missing") }; + columns +} + +/// A request equal to the current total still lifts an undersized column to its minimum. +#[test] +fn same_total_requests_normalize_each_populated_column() { + let mut app = app_with_columns(&[290.0, 180.0], false); + set_dock_width(&mut app, 470.0); + assert!((dock_width(&app) - 470.0).abs() < 0.5); + assert!(end_columns(&app).iter().all(|column| column.width >= MIN_COLUMN_WIDTH)); +} + +/// Redistribution reaches an attainable total even when more than eight columns saturate in turn. +#[test] +fn redistribution_converges_with_more_than_eight_saturating_columns() { + let mut app = app_with_columns(&[221.0, 800221.0, 889109.75, 900220.75, 901807.9, 902072.25, 902124.94, 902137.9, 902141.9, 902242.4], false); + set_dock_width(&mut app, 2300.5); + assert!((dock_width(&app) - 2300.5).abs() < 0.5, "attainable target, got {}", dock_width(&app)); + assert!(end_columns(&app).iter().all(|column| column.width >= MIN_COLUMN_WIDTH)); +} + +/// Width an iconic column cannot take past its cap goes to the expanded column, whether the cap +/// was already reached or is reached by this request. +#[test] +fn mixed_iconic_caps_redistribute_both_existing_and_new_saturation() { + for iconic_width in [100.0, ICONIC_MAX_WIDTH] { + let mut app = app_with_columns(&[290.0, 220.0], false); + let first = &mut dock_columns_mut(app.ui.docking.as_mut().unwrap().layout.root.as_mut().unwrap()).unwrap()[0]; + first.iconic = true; + first.iconic_width = Some(iconic_width); + assert!((set_end_dock_total(&mut app, 800.0).unwrap() - 800.0).abs() < 0.5); + assert_eq!(end_columns(&app)[0].iconic_width, Some(ICONIC_MAX_WIDTH)); + assert_eq!(end_columns(&app)[0].width, 290.0, "iconic resizing preserves expanded width"); + assert!((end_columns(&app)[1].width - 560.0).abs() < 0.5); + } +} + +/// An all-iconic dock stops at the summed iconic minimum and cap, and keeps every expanded width. +#[test] +fn all_iconic_totals_stop_at_both_bounds_without_losing_expanded_widths() { + let mut app = app_with_columns(&[100.0, 200.0], true); + let expanded: Vec<_> = end_columns(&app).iter().map(|column| column.width).collect(); + for (requested, expected) in [(1000.0, 480.0), (1.0, 72.0), (300.0, 300.0)] { + assert!((set_end_dock_total(&mut app, requested).unwrap() - expected).abs() < 0.5); + let stored: f32 = end_columns(&app).iter().map(display_width).sum(); + assert!((stored - expected).abs() < 1.0, "the columns store the {expected}-point total, got {stored}"); + for (column, expanded) in end_columns(&app).iter().zip(&expanded) { + assert!((ICONIC_WIDTH..=ICONIC_MAX_WIDTH).contains(&display_width(column))); + assert_eq!(column.width, *expanded); + } + } +} + +/// The end-dock setter reports a same-width legacy request as a no-op and rejects invalid, +/// locked and empty cases without changing the workspace. +#[test] +fn end_dock_setter_reports_legacy_noop_and_rejects_without_mutation() { + let mut app = app(); + let current = dock_width(&app); + assert_eq!(set_end_dock_total(&mut app, current), Some(current)); + assert!(app.ui.docking.is_none(), "same-width legacy requests must not customize"); + for rejected in [0.0, -1.0, f32::NAN, f32::INFINITY] { + assert_eq!(set_end_dock_total(&mut app, rejected), None); + assert!(app.ui.docking.is_none()); + } + app.session.prefs.edit(|prefs| prefs.workspace_locked = true); + assert_eq!(set_end_dock_total(&mut app, current + 100.0), None); + assert!(app.ui.docking.is_none()); + app.session.prefs.edit(|prefs| prefs.workspace_locked = false); + assert_eq!(set_end_dock_total(&mut app, 400.0), Some(400.0)); + assert!((dock_width(&app) - 400.0).abs() < 0.5); + let mut empty = app_with_columns(&[], false); + let before = empty.ui.docking.clone(); + assert_eq!(set_end_dock_total(&mut empty, 400.0), None); + assert_eq!(empty.ui.docking, before); +} + +/// Idle frames draw the stored 220-point width; a gesture, even without motion, applies the +/// 250-point resize minimum. +#[test] +fn an_idle_expanded_dock_uses_its_stored_width_not_the_gesture_minimum() { + let mut h = dock_edge_harness(app_with_columns(&[220.0], false), vec2(1280.0, 800.0)); + let rail = if h.state().ui.panels.rail { 36.0 } else { 0.0 }; + assert!((allocated_dock(&h).width() - 220.0 - rail).abs() < 1.0); + assert_eq!(dock_width(h.state()), 220.0); + let start = begin_outer_resize(&mut h); + assert_eq!(dock_width(h.state()), 220.0, "the press records the existing idle width"); + release_outer_resize(&mut h, start); + assert_eq!(dock_width(h.state()), 250.0, "even an unmoved gesture applies the resize minimum"); + assert!((allocated_dock(&h).width() - 250.0 - rail).abs() < 1.0); + h.run_steps(2); + assert_eq!(dock_width(h.state()), 250.0, "idle keeps the normalized gesture result"); +} + +/// A release with no preceding move updates the model and the allocated panel on that frame, +/// counting the rail once, in both directions, both rail widths and with the rail hidden. +#[test] +fn release_only_position_updates_model_and_actual_geometry_on_that_frame() { + for dir in [crate::direction::Dir::Ltr, crate::direction::Dir::Rtl] { + let _direction = crate::direction::direction_scope(dir); + for theme in [crate::theme::ThemeKind::ProMedium, crate::theme::ThemeKind::Classic] { + for rail in [false, true] { + let mut app = app_with_columns(&[400.0], false); + app.ui.panels.rail = rail; + app.ui.theme = theme; + let rw = if rail { if theme.is_pro() { 36.0 } else { 44.0 } } else { 0.0 }; + let mut h = dock_panel_harness(app, vec2(1280.0, 800.0), theme); + let start = begin_outer_resize(&mut h); + let sign = if crate::direction::is_rtl() { 1.0 } else { -1.0 }; + h.event(Event::PointerMoved(start + vec2(sign * 30.0, 0.0))); + h.run_steps(1); + assert!((dock_width(h.state()) - 430.0).abs() < 0.5); + assert!((allocated_dock(&h).width() - 430.0 - rw).abs() < 1.0); + release_outer_resize(&mut h, start + vec2(sign * 80.0, 0.0)); + assert!(!crate::panels::dock_edge_drag_active(&h.ctx)); + assert!((dock_width(h.state()) - 480.0).abs() < 0.5, "the release-only position is applied immediately"); + let released = allocated_dock(&h); + assert!((released.width() - 480.0 - rw).abs() < 1.0, "the rail is counted once"); + h.run_steps(2); + assert!((allocated_dock(&h).width() - released.width()).abs() < 0.5, "idle does not jump"); + } + } + } +} + +/// A dock-edge harness `width` points wide with the Pro single-column toolbox on the right, the +/// rail and a stored 520-point dock column, after the frames that draw it. +fn narrow_dock_harness(width: f32) -> Harness<'static, PhotocraftApp> { + let mut app = app_with_columns(&[400.0], false); + app.ui.panels.rail = true; + app.ui.panels.toolbar_double = false; + app.ui.panels.toolbar_side = crate::state::ToolbarSide::Right; + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(width, 600.0)).with_step_dt(1.0 / 60.0).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); + ready = true; + return; + } + crate::panels::dock_panel(app, ui); + crate::panels::toolbar(app, ui); + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { + ui.ctx().data_mut(|data| data.insert_temp(egui::Id::unique("narrow-canvas-rect"), ui.max_rect())); + }); + }, + app, + ); + h.run_steps(4); + assert_eq!(crate::panels::toolbar::toolbar_width(h.state().ui.theme.is_pro(), false), 40.0, "the Pro single-column toolbox"); + let ctx = h.ctx.clone(); + assert_eq!(control_set(h.state_mut(), &ctx, json!({"dockWidth":520.0}))["ok"], true); + h.run_steps(2); + h +} + +/// The canvas rectangle [`narrow_dock_harness`] drew on its last frame. +fn narrow_canvas(h: &Harness<'static, PhotocraftApp>) -> egui::Rect { + h.ctx.data(|data| data.get_temp::(egui::Id::unique("narrow-canvas-rect")).expect("the canvas rect")) +} + +/// The panel a dock column's control acts on; `None` for floating-window and split controls. +fn dock_hit_panel(hit: &craft_ui::docking::Hit) -> Option<&String> { + use craft_ui::docking::{Divider, Hit}; + match hit { + Hit::Tab(p) + | Hit::DragBar(p) + | Hit::Border(p) + | Hit::Overflow(p) + | Hit::ColumnBar(p) + | Hit::Collapse(p) + | Hit::StackToggle(p) + | Hit::Icon(p) + | Hit::IconGrip(p) => Some(p), + Hit::Divider(Divider::Column(p) | Divider::Group(p) | Divider::Stack(p)) => Some(p), + _ => None, + } +} + +/// Asserts that every control of the end dock's panels keeps its clipped `interact_rect`, what is +/// drawn and clickable, inside `dock`, and returns how many have a full `rect` past it. +fn dock_controls_clipped(h: &Harness<'static, PhotocraftApp>, dock: egui::Rect) -> usize { + use craft_ui::docking::DockArea; + let panels: Vec<&String> = end_columns(h.state()).iter().flat_map(|c| c.groups.iter().flat_map(|g| g.panels.iter())).collect(); + let area = area_id(); + let hits = DockArea::::hit_rects(&h.ctx, area); + let (mut drawn, mut clipped) = (0, 0); + for (hit, rect) in hits.iter().filter(|(hit, _)| dock_hit_panel(hit).is_some_and(|p| panels.contains(&p))) { + let Some(response) = h.ctx.read_response(DockArea::::hit_id(area, hit)) else { + assert!(!rect.intersect(dock).is_positive(), "{hit:?} at {rect:?} has no response"); + continue; + }; + let shown = response.interact_rect; + assert!(!shown.is_positive() || dock.expand(0.5).contains_rect(shown), "{hit:?}: interactive {shown:?} (full {rect:?}) outside {dock:?}"); + drawn += usize::from(shown.is_positive()); + clipped += usize::from(!dock.expand(2.0).contains_rect(*rect)); + } + assert!(drawn > 0, "the dock drew controls: {hits:?}"); + clipped +} + +/// `dockWidth` stores the requested model width even when a narrow window draws the dock +/// narrower: in one frame the model keeps 520, the drawn dock column fits in 364 points and the +/// canvas keeps its 200, and a wider window draws the stored width again. +#[test] +fn a_narrow_window_draws_a_stored_dock_width_narrower_without_losing_it() { + let mut h = narrow_dock_harness(640.0); + let canvas = narrow_canvas(&h); + let dock = allocated_dock(&h); + let model = dock_width(h.state()); + assert!((model - 520.0).abs() < 0.5, "the model keeps the request, got {model}"); + assert!(dock.width() - 36.0 <= 364.5, "the drawn dock column fits beside the toolbox and canvas, got {dock:?}"); + assert!(canvas.width() >= 199.5, "the canvas keeps its 200-point minimum, got {canvas:?} beside {dock:?}"); + assert!(!canvas.intersect(dock).is_positive(), "the dock never overlaps the canvas: {canvas:?}, {dock:?}"); + // The fitted column lays its chrome out inside the dock, so here nothing needs the clip. + dock_controls_clipped(&h, dock); + h.set_size(vec2(1280.0, 600.0)); + h.run_steps(3); + assert!((dock_width(h.state()) - 520.0).abs() < 0.5, "growing the window keeps the stored width"); + assert!((allocated_dock(&h).width() - 520.0 - 36.0).abs() < 1.0, "a wide window draws the stored width again, got {:?}", allocated_dock(&h)); +} + +/// With no room left for the dock column, its controls' full rects run past the panel edge and +/// the panel clip keeps what is drawn and clickable inside the dock. +#[test] +fn a_squeezed_dock_column_clips_its_controls_at_the_panel_edge() { + // The toolbox, the Pro rail (36 points) and the canvas minimum fill the window, which leaves + // the dock column only its one-point floor. + let h = narrow_dock_harness(crate::panels::toolbar::toolbar_width(true, false) + 36.0 + CANVAS_MIN_WIDTH); + let canvas = narrow_canvas(&h); + let dock = allocated_dock(&h); + assert!(!canvas.intersect(dock).is_positive(), "the dock never overlaps the canvas: {canvas:?}, {dock:?}"); + // Asserts that some control's full rect overflows the dock while every clipped one stays inside. + let clipped = dock_controls_clipped(&h, dock); + assert!(clipped > 0, "a squeezed column has chrome wider than the dock that the clip cuts off, in {dock:?}"); +} + +/// A queued external width cancels a held or just-released dock-edge drag and is drawn at its +/// actual width on the first frame that processes it. +#[test] +fn external_width_requests_supersede_held_and_release_first_gestures() { + for release_first in [false, true] { + let mut h = dock_edge_harness(app_with_columns(&[400.0], false), vec2(1280.0, 800.0)); + let start = begin_outer_resize(&mut h); + h.event(Event::PointerMoved(start + vec2(30.0, 0.0))); + h.run_steps(1); + let ctx = h.ctx.clone(); + assert_eq!(control_set(h.state_mut(), &ctx, json!({"dockWidth":520.0}))["ok"], true); + assert!(!crate::panels::dock_edge_drag_active(&h.ctx), "the external request invalidates the gesture before another frame"); + if release_first { + release_outer_resize(&mut h, start + vec2(100.0, 0.0)); + } else { + h.run_steps(1); + } + assert!((dock_width(h.state()) - 520.0).abs() < 0.5, "the request is installed on its first frame"); + let rail = if h.state().ui.panels.rail { 36.0 } else { 0.0 }; + assert!( + (allocated_dock(&h).width() - 520.0 - rail).abs() < 1.0, + "release_first={release_first}: the FIRST processing frame allocates the requested model width plus one rail, got {:?}", + allocated_dock(&h), + ); + if !release_first { + h.event(Event::PointerMoved(start + vec2(100.0, 0.0))); + h.run_steps(1); + assert!((dock_width(h.state()) - 520.0).abs() < 0.5, "later held movement cannot restore the old width"); + release_outer_resize(&mut h, start + vec2(140.0, 0.0)); + } + h.run_steps(2); + assert!((dock_width(h.state()) - 520.0).abs() < 0.5); + } +} + +/// Narrowing the window below the iconic minimum cancels an active resize without committing the +/// stale pointer width, held or release-first, and widening restores the expanded width. +#[test] +fn active_iconic_resize_is_cancelled_when_the_window_narrows() { + for release_first in [false, true] { + let mut h = dock_edge_harness(app_with_columns(&[100.0], true), vec2(1280.0, 800.0)); + let start = begin_outer_resize(&mut h); + h.event(Event::PointerMoved(start - vec2(40.0, 0.0))); + h.run_steps(1); + let before = dock_width(h.state()); + let expanded = end_columns(h.state())[0].width; + assert!((before - 140.0).abs() < 0.5); + h.set_size(vec2(300.0, 600.0)); + if release_first { + release_outer_resize(&mut h, start + vec2(100.0, 0.0)); + } else { + h.event(Event::PointerMoved(start + vec2(100.0, 0.0))); + h.run_steps(1); + } + assert!(!crate::panels::dock_edge_drag_active(&h.ctx)); + assert_eq!(dock_width(h.state()), before, "narrowing cannot commit the stale pointer width"); + assert!(allocated_dock(&h).width() <= 44.5, "the allocated panel remains bounded"); + if !release_first { + release_outer_resize(&mut h, start + vec2(140.0, 0.0)); + } + h.set_size(vec2(1280.0, 800.0)); + h.run_steps(2); + assert_eq!(dock_width(h.state()), before); + assert_eq!(end_columns(h.state())[0].width, expanded); + } +} + +/// Several iconic columns disable resizing when the window cannot fit their combined minimum. +#[test] +fn multiple_iconic_columns_disable_resizing_below_their_combined_minimum() { + let mut h = dock_edge_harness(app_with_columns(&[100.0, 100.0], true), vec2(342.0, 600.0)); + let original = dock_width(h.state()); + let start = outer_handle(&h).center(); + h.event(Event::PointerMoved(start)); + h.run_steps(1); + h.event(Event::PointerButton { pos: start, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + assert!(!crate::panels::dock_edge_drag_active(&h.ctx), "50 available points cannot fit two 36-point columns"); + h.event(Event::PointerMoved(start + vec2(100.0, 0.0))); + h.run_steps(1); + release_outer_resize(&mut h, start + vec2(120.0, 0.0)); + assert_eq!(dock_width(h.state()), original); + h.set_size(vec2(1280.0, 800.0)); + h.run_steps(2); + assert_eq!(dock_width(h.state()), original, "widening retains the pre-constraint iconic widths"); +} + +/// With the dock, toolbar and canvas composed in a narrow window, the canvas keeps its minimum +/// width and the model keeps its column minimums, in both directions and themes, with and without +/// the rail. +#[test] +fn constrained_composed_layout_reserves_the_canvas_with_or_without_a_rail() { + for dir in [crate::direction::Dir::Ltr, crate::direction::Dir::Rtl] { + let _direction = crate::direction::direction_scope(dir); + for theme in [crate::theme::ThemeKind::ProMedium, crate::theme::ThemeKind::Classic] { + for rail in [false, true] { + let mut app = app_with_columns(&[290.0, 220.0], false); + app.ui.panels.rail = rail; + app.ui.theme = theme; + app.ui.panels.toolbar_side = if crate::direction::is_rtl() { crate::state::ToolbarSide::Left } else { crate::state::ToolbarSide::Right }; + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(400.0, 600.0)).with_step_dt(1.0 / 60.0).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + PhotocraftApp::setup_context(ui.ctx(), theme); + ready = true; + return; + } + crate::panels::dock_panel(app, ui); + crate::panels::toolbar(app, ui); + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { + ui.ctx().data_mut(|data| data.insert_temp(egui::Id::unique("constrained-canvas-rect"), ui.max_rect())); + }); + }, + app, + ); + h.run_steps(4); + let assert_geometry = |h: &Harness<'static, PhotocraftApp>| { + let canvas = h.ctx.data(|data| data.get_temp::(egui::Id::unique("constrained-canvas-rect")).unwrap()); + assert!(canvas.width() >= 199.5, "rail={rail}, dir={dir:?}: canvas {canvas:?}, dock {:?}", allocated_dock(h)); + assert!((dock_width(h.state()) - 510.0).abs() < 0.5, "the constrained display does not corrupt model minima"); + assert!(allocated_dock(h).width() <= 144.5, "actual allocation, not requested width"); + }; + assert_geometry(&h); + let start = outer_handle(&h).center(); + h.event(Event::PointerMoved(start)); + h.run_steps(1); + h.event(Event::PointerButton { pos: start, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + assert!(!crate::panels::dock_edge_drag_active(&h.ctx)); + release_outer_resize(&mut h, start + vec2(60.0, 0.0)); + assert_geometry(&h); + } + } + } +} + +/// A queued width is drawn on its first frame while native migration still waits for the +/// canvas; the native state is consumed once. +#[test] +fn queued_external_width_precedes_but_does_not_eagerly_consume_native_migration() { + let mut app = app(); + app.last_canvas_rect = egui::Rect::NOTHING; + let mut h = dock_edge_harness(app, vec2(1280.0, 800.0)); + assert!(!h.state().last_canvas_rect.is_positive(), "the dock-only harness has not laid out a canvas"); + assert!(h.state().ui.docking.is_none()); + let key = crate::panels::panel_state_id(&h.ctx, "dock"); + let state = egui::PanelState { outer_rect: egui::Rect::from_min_size(egui::pos2(900.0, 0.0), vec2(380.0, 700.0)) }; + h.ctx.data_mut(|data| data.insert_persisted(key, state)); + h.run_steps(1); + assert!(h.state().ui.docking.is_none(), "native migration still waits for canvas readiness"); + assert!(h.ctx.data_mut(|data| data.get_persisted::(key)).is_some()); + crate::panels::request_dock_width(&h.ctx, 520.0); + h.run_steps(1); + assert!((dock_width(h.state()) - 520.0).abs() < 0.5, "the explicit request takes precedence over the old native width"); + let rail = if h.state().ui.panels.rail { 36.0 } else { 0.0 }; + assert!((allocated_dock(&h).width() - 520.0 - rail).abs() < 1.0, "first-frame allocation uses that same width"); + assert!(h.ctx.data_mut(|data| data.get_persisted::(key)).is_none(), "the migrated state is consumed once"); + h.run_steps(2); + assert!((dock_width(h.state()) - 520.0).abs() < 0.5); +} + +/// Stores a native dock `PanelState` `width` points wide, as earlier versions saved it, and +/// returns its key. +fn remember_native_width_for_request_test(ctx: &egui::Context, width: f32) -> egui::Id { + let key = crate::panels::panel_state_id(ctx, "dock"); + let state = egui::PanelState { outer_rect: egui::Rect::from_min_size(pos2(900.0, 0.0), vec2(width, 700.0)) }; + ctx.data_mut(|data| data.insert_persisted(key, state)); + key +} + +/// With the canvas already laid out, an explicit same-width request retires native migration +/// without customizing the legacy workspace, in both layouts and with the workspace locked. +#[test] +fn explicit_legacy_noop_width_supersedes_native_migration_with_ready_canvas() { + for compact in [false, true] { + for locked in [false, true] { + let mut app = app(); + app.session.prefs.edit(|prefs| prefs.workspace_locked = locked); + let mut h = if compact { dock_edge_harness(app, vec2(1280.0, 800.0)) } else { harness(app, vec2(1280.0, 800.0)) }; + assert!(h.state().last_canvas_rect.is_positive()); + assert!(h.state().ui.docking.is_none()); + let requested = dock_width(h.state()); + let key = remember_native_width_for_request_test(&h.ctx, 380.0); + let ctx = h.ctx.clone(); + assert_eq!(control_set(h.state_mut(), &ctx, json!({"dockWidth":requested}))["ok"], true); + h.run_steps(1); + assert_eq!(dock_width(h.state()), requested, "compact={compact}, locked={locked}: migration cannot override an explicit legacy no-op"); + assert!(h.state().ui.docking.is_none(), "a same-width request must not customize the legacy workspace"); + assert!(h.ctx.data_mut(|data| data.get_persisted::(key)).is_none()); + assert!(h.ctx.data(|data| data.get_temp::(crate::panels::dock_width_id())).is_none()); + h.run_steps(2); + assert_eq!(dock_width(h.state()), requested); + assert!(h.state().ui.docking.is_none()); + } + } +} + +/// An explicit same-width request made before the canvas is ready still prevents a later +/// migration from applying the saved native width. +#[test] +fn explicit_legacy_noop_width_supersedes_native_migration_after_canvas_becomes_ready() { + for locked in [false, true] { + let mut app = app(); + app.last_canvas_rect = egui::Rect::NOTHING; + app.session.prefs.edit(|prefs| prefs.workspace_locked = locked); + let mut h = dock_edge_harness(app, vec2(1280.0, 800.0)); + let requested = dock_width(h.state()); + let key = remember_native_width_for_request_test(&h.ctx, 380.0); + let ctx = h.ctx.clone(); + assert_eq!(control_set(h.state_mut(), &ctx, json!({"dockWidth":requested}))["ok"], true); + h.run_steps(1); + assert!(!h.state().last_canvas_rect.is_positive()); + assert_eq!(dock_width(h.state()), requested); + assert!(h.state().ui.docking.is_none()); + let native_retained_after_request = h.ctx.data_mut(|data| data.get_persisted::(key)).is_some(); + assert!(h.ctx.data(|data| data.get_temp::(crate::panels::dock_width_id())).is_none()); + h.state_mut().last_canvas_rect = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 600.0)); + h.run_steps(1); + assert_eq!(dock_width(h.state()), requested, "locked={locked}: later canvas readiness cannot revive the superseded native width"); + assert!(!native_retained_after_request, "consuming the explicit request retires native migration even before canvas readiness"); + assert!(h.state().ui.docking.is_none(), "the legacy no-op still must not customize the workspace"); + assert!(h.ctx.data_mut(|data| data.get_persisted::(key)).is_none()); + } +} + +/// An invalid workspace defers both the queued request and the native state until recovery, +/// after which the explicit request wins. +#[test] +fn explicit_width_request_preserves_queue_and_native_state_until_invalid_workspace_recovery() { + // The current width (a legacy no-op) and a changed one. + for changed in [false, true] { + let mut h = dock_edge_harness(app(), vec2(1280.0, 800.0)); + let requested = if changed { 520.0 } else { dock_width(h.state()) }; + let key = remember_native_width_for_request_test(&h.ctx, 380.0); + let invalid = Workspace::new(Layout { root: None, floating: Vec::new() }); + assert!(!valid_workspace(&invalid)); + h.state_mut().ui.docking = Some(invalid.clone()); + let ctx = h.ctx.clone(); + assert_eq!(control_set(h.state_mut(), &ctx, json!({"dockWidth":requested}))["ok"], true); + apply_pending_dock_width(h.state_mut(), &ctx); + assert_eq!(h.state().ui.docking, Some(invalid)); + assert_eq!(h.ctx.data(|data| data.get_temp::(crate::panels::dock_width_id())), Some(requested)); + // The rectangle `remember_native_width_for_request_test` saved, in both cases. + assert_eq!( + h.ctx.data_mut(|data| data.get_persisted::(key)).map(|state| state.outer_rect), + Some(egui::Rect::from_min_size(pos2(900.0, 0.0), vec2(380.0, 700.0))), + "changed={changed}: neither queued request nor native state is consumed or changed before recovery" + ); + h.run_steps(1); + assert!( + (dock_width(h.state()) - requested).abs() < 0.5, + "changed={changed}: the restored workspace honors the explicit request instead of the saved native width, got {}", + dock_width(h.state()) + ); + if !changed { + assert!(h.state().ui.docking.is_none(), "the legacy no-op does not customize the workspace"); + } + assert!(h.ctx.data(|data| data.get_temp::(crate::panels::dock_width_id())).is_none(), "changed={changed}: the queued request is consumed"); + assert!(h.ctx.data_mut(|data| data.get_persisted::(key)).is_none(), "changed={changed}: the native state is retired"); + } +} + +/// A consumed request retires the native state even when it is clamped or the dock is empty. +#[test] +fn explicit_width_requests_retire_native_state_even_when_clamped_or_empty() { + for empty in [false, true] { + let mut h = dock_edge_harness(if empty { app_with_columns(&[], false) } else { app() }, vec2(1280.0, 800.0)); + let before = h.state().ui.docking.clone(); + let key = remember_native_width_for_request_test(&h.ctx, 380.0); + let ctx = h.ctx.clone(); + assert_eq!(control_set(h.state_mut(), &ctx, json!({"dockWidth":20}))["ok"], true); + apply_pending_dock_width(h.state_mut(), &ctx); + assert!(h.ctx.data(|data| data.get_temp::(crate::panels::dock_width_id())).is_none()); + assert!( + h.ctx.data_mut(|data| data.get_persisted::(key)).is_none(), + "an accepted explicit request supersedes migration even when empty={empty}" + ); + if empty { + assert_eq!(h.state().ui.docking, before, "an empty dock still has no width to change"); + } else { + assert_eq!(dock_width(h.state()), 250.0, "the existing request clamp remains unchanged"); + } + } +} + +/// Without an accepted request, native migration still waits for the canvas and then applies +/// the saved width once. +#[test] +fn native_migration_still_waits_and_runs_without_an_accepted_explicit_request() { + for rejected in [false, true] { + let mut app = app(); + app.last_canvas_rect = egui::Rect::NOTHING; + let mut h = dock_edge_harness(app, vec2(1280.0, 800.0)); + let key = remember_native_width_for_request_test(&h.ctx, 380.0); + if rejected { + let ctx = h.ctx.clone(); + assert_eq!(control_set(h.state_mut(), &ctx, json!({"dockWidth":"not a width"}))["ok"], false); + } + assert!(h.ctx.data(|data| data.get_temp::(crate::panels::dock_width_id())).is_none()); + h.run_steps(1); + assert!(h.state().ui.docking.is_none()); + assert!(h.ctx.data_mut(|data| data.get_persisted::(key)).is_some(), "absent/rejected requests do not discard a pending migration"); + h.state_mut().last_canvas_rect = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 600.0)); + h.run_steps(1); + assert_eq!(dock_width(h.state()), 380.0); + assert!(h.state().ui.docking.is_some()); + assert!(h.ctx.data_mut(|data| data.get_persisted::(key)).is_none(), "the unchanged native migration consumes its state once"); + } +} diff --git a/crates/ui-egui/src/panels/toolbar.rs b/crates/ui-egui/src/panels/toolbar.rs index da8500f535..a8ed212b72 100644 --- a/crates/ui-egui/src/panels/toolbar.rs +++ b/crates/ui-egui/src/panels/toolbar.rs @@ -1,8 +1,20 @@ use super::options_bar::reveal_focused_control; use super::{Align2, Color32, PhotocraftApp, Pos2, Rect, Sense, Stroke, StrokeKind, Tokens, Tool, c32, icons, json, pos2, vec2}; +use crate::state::ToolbarSide; // ----------------------------------------------------------------------------- toolbar +/// The toolbar's single-column width and its second column's button width, in points. +pub(crate) fn toolbar_columns(pro: bool) -> (f32, f32) { + if pro { (40.0, 30.0) } else { (50.0, 36.0) } +} + +/// The toolbar panel's width in points: one column, or two when the header chevron asks for them. +pub(crate) fn toolbar_width(pro: bool, double: bool) -> f32 { + let (w1, bx) = toolbar_columns(pro); + if double { w1 + bx + 2.0 } else { w1 } +} + /// Tool groups separated by hairlines, Photoshop order. /// Photoshop toolbar: divider-separated sections of slots; each slot is a flyout group whose button /// shows the group's last-used tool. @@ -105,7 +117,7 @@ pub(crate) fn more_flyout_open(ctx: &egui::Context) -> bool { /// Right-click on the "…" button opens a flyout with one row, "Edit Toolbar…", drawn like a tool /// flyout. The row runs `edit.toolbar` and closes the flyout; Escape or a press outside closes it /// without running anything. -fn more_slot_flyout(app: &mut PhotocraftApp, ui: &mut egui::Ui, button: &egui::Response, t: &Tokens) { +fn more_slot_flyout(app: &mut PhotocraftApp, ui: &mut egui::Ui, button: &egui::Response, t: &Tokens, side: ToolbarSide) { let open_id = more_flyout_id(); let open = ui.data(|d| d.get_temp::(open_id)).unwrap_or(false); if button.secondary_clicked() { @@ -116,7 +128,7 @@ fn more_slot_flyout(app: &mut PhotocraftApp, ui: &mut egui::Ui, button: &egui::R if !open { return; } - let (pivot, pos) = if crate::direction::is_rtl() { + let (pivot, pos) = if side == ToolbarSide::Right { (egui::Align2::RIGHT_BOTTOM, button.rect.left_bottom() - vec2(6.0, 0.0)) } else { (egui::Align2::LEFT_BOTTOM, button.rect.right_bottom() + vec2(6.0, 0.0)) @@ -150,353 +162,369 @@ fn more_slot_flyout(app: &mut PhotocraftApp, ui: &mut egui::Ui, button: &egui::R } } +/// The tool palette panel, on the side `panels.toolbar_side` resolves to, with its width +/// toggle and Move Toolbar header buttons. pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let (w1, bx, m) = if t.pro { (40.0, 30.0, 5i8) } else { (50.0, 36.0, 7i8) }; + let (_, bx) = toolbar_columns(t.pro); + let m = if t.pro { 5i8 } else { 7i8 }; let sections = visible_sections(&app.session.prefs().toolbar.hidden); // Two columns when the header chevron asks for them; a column that doesn't fit scrolls. let double = app.ui.panels.toolbar_double; - let w = if double { w1 + bx + 2.0 } else { w1 }; - crate::direction::start_panel("toolbar") - .resizable(false) - .exact_size(w) - .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(m, 8))) - .show(ui, |ui| { - // Toolbar width is a user preference, not a Pro-only feature. - // Keep the same hit target in every theme (including RTL layouts). - let (cr, resp) = ui.allocate_exact_size(vec2(bx, 14.0), Sense::click()); - let (shrink, grow) = if crate::direction::is_rtl() { ("chevrons-right", "chevrons-left") } else { ("chevrons-left", "chevrons-right") }; + let w = toolbar_width(t.pro, double); + let side = app.ui.panels.toolbar_side.resolve(); + let panel = if side == ToolbarSide::Right { egui::Panel::right("toolbar") } else { egui::Panel::left("toolbar") }; + panel.resizable(false).exact_size(w).frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(m, 8))).show(ui, |ui| { + // Toolbar width is a user preference, not a Pro-only feature. The two header buttons share + // the narrow toolbar column (14-point targets in every theme, including RTL layouts), so + // their tooltips carry the full names. + ui.allocate_ui_with_layout(vec2(bx, 14.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = 2.0; + let (cr, resp) = ui.allocate_exact_size(vec2(bx.min(14.0), 14.0), Sense::click()); + let (shrink, grow) = if side == ToolbarSide::Right { ("chevrons-right", "chevrons-left") } else { ("chevrons-left", "chevrons-right") }; icons::paint(ui, cr, if double { shrink } else { grow }, 11.0, if resp.hovered() { t.text } else { t.text_faint }); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Toolbar"))); if resp.clicked() { app.ui.panels.toolbar_double = !double; } - ui.add_space(4.0); - // Subtle violet wash at the bottom of the toolbar. - let full = ui.max_rect(); - if !t.bevel && !t.pro && t.dark() { - let mut mesh = egui::Mesh::default(); - let r = Rect::from_min_max(pos2(full.left() - 7.0, full.bottom() - 260.0), pos2(full.right() + 7.0, full.bottom() + 8.0)); - let top = Color32::TRANSPARENT; - let bottom = Color32::from_rgba_unmultiplied(90, 70, 190, 34); - mesh.colored_vertex(r.left_top(), top); - mesh.colored_vertex(r.right_top(), top); - mesh.colored_vertex(r.right_bottom(), bottom); - mesh.colored_vertex(r.left_bottom(), bottom); - mesh.add_triangle(0, 1, 2); - mesh.add_triangle(0, 2, 3); - ui.painter().add(mesh); + let other = side.other(); + let (dock_tip, dock_icon) = match other { + ToolbarSide::Right => (tl!("Move Toolbar to the Right"), "panel-right"), + _ => (tl!("Move Toolbar to the Left"), "panel-left"), + }; + let (dr, dock) = ui.allocate_exact_size(vec2((bx - 16.0).max(14.0), 14.0), Sense::click()); + icons::paint(ui, dr, dock_icon, 11.0, if dock.hovered() { t.text } else { t.text_faint }); + dock.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, dock_tip)); + if dock.on_hover_text(dock_tip).clicked() { + app.ui.panels.toolbar_side = other; + // Persist the side as a discrete change; the dock may not be drawn this frame. + crate::dock::persist_panels(app, ui.ctx()); } - ui.spacing_mut().item_spacing = vec2(2.0, 3.0); - let flyout_id = egui::Id::unique("tool-flyout"); - let held_id = flyout_id.with("held"); - if ui.input(|i| i.pointer.any_pressed()) { - ui.data_mut(|d| d.remove::(held_id)); - } - // No tool tooltips over an open flyout, or while a button is held to open one (#4450): - // Photoshop shows none there. (The flyout is our own Area, so egui's rule for popups - // doesn't hide them.) - let tool_tips = ui.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id)).is_none() && !ui.input(|i| i.pointer.primary_down()); - let ctx = ui.ctx().clone(); - let footer = (if t.pro { 8.0 } else { 14.0 }) - + chips_height(t.pro, t.round_chips, t.round_chips && double) - + 8.0 - + if double { bx } else { bx * 2.0 + 3.0 } - + 6.0; - // As tall as the tools, so the colour chips sit right under them (Photoshop); a column - // taller than the window scrolls and keeps the chips at the bottom. - egui::ScrollArea::vertical() - .id_salt("toolbar-columns") - .auto_shrink([false, true]) - .max_height((ui.available_height() - footer).max(0.0)) - .min_scrolled_height(0.0) - .show(ui, |ui| { - // Pro has no group dividers, so its two columns fill every row across groups. - let sections: Vec)>> = if t.pro { vec![sections.into_iter().flatten().collect()] } else { sections }; - for (si, section) in sections.iter().enumerate() { - // Photoshop 2026 draws one uninterrupted column (no group dividers). - if si > 0 && !t.pro { - ui.add_space(4.0); - let (r, _) = ui.allocate_exact_size(vec2(if double { bx * 2.0 + 2.0 } else { bx }, 1.0), Sense::hover()); - ui.painter().line_segment([r.left_center() + vec2(8.0, 0.0), r.right_center() - vec2(8.0, 0.0)], Stroke::new(1.0, t.separator)); - ui.add_space(4.0); - } - let rows: Vec<&[(usize, Vec)]> = if double { section.chunks(2).collect() } else { section.chunks(1).collect() }; - for row in rows { - ui.horizontal(|ui| { - for (slot_index, slot) in row.iter() { - let key = egui::Id::unique(("tool-slot", *slot_index)); - let tool = slot_tool(app.ui.tool, &app.ui.group_tools, slot); - let sel = slot.contains(&app.ui.tool); - let tip = match crate::tool_shortcuts::shortcut(app, tool) { - Some(sc) => format!("{} ({})", tl!(tool.label()), crate::shortcuts::pretty(&sc)), - None => tl!(tool.label()).to_string(), - }; - let resp = - icons::button_with_icon_size_tip(ui, icons::tool_icon(tool), bx, toolbar_icon_size(bx, t.pro), sel, &tip, tool_tips); - if slot.len() > 1 { - paint_toolbar_flyout_indicator(ui, &t, &resp); - } - // Plain click selects the shown tool; Alt/Option-click cycles the - // flyout slot to the next tool, as Shift+letter does for the key group. - // Prefer the primary-release event's modifiers (Alt may already be - // up), and only a release on this slot — the frame's event list is - // shared with every other widget. - let click_mods = - ui.input(|i| { - i.events - .iter() - .rev() - .find_map(|e| match e { - egui::Event::PointerButton { - pos, button: egui::PointerButton::Primary, pressed: false, modifiers, .. - } if resp.rect.contains(*pos) => Some(*modifiers), - _ => None, - }) - .unwrap_or(i.modifiers) - }); - let alt = click_mods.alt; - let modified = click_mods.alt || click_mods.shift || click_mods.ctrl || click_mods.command; - // egui's double-click ignores modifiers: a plain click after a - // modified click within max_double_click_delay still reports - // double_clicked(). Remember the previous click so Hand fit / - // Zoom 100% only run for two plain clicks. - let prev_mod_id = key.with("click-mod"); - let prev_modified = ui.data(|d| d.get_temp::(prev_mod_id)).unwrap_or(false); - let slot_click = resp.clicked() && ui.data(|d| d.get_temp::(held_id)) != Some(key); - if slot_click { - let pick = if slot.len() > 1 && alt { - let from = if slot.contains(&app.ui.tool) { app.ui.tool } else { tool }; - next_slot_tool(slot, from) - } else { - tool - }; - crate::tool_memory::select(app, pick); - } - // Double-clicking the Hand tool fits the image on screen (Photoshop). - // Modified clicks only cycle / select: a second rapid Alt-click, or a - // plain click that egui pairs with a prior modified click, must not - // count as Hand/Zoom double-click after cycling onto those tools. - let plain_double = resp.double_clicked() && !modified && !prev_modified; - if plain_double && tool == Tool::Hand { - crate::tool_memory::select(app, tool); - // With no document open there is nothing to fit. - let _ = app.run("view.fitOnScreen", json!({})); - } - if plain_double && tool == Tool::Zoom { - crate::tool_memory::select(app, tool); - let _ = app.run("view.actualPixels", json!({})); - } - if slot_click { - ui.data_mut(|d| d.insert_temp(prev_mod_id, modified)); - } - // Right-click, long-press, or press-drag away opens the flyout (Photoshop). - let held_for = - resp.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|t0| i.time - t0))).flatten(); - if slot.len() > 1 - && let Some(seconds) = held_for - && seconds < 0.35 - { - ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds)); - } - let long_press = held_for.is_some_and(|seconds| seconds >= 0.35); - let drag_open = ui - .input(|i| tool_flyout_drag_open(resp.rect, i.pointer.press_origin(), i.pointer.hover_pos(), i.pointer.primary_down())); - // Clicking the button of its open flyout again, with either button, closes - // it (#4932); the release that ends the long press that opened it doesn't. - let open_here = ui.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id)).is_some_and(|(k, _)| k == key); - let reclicked = open_here - && (resp.secondary_clicked() || (resp.clicked() && ui.data(|d| d.get_temp::(held_id)) != Some(key))); - if reclicked { - ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id)); - } else if slot.len() > 1 && (resp.secondary_clicked() || long_press || drag_open) { - ui.data_mut(|d| { - d.insert_temp(flyout_id, (key, resp.rect)); - if long_press || drag_open { - d.insert_temp(held_id, key); + }); + ui.add_space(4.0); + // Subtle violet wash at the bottom of the toolbar. + let full = ui.max_rect(); + if !t.bevel && !t.pro && t.dark() { + let mut mesh = egui::Mesh::default(); + let r = Rect::from_min_max(pos2(full.left() - 7.0, full.bottom() - 260.0), pos2(full.right() + 7.0, full.bottom() + 8.0)); + let top = Color32::TRANSPARENT; + let bottom = Color32::from_rgba_unmultiplied(90, 70, 190, 34); + mesh.colored_vertex(r.left_top(), top); + mesh.colored_vertex(r.right_top(), top); + mesh.colored_vertex(r.right_bottom(), bottom); + mesh.colored_vertex(r.left_bottom(), bottom); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + ui.painter().add(mesh); + } + ui.spacing_mut().item_spacing = vec2(2.0, 3.0); + let flyout_id = egui::Id::unique("tool-flyout"); + let held_id = flyout_id.with("held"); + if ui.input(|i| i.pointer.any_pressed()) { + ui.data_mut(|d| d.remove::(held_id)); + } + // No tool tooltips over an open flyout, or while a button is held to open one (#4450): + // Photoshop shows none there. (The flyout is our own Area, so egui's rule for popups + // doesn't hide them.) + let tool_tips = ui.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id)).is_none() && !ui.input(|i| i.pointer.primary_down()); + let ctx = ui.ctx().clone(); + let footer = (if t.pro { 8.0 } else { 14.0 }) + + chips_height(t.pro, t.round_chips, t.round_chips && double) + + 8.0 + + if double { bx } else { bx * 2.0 + 3.0 } + + 6.0; + // As tall as the tools, so the colour chips sit right under them (Photoshop); a column + // taller than the window scrolls and keeps the chips at the bottom. + egui::ScrollArea::vertical() + .id_salt("toolbar-columns") + .auto_shrink([false, true]) + .max_height((ui.available_height() - footer).max(0.0)) + .min_scrolled_height(0.0) + .show(ui, |ui| { + // Pro has no group dividers, so its two columns fill every row across groups. + let sections: Vec)>> = if t.pro { vec![sections.into_iter().flatten().collect()] } else { sections }; + for (si, section) in sections.iter().enumerate() { + // Photoshop 2026 draws one uninterrupted column (no group dividers). + if si > 0 && !t.pro { + ui.add_space(4.0); + let (r, _) = ui.allocate_exact_size(vec2(if double { bx * 2.0 + 2.0 } else { bx }, 1.0), Sense::hover()); + ui.painter().line_segment([r.left_center() + vec2(8.0, 0.0), r.right_center() - vec2(8.0, 0.0)], Stroke::new(1.0, t.separator)); + ui.add_space(4.0); + } + let rows: Vec<&[(usize, Vec)]> = if double { section.chunks(2).collect() } else { section.chunks(1).collect() }; + for row in rows { + ui.horizontal(|ui| { + for (slot_index, slot) in row.iter() { + let key = egui::Id::unique(("tool-slot", *slot_index)); + let tool = slot_tool(app.ui.tool, &app.ui.group_tools, slot); + let sel = slot.contains(&app.ui.tool); + let tip = match crate::tool_shortcuts::shortcut(app, tool) { + Some(sc) => format!("{} ({})", tl!(tool.label()), crate::shortcuts::pretty(&sc)), + None => tl!(tool.label()).to_string(), + }; + let resp = icons::button_with_icon_size_tip(ui, icons::tool_icon(tool), bx, toolbar_icon_size(bx, t.pro), sel, &tip, tool_tips); + if slot.len() > 1 { + paint_toolbar_flyout_indicator(ui, &t, &resp, side); + } + // Plain click selects the shown tool; Alt/Option-click cycles the + // flyout slot to the next tool, as Shift+letter does for the key group. + // Prefer the primary-release event's modifiers (Alt may already be + // up), and only a release on this slot — the frame's event list is + // shared with every other widget. + let click_mods = ui.input(|i| { + i.events + .iter() + .rev() + .find_map(|e| match e { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers, .. } + if resp.rect.contains(*pos) => + { + Some(*modifiers) } - }); - } - if slot.len() > 1 && long_press { - // Holding a tool button selects the tool it shows, as a click would (#4453). - crate::tool_memory::select(app, tool); - ui.ctx().request_repaint(); - } - if let Some((open_key, anchor)) = ui.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id)) - && open_key == key - { - // Beside the tool, towards the canvas. - let (pivot, pos) = if crate::direction::is_rtl() { - (egui::Align2::RIGHT_TOP, anchor.left_top() - vec2(6.0, 0.0)) - } else { - (egui::Align2::LEFT_TOP, anchor.right_top() + vec2(6.0, 0.0)) - }; - let area = egui::Area::new(key.with("flyout")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(pos).show( - ui.ctx(), - |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - let label_w = slot - .iter() - .map(|it| { - ui.painter() - .layout_no_wrap(tl!(it.label()).to_string(), egui::FontId::proportional(12.5), t.text) - .size() - .x - }) - .fold(0.0f32, f32::max); - let fw = (label_w + 42.0 + 40.0).max(180.0); - for &item in slot.iter() { - let (r, ir) = ui.allocate_exact_size(vec2(fw, 26.0), Sense::click()); - if ir.hovered() { - ui.painter().rect_filled(r, 3.0, t.hover); - } - if item == tool { - ui.painter().rect_filled( - Rect::from_center_size(pos2(r.left() + 8.0, r.center().y), vec2(4.0, 4.0)), - 0.0, - t.text, - ); - } - icons::paint( - ui, - Rect::from_center_size(pos2(r.left() + 26.0, r.center().y), vec2(18.0, 18.0)), - icons::tool_icon(item), - 14.0, - t.icon, - ); - ui.painter().text( - pos2(r.left() + 42.0, r.center().y), - Align2::LEFT_CENTER, - tl!(item.label()), - egui::FontId::proportional(12.5), + _ => None, + }) + .unwrap_or(i.modifiers) + }); + let alt = click_mods.alt; + let modified = click_mods.alt || click_mods.shift || click_mods.ctrl || click_mods.command; + // egui's double-click ignores modifiers: a plain click after a + // modified click within max_double_click_delay still reports + // double_clicked(). Remember the previous click so Hand fit / + // Zoom 100% only run for two plain clicks. + let prev_mod_id = key.with("click-mod"); + let prev_modified = ui.data(|d| d.get_temp::(prev_mod_id)).unwrap_or(false); + let slot_click = resp.clicked() && ui.data(|d| d.get_temp::(held_id)) != Some(key); + if slot_click { + let pick = if slot.len() > 1 && alt { + let from = if slot.contains(&app.ui.tool) { app.ui.tool } else { tool }; + next_slot_tool(slot, from) + } else { + tool + }; + crate::tool_memory::select(app, pick); + } + // Double-clicking the Hand tool fits the image on screen (Photoshop). + // Modified clicks only cycle / select: a second rapid Alt-click, or a + // plain click that egui pairs with a prior modified click, must not + // count as Hand/Zoom double-click after cycling onto those tools. + let plain_double = resp.double_clicked() && !modified && !prev_modified; + if plain_double && tool == Tool::Hand { + crate::tool_memory::select(app, tool); + // With no document open there is nothing to fit. + let _ = app.run("view.fitOnScreen", json!({})); + } + if plain_double && tool == Tool::Zoom { + crate::tool_memory::select(app, tool); + let _ = app.run("view.actualPixels", json!({})); + } + if slot_click { + ui.data_mut(|d| d.insert_temp(prev_mod_id, modified)); + } + // Right-click, long-press, or press-drag away opens the flyout (Photoshop). + let held_for = + resp.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|t0| i.time - t0))).flatten(); + if slot.len() > 1 + && let Some(seconds) = held_for + && seconds < 0.35 + { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds)); + } + let long_press = held_for.is_some_and(|seconds| seconds >= 0.35); + let drag_open = + ui.input(|i| tool_flyout_drag_open(resp.rect, i.pointer.press_origin(), i.pointer.hover_pos(), i.pointer.primary_down())); + // Clicking the button of its open flyout again, with either button, closes + // it (#4932); the release that ends the long press that opened it doesn't. + let open_here = ui.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id)).is_some_and(|(k, _)| k == key); + let reclicked = + open_here && (resp.secondary_clicked() || (resp.clicked() && ui.data(|d| d.get_temp::(held_id)) != Some(key))); + if reclicked { + ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id)); + } else if slot.len() > 1 && (resp.secondary_clicked() || long_press || drag_open) { + ui.data_mut(|d| { + d.insert_temp(flyout_id, (key, resp.rect)); + if long_press || drag_open { + d.insert_temp(held_id, key); + } + }); + } + if slot.len() > 1 && long_press { + // Holding a tool button selects the tool it shows, as a click would (#4453). + crate::tool_memory::select(app, tool); + ui.ctx().request_repaint(); + } + if let Some((open_key, anchor)) = ui.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id)) + && open_key == key + { + // Beside the tool, towards the canvas. + let (pivot, pos) = if side == ToolbarSide::Right { + (egui::Align2::RIGHT_TOP, anchor.left_top() - vec2(6.0, 0.0)) + } else { + (egui::Align2::LEFT_TOP, anchor.right_top() + vec2(6.0, 0.0)) + }; + let area = + egui::Area::new(key.with("flyout")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(pos).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + let label_w = slot + .iter() + .map(|it| { + ui.painter() + .layout_no_wrap(tl!(it.label()).to_string(), egui::FontId::proportional(12.5), t.text) + .size() + .x + }) + .fold(0.0f32, f32::max); + let fw = (label_w + 42.0 + 40.0).max(180.0); + for &item in slot.iter() { + let (r, ir) = ui.allocate_exact_size(vec2(fw, 26.0), Sense::click()); + if ir.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + if item == tool { + ui.painter().rect_filled( + Rect::from_center_size(pos2(r.left() + 8.0, r.center().y), vec2(4.0, 4.0)), + 0.0, t.text, ); - if let Some(sc) = crate::tool_shortcuts::shortcut(app, item) { - ui.painter().text( - pos2(r.right() - 8.0, r.center().y), - Align2::RIGHT_CENTER, - crate::shortcuts::pretty(&sc), - egui::FontId::proportional(12.0), - t.text_dim, - ); - } - let drag_release = tool_flyout_release( - r, - ui.input(|i| i.pointer.hover_pos()), - ui.input(|i| i.pointer.primary_released()), - ui.data(|d| d.get_temp::(held_id)) == Some(key), - ); - if ir.clicked() || drag_release { - crate::tool_memory::select(app, item); - ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id)); - } } - }); - }, - ); - // Any press outside the flyout and its button closes it, as in Photoshop: a - // drag on the canvas (cropping, painting) isn't a click (#4460). - let pressed_outside = ui.input(|i| { - i.pointer.any_pressed() - && i.pointer.press_origin().is_some_and(|p| !area.response.rect.contains(p) && !resp.rect.contains(p)) + icons::paint( + ui, + Rect::from_center_size(pos2(r.left() + 26.0, r.center().y), vec2(18.0, 18.0)), + icons::tool_icon(item), + 14.0, + t.icon, + ); + ui.painter().text( + pos2(r.left() + 42.0, r.center().y), + Align2::LEFT_CENTER, + tl!(item.label()), + egui::FontId::proportional(12.5), + t.text, + ); + if let Some(sc) = crate::tool_shortcuts::shortcut(app, item) { + ui.painter().text( + pos2(r.right() - 8.0, r.center().y), + Align2::RIGHT_CENTER, + crate::shortcuts::pretty(&sc), + egui::FontId::proportional(12.0), + t.text_dim, + ); + } + let drag_release = tool_flyout_release( + r, + ui.input(|i| i.pointer.hover_pos()), + ui.input(|i| i.pointer.primary_released()), + ui.data(|d| d.get_temp::(held_id)) == Some(key), + ); + if ir.clicked() || drag_release { + crate::tool_memory::select(app, item); + ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id)); + } + } + }); }); - if pressed_outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { - ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id)); - } + // Any press outside the flyout and its button closes it, as in Photoshop: a + // drag on the canvas (cropping, painting) isn't a click (#4460). + let pressed_outside = ui.input(|i| { + i.pointer.any_pressed() + && i.pointer.press_origin().is_some_and(|p| !area.response.rect.contains(p) && !resp.rect.contains(p)) + }); + if pressed_outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id)); } } - }); - } + } + }); } - let more = icons::button(ui, "ellipsis", bx, false, tl!("Edit Toolbar…")); - if more.clicked() - && let Err(e) = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({})) - { - app.ui.status = e; - app.ui.status_error = true; - } - more_slot_flyout(app, ui, &more, &t); - reveal_focused_control(ui); - }); - ui.add_space(if t.pro { 8.0 } else { 14.0 }); - // Centred in the tool column as it is before the tools scroll: a scroll bar widens the - // UI it is in, and the chips moved over by its width (#7316). - color_chips(app, ui, double, full.width()); - ui.add_space(8.0); - - let layout = if double { egui::Layout::left_to_right(egui::Align::Min) } else { egui::Layout::top_down(egui::Align::Min) }; - - ui.with_layout(layout, |ui| { - let quick_mask = app.session.active().is_some_and(|s| s.doc.quick_mask.is_some()); - if icons::button_with_icon_size_tip( - ui, - "square-dashed", - bx, - (bx * 0.52).round(), - quick_mask, - if quick_mask { tl!("Edit in Standard Mode (Q)") } else { tl!("Edit in Quick Mask Mode (Q)") }, - tool_tips, - ) - .clicked() + } + let more = icons::button(ui, "ellipsis", bx, false, tl!("Edit Toolbar…")); + if more.clicked() + && let Err(e) = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({})) { - let _ = crate::menus::invoke(app, &ctx, "select.editInQuickMaskMode", json!({})); + app.ui.status = e; + app.ui.status_error = true; } - // Greyed out while Cycle is unavailable (no document in Standard, #9349): its - // list would offer nothing else either. - let cycle_enabled = crate::view_cmds::is_enabled(app, "view.screenMode.cycle").unwrap_or(true); - let sm = ui - .add_enabled_ui(cycle_enabled, |ui| { - icons::button_with_icon_size_tip(ui, "app-window", bx, (bx * 0.52).round(), false, tl!("Change Screen Mode (F)"), tool_tips) - }) - .inner; - paint_toolbar_flyout_indicator(ui, &t, &sm); + more_slot_flyout(app, ui, &more, &t, side); + reveal_focused_control(ui); + }); + ui.add_space(if t.pro { 8.0 } else { 14.0 }); + // Centred in the tool column as it is before the tools scroll: a scroll bar widens the + // UI it is in, and the chips moved over by its width (#7316). + color_chips(app, ui, double, full.width()); + ui.add_space(8.0); - // Right-click, or click and hold like the tool flyouts (#4444), lists the modes, - // like Photoshop's flyout. The release that ends a hold doesn't cycle the mode. - let held_id = sm.id.with("held"); - let was_held = ui.data(|d| d.get_temp::(held_id)).unwrap_or(false); - let held_for = sm.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|t0| i.time - t0))).flatten(); - if let Some(seconds) = held_for - && seconds < 0.35 - { - ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds)); - } - let long_press = !was_held && held_for.is_some_and(|seconds| seconds >= 0.35); - if long_press { - ui.data_mut(|d| d.insert_temp(held_id, true)); - } else if was_held && !sm.is_pointer_button_down_on() { - ui.data_mut(|d| d.remove::(held_id)); - } - let cycle = sm.clicked() && !was_held; - if cycle { - let _ = crate::menus::invoke(app, &ctx, "view.screenMode.cycle", json!({})); - } - let open = if sm.secondary_clicked() || long_press { - Some(egui::SetOpenCommand::Bool(true)) - } else if cycle { - Some(egui::SetOpenCommand::Bool(false)) - } else { - None - }; - // The release ending the hold counts as a click; it mustn't close the list it opened. - let close = if was_held || long_press { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClick }; - egui::Popup::menu(&sm).open_memory(open).close_behavior(close).at_pointer_fixed().show(|ui| { - ui.set_min_width(220.0); - for (id, label) in [ - ("view.screenMode.standard", tl!("Standard Screen Mode")), - ("view.screenMode.fullScreenWithMenuBar", tl!("Full Screen Mode With Menu Bar")), - ("view.screenMode.fullScreen", tl!("Full Screen Mode")), - ] { - let on = crate::view_cmds::checked(app, id).unwrap_or(false); - let enabled = crate::view_cmds::is_enabled(app, id).unwrap_or(true); - if ui.add_enabled(enabled, egui::Button::selectable(on, label)).clicked() { - let _ = crate::menus::invoke(app, &ctx, id, json!({})); - ui.close(); - } + let layout = if double { egui::Layout::left_to_right(egui::Align::Min) } else { egui::Layout::top_down(egui::Align::Min) }; + + ui.with_layout(layout, |ui| { + let quick_mask = app.session.active().is_some_and(|s| s.doc.quick_mask.is_some()); + if icons::button_with_icon_size_tip( + ui, + "square-dashed", + bx, + (bx * 0.52).round(), + quick_mask, + if quick_mask { tl!("Edit in Standard Mode (Q)") } else { tl!("Edit in Quick Mask Mode (Q)") }, + tool_tips, + ) + .clicked() + { + let _ = crate::menus::invoke(app, &ctx, "select.editInQuickMaskMode", json!({})); + } + // Greyed out while Cycle is unavailable (no document in Standard, #9349): its + // list would offer nothing else either. + let cycle_enabled = crate::view_cmds::is_enabled(app, "view.screenMode.cycle").unwrap_or(true); + let sm = ui + .add_enabled_ui(cycle_enabled, |ui| { + icons::button_with_icon_size_tip(ui, "app-window", bx, (bx * 0.52).round(), false, tl!("Change Screen Mode (F)"), tool_tips) + }) + .inner; + paint_toolbar_flyout_indicator(ui, &t, &sm, side); + + // Right-click, or click and hold like the tool flyouts (#4444), lists the modes, + // like Photoshop's flyout. The release that ends a hold doesn't cycle the mode. + let held_id = sm.id.with("held"); + let was_held = ui.data(|d| d.get_temp::(held_id)).unwrap_or(false); + let held_for = sm.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|t0| i.time - t0))).flatten(); + if let Some(seconds) = held_for + && seconds < 0.35 + { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds)); + } + let long_press = !was_held && held_for.is_some_and(|seconds| seconds >= 0.35); + if long_press { + ui.data_mut(|d| d.insert_temp(held_id, true)); + } else if was_held && !sm.is_pointer_button_down_on() { + ui.data_mut(|d| d.remove::(held_id)); + } + let cycle = sm.clicked() && !was_held; + if cycle { + let _ = crate::menus::invoke(app, &ctx, "view.screenMode.cycle", json!({})); + } + let open = if sm.secondary_clicked() || long_press { + Some(egui::SetOpenCommand::Bool(true)) + } else if cycle { + Some(egui::SetOpenCommand::Bool(false)) + } else { + None + }; + // The release ending the hold counts as a click; it mustn't close the list it opened. + let close = if was_held || long_press { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClick }; + egui::Popup::menu(&sm).open_memory(open).close_behavior(close).at_pointer_fixed().show(|ui| { + ui.set_min_width(220.0); + for (id, label) in [ + ("view.screenMode.standard", tl!("Standard Screen Mode")), + ("view.screenMode.fullScreenWithMenuBar", tl!("Full Screen Mode With Menu Bar")), + ("view.screenMode.fullScreen", tl!("Full Screen Mode")), + ] { + let on = crate::view_cmds::checked(app, id).unwrap_or(false); + let enabled = crate::view_cmds::is_enabled(app, id).unwrap_or(true); + if ui.add_enabled(enabled, egui::Button::selectable(on, label)).clicked() { + let _ = crate::menus::invoke(app, &ctx, id, json!({})); + ui.close(); } - }); + } }); }); + }); } /// Glyph size of a toolbar tool button. Pro fills two thirds of the button, close to Photoshop's @@ -507,9 +535,10 @@ pub(super) fn toolbar_icon_size(box_size: f32, pro: bool) -> f32 { /// Paints a triangle on the bottom corner of an icon button that faces the canvas (bottom right, /// bottom left in a right-to-left UI) to indicate that a toolbar item has a submenu. -pub fn paint_toolbar_flyout_indicator(ui: &mut egui::Ui, tokens: &Tokens, resp: &egui::Response) { +pub fn paint_toolbar_flyout_indicator(ui: &mut egui::Ui, tokens: &Tokens, resp: &egui::Response, side: ToolbarSide) { let r = resp.rect; - let tri = if crate::direction::is_rtl() { + // The corner nearest the canvas, so the triangle faces the flyout. + let tri = if side == ToolbarSide::Right { vec![r.left_bottom() + vec2(2.0, -2.0), r.left_bottom() + vec2(6.0, -2.0), r.left_bottom() + vec2(2.0, -6.0)] } else { vec![r.right_bottom() + vec2(-2.0, -2.0), r.right_bottom() + vec2(-6.0, -2.0), r.right_bottom() + vec2(-2.0, -6.0)] diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 9d07bf8fce..063e14ae2a 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -24,6 +24,45 @@ pub use transform::{ BoxResize, CamTransform, ContentAwareOptions, Extras, MadeLayer, TextEdit, TransformMode, TransformSession, TypeInsertionStyle, TypeTransform, }; +/// Which side of the canvas holds the tool palette. `Start` follows the UI direction; the +/// physical sides pin it so the palette can sit beside the right-hand dock. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ToolbarSide { + /// The leading edge of the interface direction: left in LTR, right in RTL. + #[default] + Start, + /// The window's left edge in every direction. + Left, + /// The window's right edge in every direction. + Right, +} + +/// Reads a [`ToolbarSide`], falling back to the default for any value it does not name. +fn toolbar_side_or_default<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let value = serde_json::Value::deserialize(d)?; + Ok(serde_json::from_value(value).unwrap_or_default()) +} + +impl ToolbarSide { + /// Resolves `Start` against the interface direction; physical sides are returned unchanged. + pub fn resolve(self) -> Self { + match self { + Self::Start if crate::direction::is_rtl() => Self::Right, + Self::Start => Self::Left, + side => side, + } + } + + /// The opposite physical side. + pub fn other(self) -> Self { + match self.resolve() { + Self::Right => Self::Left, + _ => Self::Right, + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Panels { pub layers: bool, @@ -46,6 +85,10 @@ pub struct Panels { /// The toolbar's header chevron: two columns even when one fits (#1197). #[serde(default)] pub toolbar_double: bool, + /// Physical side of the tool palette. `Start` mirrors with the reading direction. An unknown + /// or malformed value reads as `Start`, so it never discards the other panel flags. + #[serde(default, deserialize_with = "toolbar_side_or_default")] + pub toolbar_side: ToolbarSide, /// The right-hand panel dock. ⇧Tab hides and shows it, as in Photoshop (#1313). #[serde(default = "yes")] pub dock: bool, @@ -105,6 +148,7 @@ impl Default for Panels { brushes: false, character: false, toolbar_double: false, + toolbar_side: ToolbarSide::Start, dock: true, hidden_by_tab: None, full_screen_chrome: false, @@ -731,6 +775,46 @@ impl UiState { mod tests { use super::*; + /// `toolbar_side` defaults to `Start`, serializes as a string and reads old prefs without it. + #[test] + fn toolbar_side_defaults_to_start_and_round_trips() { + assert_eq!(ToolbarSide::default(), ToolbarSide::Start); + assert_eq!(Panels::default().toolbar_side, ToolbarSide::Start); + let value = serde_json::to_value(Panels::default()).unwrap(); + assert_eq!(value["toolbar_side"], "start"); + assert_eq!(serde_json::from_value::(value).unwrap(), Panels::default()); + + let right = Panels { toolbar_side: ToolbarSide::Right, ..Default::default() }; + let value = serde_json::to_value(&right).unwrap(); + assert_eq!(value["toolbar_side"], "right"); + assert_eq!(serde_json::from_value::(value).unwrap(), right); + + let mut old = serde_json::to_value(Panels::default()).unwrap(); + old.as_object_mut().unwrap().remove("toolbar_side"); + assert_eq!(serde_json::from_value::(old).unwrap().toolbar_side, ToolbarSide::Start); + + assert_eq!(ToolbarSide::Left.other(), ToolbarSide::Right); + assert_eq!(ToolbarSide::Right.other(), ToolbarSide::Left); + } + + /// An unknown or malformed `toolbar_side` reads as `Start` and keeps every other panel flag. + #[test] + fn unknown_toolbar_side_falls_back_without_dropping_panels() { + let saved = Panels { layers: false, toolbar_double: true, toolbar_side: ToolbarSide::Left, ..Default::default() }; + for bad in + [serde_json::json!("diagonal"), serde_json::json!(5), serde_json::json!(null), serde_json::json!("RIGHT"), serde_json::json!({"side": "left"})] + { + let mut value = serde_json::to_value(&saved).unwrap(); + value["toolbar_side"] = bad.clone(); + let read = serde_json::from_value::(value).unwrap_or_else(|e| panic!("{bad} must not fail the panels: {e}")); + assert_eq!(read, Panels { toolbar_side: ToolbarSide::Start, ..saved.clone() }, "{bad}"); + } + for side in [ToolbarSide::Start, ToolbarSide::Left, ToolbarSide::Right] { + let panels = Panels { toolbar_side: side, ..saved.clone() }; + assert_eq!(serde_json::from_value::(serde_json::to_value(&panels).unwrap()).unwrap(), panels); + } + } + #[test] fn sample_all_layers_is_per_tool_and_old_prefs_load() { // The single shared flag of older preferences is dropped: every tool starts off (#5524). diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8f1260ad9d..98a344f148 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -47,7 +47,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `engine.execute {command, params}`: run any engine or UI command by id. Engine commands run directly with their default params and never open a dialog. Use `ui.menu.invoke` for menu-click behaviour, which opens a command's dialog when no params are given. `params` must be a JSON object (omit it or pass `null` for none); an array, string, number or boolean is an error naming the command, in every transport (control channel, `serve`, MCP and the CLI) - `engine.commands`: list commands with enablement; the `result` is a JSON array of command objects (see [Command list replies](#command-list-replies)) - `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size). The menu tree is not included; use `ui.menu.list`. `brushPicker` is `null` while closed and `{pos: [x, y], size: [w, h]?, list: {collapsed, filter, renaming, showName, showStroke, showTip, scale}}` while the Brush Preset picker is open (coordinates in screen points; `size` is the picker's content size once its corner grip was dragged, `null` before that, and the drag remembers both dimensions; the three `show*` flags pick the parts of each preset's card — at least one is always on; `scale` is the footer slider, 0.15–2 with 1 the standard, multiplying the cards' width and height — the heights stop at half size and the name and tip-size text hold their 1.0 size — and dropping the tips' size numbers at 0.40 and below; the parts and the scale are remembered across restarts, while the search, collapsed groups and a rename in progress are not). `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options. `perf.timings.gpuInfo` holds the graphics adapter, backend, driver, the backend chosen at launch and why, the canvas renderer (`gpu`/`cpu`) and, after a device loss, `lost` (`help.systemInfo` returns the same as `info`). `perf.renderPath` says why the active document is drawn on the CPU: `current` is `null` on the GPU, else `{doc, kind, detail}` (`kind` is one of `overBudget`, `canvasTooLarge`, `compositorBuild`, `deviceLost`, `gpuError`, `unsupported`, `adjustPreview`, `viewTransform`, `softwareAdapter`, `cpuMode`; `detail` is the English reason, with the working set and budget for `overBudget`), and `history` holds the last 32 changes, oldest first (`{t, doc, kind, detail}`, `kind` `null` for back on the GPU, `doc` the changed document's id, or 0 for a change to every document). An adjustment dialog's CPU proxy preview has its own slot: `current` reports it (`adjustPreview`) only when neither the window nor the canvas has a reason, it isn't recorded while the whole window is off the GPU, and its history entries always have `kind` `adjustPreview`, with `detail` `null` when the preview stops using the proxy (never read as back on the GPU). `perf.timings.gpuFallback` is `current`'s `detail` as text. `perf.slowFrames` lists the last 64 frames over 50 ms, oldest first, each with its stages in ms: `t` (start), `frameMs`, `inputMs` (raw input to the UI build, commands excluded), `commandMs` and `command` (the last command run since the previous frame), `damageMs` (damage tracking and GPU memory budgeting), `compositeMs`, `uploadMs`, `gpuMs` (the GPU's own composite time, or `null`), `presentMs` (the previous frame's tessellation and submission, or `null`), `refresh` and `refreshPx` (the canvas redraw's kind and pixels) and `path` (`gpu` or `cpu`). The web build keeps them too (its clock is `performance.now()`) and logs them to the browser console -- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, filterMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerName?, brushPickerStroke?, brushPickerTip?, brushPickerScale?, gradientBlendMode?, gradientClassic?, shapeStroke?}`: change UI state; any other field is an error, checked before anything changes (`zoom` is the canvas zoom where 1 = 100%, one document pixel per physical display pixel, so a scaled display doesn't magnify the image; `theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` is `list` or `grid` for the Brushes panel; `brushPickerName`, `brushPickerStroke` and `brushPickerTip` are the Brush Preset picker's card parts (booleans, at least one on), and `brushPickerScale` its footer card-scale slider (0.15 to 2, 1 the standard); `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"], tabOrder: {"layers": ["Paths", "Layers", "Channels"]}}`, the right-dock groups top to bottom, their heights in points, the groups collapsed to their tab strip and each group's tab order left to right by panel name (a group left out keeps its default order, unknown names are ignored and tabs not listed follow in their default order; `dockTabs` keeps indexing tabs in their default order); `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background?, mode?, ramp?, webSafeRamp?}`, merged into the Color panel's state: `background` is whether it edits the background colour, `mode` its panel-menu display (`hueCube`, `brightnessCube`, `colorWheel`, `grayscaleSlider`, `rgbSliders`, `hsbSliders`, `cmykSliders`, `labSliders` or `webColorSliders`), `ramp` the colour ramp under the sliders (`rgbSpectrum`, `cmykSpectrum`, `grayscaleRamp` or `currentColors`) and `webSafeRamp` Make Ramp Web Safe; `maskTarget` targets the active layer's mask, which, like Photoshop, swaps in the foreground/background pair kept for masks (white/black until one is picked) and swaps the pixels' pair back when the pixels are targeted again; `filterMaskTarget` targets the active smart object's filter mask (an error without smart filters), so painting, fills, filters and adjustments edit it (`"target":"filterMask"`) while Move and Edit › Transform still act on the smart object) +- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, filterMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerName?, brushPickerStroke?, brushPickerTip?, brushPickerScale?, gradientBlendMode?, gradientClassic?, shapeStroke?}`: change UI state; any other field is an error, checked before anything changes (`zoom` is the canvas zoom where 1 = 100%, one document pixel per physical display pixel, so a scaled display doesn't magnify the image; `theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` is `list` or `grid` for the Brushes panel; `brushPickerName`, `brushPickerStroke` and `brushPickerTip` are the Brush Preset picker's card parts (booleans, at least one on), and `brushPickerScale` its footer card-scale slider (0.15 to 2, 1 the standard); `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"], tabOrder: {"layers": ["Paths", "Layers", "Channels"]}}`, the right-dock groups top to bottom, their heights in points, the groups collapsed to their tab strip and each group's tab order left to right by panel name (a group left out keeps its default order, unknown names are ignored and tabs not listed follow in their default order; `dockTabs` keeps indexing tabs in their default order); `dockWidth` sets the whole end dock's width in points, requested within 250..max(520, window width − 400): the change from the current width is shared equally among the columns, within their minimums and iconic caps (a column that reaches one passes the rest to the others), so those determine the achieved model width; `dockWidth` is the requested model width, and in a window too narrow for it, in either layout, the drawn dock may be narrower, clipped to its panel so the toolbar and the canvas's 200 points stay clear, and it is drawn at the stored width again when the window grows; `colorPanel` is `{background?, mode?, ramp?, webSafeRamp?}`, merged into the Color panel's state: `background` is whether it edits the background colour, `mode` its panel-menu display (`hueCube`, `brightnessCube`, `colorWheel`, `grayscaleSlider`, `rgbSliders`, `hsbSliders`, `cmykSliders`, `labSliders` or `webColorSliders`), `ramp` the colour ramp under the sliders (`rgbSpectrum`, `cmykSpectrum`, `grayscaleRamp` or `currentColors`) and `webSafeRamp` Make Ramp Web Safe; `maskTarget` targets the active layer's mask, which, like Photoshop, swaps in the foreground/background pair kept for masks (white/black until one is picked) and swaps the pixels' pair back when the pixels are targeted again; `filterMaskTarget` targets the active smart object's filter mask (an error without smart filters), so painting, fills, filters and adjustments edit it (`"target":"filterMask"`) while Move and Edit › Transform still act on the smart object) - `typeTransform` is `null` outside a temporary Ctrl/Cmd Type-tool drag. During a drag it reports its source document/revision, original affine, oriented `frame` (layer, local bounds, document-space corners and pivot), and captured `gesture`. Drive it with `ui.pointer` and `command: true` at `down`; `move`/`up` may release the modifier. The document/history remain unchanged during preview, and `up` applies one `type.edit` within `textEdit`'s session. Pressing Escape during the drag cancels only the preview. This pointer state is never restored from saved UI state. - `ui.menu.invoke {id, params?, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once. Window ends with the open documents as `window.document.` (tab order, the active one checked, #2991); invoking one brings that document to the front and replies `{document: index}`. Camera Raw and the full-window workspaces (Liquify, Content-Aware Fill, Filter Gallery, Adaptive Wide Angle) are modal: while one is open, `ui.menu.invoke` refuses every other menu item ("menu command is unavailable while a modal dialog is open") and accepts only that workspace's own `{"ui": {...}}` requests (`filter.liquify`, `edit.contentAwareFill`, `filter.filterGallery`, `filter.adaptiveWideAngle`, `filter.cameraRaw`; `params` must hold only the `ui` object), so finish it with `{"ui":{"commit":true}}` or `{"ui":{"cancel":true}}` first. Those requests are refused too while a dialog, an unsaved-changes prompt or a crop that blocks the command is open: resolve that first, then commit or cancel the workspace. `ui.menu.list` reports the same gate: an item the modal state refuses is listed with `enabled: false`. `engine.execute` is not a menu click and is not gated - `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar. @@ -763,7 +763,9 @@ such as a bare layout with `dockingHidden`, loads as the default layout, built f saved `panels`, `dockTabs` and `dock`. The existing `ui.set` panel visibility, `dockTabs` selection and `dockWidth` controls -remain available after customization. Width keeps its 250–520 point clamp. Named +remain available after customization. Width requests use the window-dependent range +250..max(520, window width − 400), with achieved widths subject to column minimums +and iconic caps; constrained dock-edge rendering still reserves canvas space. Named `dock.hidden_tabs` and intact native-group collapse settings are also preserved. A legacy `dock` replacement that changes flat group order or heights is rejected after customization: use `window.panel.layout` to change a custom split tree. A replacement