mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-12 21:23:02 +00:00
Add toolbar side toggle (#11193)
* Add a toolbar side toggle and dock-edge resizing
- Let the toolbox sit on the canvas' left or right, persisted in the panel
state and switchable from the toolbar header and preferences.
- In the compact arrangement, stack dock, rail and toolbox on the dock's
physical edge so the toolbox cannot cover the rail.
- Make the dock panel's inner edge the single resize handle: it drives the
panel width and the dock total (split size and column widths) in the same
frame, so the rail and dock content stay bound with no reserved room and
no second handle. The docking layout's own column-edge handle is covered;
the bar is the theme separator at rest and the accent on hover/drag.
- Persist the layout, keep the split size in sync, handle RTL, and test the
toolbar side state.
* Toolbar side: dock-edge only with a lone canvas; test every theme
- Use the between-dock-and-canvas layout only when nothing is docked beside the canvas: that view drops the canvas from craft-ui's split, so with a panel there the standard layout keeps it visible and dockable.
- Add canvas_alone_layout/canvas_alone and a unit test for a lone canvas versus a split canvas side.
- Add a kittest that toggles the toolbar Left/Right across every ThemeKind::ALL entry, asserting the panel lands on the requested physical side.
- No dependency changes: Cargo.toml and Cargo.lock are identical to main, so the repository's exact pins are untouched.
* Toolbar side: keep the canvas a drop target during dock drags
While a panel is being dragged, draw the standard layout even with the toolbar at the dock edge. It keeps the canvas in craft-ui's split, so a panel can still be dropped beside it; after the drop the canvas is no longer alone and the standard layout stays. The between-dock-and-canvas layout resumes for a lone canvas once the drag ends.
* Toolbar side: stable drag layout, unwind-safe dock flag, clippy
- Restore the dock-only thread-local through a drop guard, so an unwinding show does not leave a later standard layout canvas-less.
- Do not switch the dock layout mid-drag: craft-ui records its areas on the press, and changing the shape cancels the drag. The layout stays stable; a panel beside the canvas still selects the standard layout before the drag begins.
- Fix the CI clippy errors: explicit auto-deref in end_dock_node/resize_end_dock and field reassignment in the toolbar_side test.
* Toolbar side: anchor the Edit Toolbar flyout; borrow the canvas layout
- more_slot_flyout takes the resolved ToolbarSide instead of the text direction, so a right-hand toolbar in LTR (and a left-hand toolbar in RTL) anchors the flyout away from the canvas like the tool flyouts.
- canvas_alone borrows the saved layout and treats the native-dock legacy layout as canvas-only, avoiding a per-frame clone and traversal.
* Toolbar side: only a horizontal canvas split uses the dock-edge layout
A saved workspace can put the canvas above another panel in a vertical root split; canvas_alone_layout now requires SplitAxis::Horizontal so that shape keeps the standard layout. The guard test covers the vertical case.
* Toolbar side: document the functions this PR adds
Covers ToolbarSide::resolve/other, canvas_alone_layout, the DockOnlyGuard drop, and the two new tests. Docstring coverage of the PR's own additions is now complete; no behaviour change.
* docs: refresh the scorecard after merging main
The merge left docs/scorecard.md stale against the combined settings table; regenerated with cargo xtask scorecard, which is what the gate's scorecard step checks.
* Toolbar side: honor the workspace lock, empty docks and the end-dock shape
- The dock-edge resize handle is inert while the workspace is locked (hover sense, no highlight) and set_end_dock_total refuses to write then; the drag test covers a locked workspace.
- An empty dock takes only the rail's width instead of a clamped blank column; dock_is_empty reports it and a test empties the dock.
- canvas_alone_layout now requires the second child to be the Dock itself, and end_dock_node/resize_end_dock follow horizontal splits only, so a layout the handle cannot resize keeps the standard layout instead of snapping back.
- Tests: DockOnlyGuard restores the flag on drop and unwinding; the theme test clicks the toolbar's move button instead of setting state; a dock-edge harness drags the edge, checks it persists and that a locked workspace refuses it; axis tests and a two-column ui.set {dockWidth} test.
- The flyout indicator follows the resolved toolbar side, the rail keeps its chrome fill and 4/8 inset with the separator inside it, ATTRIBUTION counts the new icon (116), and docs/control-protocol.md describes dockWidth as the whole end dock.
* Toolbar side: floating windows on empty docks, iconic widths, dockWidth columns
- An empty dock still runs the docking pass on the full remaining area, so Studio's floating Properties window keeps rendering with or without the rail; the dock column itself takes no width. A Studio regression builds an empty dock with a floating Properties group and asserts the pass runs and the window is drawn.
- Dock width now uses each column's displayed width (iconic_width while collapsed to icons), and resizing/scaling keeps iconic widths in step. A collapse-to-icons regression checks a 36-point column not reserving 290.
- ui.set {dockWidth} scales every column to the requested total directly instead of resizing only the first column and then finding the sum already equal. The two-column test also covers a target smaller than the other column's expanded width (250 from [300, 180]) and asserts per-column widths.
* Toolbar side: iconic widths, edge release and modal handling, side persistence
- An iconic-only dock keeps its narrow displayed width instead of being raised to the expanded
minimum, and the dock-edge panel records its laid-out width for a geometry regression.
- The release frame applies its final pointer position before dropping the drag, so the dock keeps
the width the user released at.
- The dock-edge handle is inert (and any stored drag is dropped) while a modal dialog is open.
- set_display_width changes iconic_width while iconic and leaves the columns expanded width for
expanding again; the regression resizes an iconic column and checks the restore.
- Clicking the toolbar side persists the panels snapshot immediately through persist_panels, even
when the dock is not drawn, while Remember Workspace Changes is on.
- The locked-drag test compares against the pre-drag width instead of only settled equality.
* Toolbar side: defer persistence while moving, translate the side labels
- persist_panels defers while the dock persistence pass sees a native window move/resize (the
same native_moved signal), so a click during an OS resize cannot save intermediate geometry;
it requests the usual 100 ms repaint and leaves the dock pass to save when the geometry is
stable.
- The toolbar-side tooltip/accessible labels now pass both literals directly to tl!, and all 34
registered catalogs carry translations for Move Toolbar to the Right/Left.
* Toolbar side: keep in-progress dock geometry out of toolbar saves
- native_moved caches its answer for the frame, so the dock pass and persist_panels see the same
movement result even though only the first call observes the change.
- persist_panels defers while a dock-edge drag is stored, a pointer is down or a native window
moved; it stores a pending flag and requests the usual 100 ms repaint.
- persist_pending runs in the frame tick (before preferences save) and retries the deferred save
once geometry is stable, so the toolbar side is not lost even when the dock was not drawn.
- The dock persistence pass also skips while the dock-edge drag start is stored, covering a
synthetic/accessibility click with no pointer button down.
* Toolbar side: keep a same-width dockWidth request a no-op
The dockWidth rewrite scaled and committed the legacy layout even when the requested width
already matched, so a later `ui.set {dockTabs}` took the customized-workspace path and expanded
the Properties group. Restore the early return for an unchanged displayed width and cover it:
setting the current dock width keeps `app.ui.docking` None.
Fixes the `a_new_adjustment_layer_brings_properties_to_the_front` failure on CI.
* Toolbar side: recheck native bounds, expire stale dock-edge drags
- native_moved keeps movement already seen in the frame but compares the bounds again on every
call, so a later dock save is not fooled by the early persist_pending check caching false
before the docking UI updates a floating window.
- dock_panel records the frame it rendered; persist_pending drops a stored dock-edge drag start
once the panel stopped rendering (Shift+Tab hides the dock mid-drag), so deferred saves resume.
- The same-width dockWidth test also selects a tab in a collapsed group and asserts the tab,
the collapsed state and that no workspace was customized.
* Toolbar side: draw the release width, clear drags when the dock empties
- The release frame draws the panel at the final pointer width just stored, instead of the width
read before the drag ended, so the panel and toolbar match the saved dock width.
- The empty-dock path clears a stored dock-edge drag start, so a dock that empties mid-drag cannot
leave deferred saves blocked.
- Tests: nested DockOnlyGuard (flag found set stays set), and a queued `ui.set {dockWidth}`
through the rendered dock pass checking a two-column dock totals 420 points.
* Toolbar side: cancel the dock-edge drag when resizing is unavailable
- A disabled parent UI (screen picking) never delivers drag_stopped, so dock_panel now clears a
stored dock-edge drag whenever resizing is unavailable: locked, modal, empty dock or disabled
UI. The same condition makes the handle hover-only, so it cannot start a new drag while
disabled.
- Regression: start a resize, disable the UI, release, re-enable and move the pointer; the drag
is cleared, the dock does not jump and persistence can resume.
* Toolbar side: bound the dock-edge layout and enforce column minima
- The dock-edge panel leaves the toolbar and the 200-point canvas minimum room, so a narrow
window shrinks the dock instead of the canvas.
- The empty dock runs the docking pass in a child ui that does not consume the parent layout, so
the toolbar and canvas keep their space.
- Column scaling keeps the 220-point panel minimum: minima are allocated first, the rest is shared
proportionally, and requests below the sum of minima stop there.
- The dock metrics (empty, iconic-only, width) come from one layout snapshot per frame.
- Removed dead empty-dock branches, narrowed the content-edge interceptor to one point either
side, corrected the toolbar target comment, used the drag press origin, and documented the
Side::End assumption.
- Tests: narrow-window canvas minimum, empty dock composed with toolbar and canvas, minimum-aware
two-column widths, and the empty compose regression.
* Toolbar side: bound the dock-edge layout, enforce panel minima, match iconic widths
- The dock-edge panel leaves the toolbar and the 200-point canvas minimum room, so a narrow
window shrinks the dock instead of the canvas.
- The empty dock runs the docking pass in a child ui that does not consume the parent layout, so
the toolbar and canvas keep their space.
- Column scaling allocates each column's 220-point panel minimum first and shares the rest
proportionally; requests below the sum of minima stop there.
- Iconic columns use craft-ui's renderer rule: iconic_width.unwrap_or(36).clamp(36, 240), including
the real collapse case that leaves iconic_width unset.
- Dock metrics come from one layout snapshot per frame; dead empty-dock branches are gone; the
content-edge interceptor is one point either side; the toolbar target comment is corrected and
the Side::End assumption documented.
- Tests: narrow-window canvas minimum, empty dock composed with toolbar/canvas, minimum-aware
two-column widths, collapse via Action::SetIconic leaving iconic_width unset, and the 36..240
clamp. The press-origin change was reverted: Sense::drag starts immediately.
* Toolbar side: distribute refused width, draw actual widths, invalidate stale drags
- scale_columns moves the total toward the request in passes, freezing columns that hit a bound
(iconic 36..240, expanded minimum 220), so width refused by the iconic cap reaches columns that
can still grow.
- set_end_dock_total returns the width actually stored; the dock panel uses that width during the
drag, on release and after, instead of forcing the requested or minimum width.
- The custom drag stores docking_generation; a workspace replacement clears the stale gesture
before any width mutation.
- Clamp bounds are normalized against the available maximum (including iconic columns) and
resizing is disabled when the window cannot fit the column minima, so narrow layouts cannot
panic.
- Tests: iconic-cap surplus redistribution, actual-vs-requested panel geometry during and after a
drag, workspace replacement during a drag, and a 300pt window resize without panic.
* Fix dock-edge resize bounds and cancellation
* Preserve explicit dock width across legacy migration
* Document dock code, dedupe widths, harden toolbar_side load
- Add doc comments to the functions and tests this PR touches (Docstring Coverage).
- Share the toolbar width (toolbar_width/toolbar_columns), the rail widths and
CANVAS_MIN_WIDTH instead of repeating the values; no values change.
- Document dockWidth as the requested model width, which a narrow window may draw
narrower in either layout, and test model 520 / drawn <= 364 / canvas >= 200.
- Document that a dockWidth change is shared equally among columns within their
minimums and iconic caps; tie ICONIC_WIDTH/ICONIC_MAX_WIDTH to craft-ui's
DockStyle defaults (36/240) in a test.
- Replace the DOCK_ONLY thread-local and its guard with show_layout(dock_only).
- Move the test-only probe writes behind panel_docking::record, with no-op stubs
outside tests.
- Read an unknown or malformed saved toolbar_side as Start instead of dropping
the whole panel state; ui.set still refuses it.
* Test visible dock chrome in narrow windows; name panel body minimum
- The narrow-window dockWidth test now checks the docking controls craft-ui drew:
hit_rects are full widget rects (before clipping), so the visible part of each,
intersected with the dock, must stay inside the dock and off the canvas and
the toolbox, with at least one control visible.
- Name the 200-point guard for drawing a panel's body PANEL_BODY_MIN_WIDTH,
separate from CANVAS_MIN_WIDTH; no value change.
* test(ui-egui): assert dock controls are clipped at the panel edge
The narrow-window test intersected each hit rect with the dock and then asserted the
intersection lies inside the dock, which always holds. Check each dock control's clipped
interact_rect against the dock instead, and add a squeezed-column test where a control's
full rect runs past the panel edge, so the clip is actually exercised.
* ci: retrigger pull_request workflows after resolving the merge conflict
No changes. GitHub did not queue Actions for 3652ede, the push that
resolved the conflict with main.
* test(ui-egui): assert saves resume after a disabled UI cancels a dock-edge drag
The test's doc says saves must resume once a disabled UI drops the held drag, but nothing
checked it. Assert that the held drag is not saved, that its width is saved as soon as the
drag is cancelled (while the UI is still disabled), and that a width change made after
re-enabling reaches the saved layout too.
* test(ui-egui): cancel a dock-edge drag on a window shrink and check the dockWidth column split
constrained_width_does_not_panic_while_resizing now starts the drag in a wide window and
shrinks the window below the column minima while it is held: the drag must be cancelled and
the dock must stay within the window. Both ui_set_dock_width tests now check how the change is
split between the columns, not only the total.
* test(ui-egui): tighten dock tests per CodeRabbit review 5824274070
- disabling_the_ui_cancels_a_dock_edge_drag: assert the drag is cancelled on the first disabled
frame, before the pointer is released.
- all_iconic_totals_stop_at_both_bounds_without_losing_expanded_widths: check the stored column
widths sum to the expected total after each request.
- explicit_width_request_preserves_queue_and_native_state_until_invalid_workspace_recovery: add
a changed-width (520) case next to the no-op one.
- the_toolbar_side_toggle_works_in_every_interface_theme: with the toolbar on the right, a grouped
tool's flyout opens to the left of its button.
* test(ui-egui): assert stored widths and native rect per CodeRabbit review 5824824938
- constrained_width_does_not_panic_while_resizing: the stored column widths captured before the
window shrinks are unchanged after the cancel and after the release.
- explicit_width_request_preserves_queue_and_native_state_until_invalid_workspace_recovery: the
native state keeps the exact rectangle the test saved until recovery, not just some state.
* Keep the toolbar side when a workspace saved none
A workspace saved before the toolbar could move has no panels.toolbar_side,
so applying it reset a right-hand toolbar to Start. Keep the current side
when the saved value is missing or unreadable; a valid saved side still wins.
* Cover a saved start side and the other panel flags on workspace apply
---------
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
This commit is contained in:
1 parent
8ba961c11a
commit
7c64653d5c
48 files changed
+2752
-404
No files matched your search
+1
-1
@@ -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) | <https://github.com/rsms/inter> | 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 | <https://github.com/JetBrains/JetBrainsMono> | 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 | <https://github.com/lucide-icons/lucide> (`icons/<name>.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 | <https://github.com/lucide-icons/lucide> (`icons/<name>.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 | <https://github.com/feathericons/feather> 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 |
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M9 3v18" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 284 B |
@@ -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
|
||||
|
||||
@@ -614,6 +614,10 @@ fn crop_fill_param(p: &Value) -> std::result::Result<Option<bool>, String> {
|
||||
/// keeps a pin and `{"pinned": true}` keeps a dragged position (#10890).
|
||||
fn panels_patch(app: &PhotocraftApp, patch: Option<&Value>) -> std::result::Result<Option<Value>, 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::<crate::state::ToolbarSide>(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);
|
||||
}
|
||||
|
||||
@@ -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::<Vec<(u64, [f32; 4])>>(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::<Vec<(u64, [f32; 4])>>(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::<bool>(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::<bool>(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::<u64>(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::<Panels>(p.clone()).ok()) {
|
||||
if let Some(saved) = v.get("panels")
|
||||
&& let Ok(mut p) = serde_json::from_value::<Panels>(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()) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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ı
|
||||
|
Can't render this file because it contains an unexpected character in line 1345 and column 18.
|
@@ -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}
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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}
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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 Μετακίνηση γραμμής εργαλείων αριστερά
|
||||
|
Can't render this file because it contains an unexpected character in line 47 and column 53.
|
@@ -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
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -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}
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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}
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -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
|
||||
|
Can't render this file because it contains an unexpected character in line 79 and column 53.
|
@@ -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 ツールバーを左へ移動
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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}-ში
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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 도구 모음을 왼쪽으로 이동
|
||||
|
Can't render this file because it contains an unexpected character in line 63 and column 53.
|
@@ -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}
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -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
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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}
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -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 Переместить панель инструментов влево
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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}
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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} இல் சேமிக்கப்பட்டன
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 16.
|
@@ -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} แล้ว
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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şı
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -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 Перемістити панель інструментів ліворуч
|
||||
|
Can't render this file because it contains an unexpected character in line 152 and column 53.
|
@@ -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} میں محفوظ ہو گئیں
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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 显影
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 63 and column 53.
|
@@ -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 顯影
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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")),
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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::Rect>(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));
|
||||
|
||||
@@ -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<String>) -> Option<&Node<String>> {
|
||||
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<String>) -> 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<String>) -> 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<String>]) -> 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<String>) -> 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<String>]) -> 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<String>, 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<String>], 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<usize> = 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<String>) -> Option<&mut Vec<Column<String>>> {
|
||||
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<String>) -> Result<Va
|
||||
crate::workspace_ui::menu(app, id, ¶ms).unwrap_or_else(|| Err(format!("`{id}` is not available")))
|
||||
}
|
||||
|
||||
/// Draw only the dock column, for the right-hand toolbox layout: the canvas is drawn separately
|
||||
/// to the toolbox's left.
|
||||
pub(crate) fn show_dock(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
record::show_dock_call(ui.ctx());
|
||||
show_layout(app, ui, true);
|
||||
}
|
||||
|
||||
/// How many times `show_dock` ran in this context (tests: the empty-dock path must still
|
||||
/// render floating windows through it).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn show_dock_calls(ctx: &egui::Context) -> u32 {
|
||||
ctx.data(|data| data.get_temp::<u32>(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<String>) -> Layout<String> {
|
||||
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<String>) -> 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::<f32>(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::<f32>(id);
|
||||
data.remove::<egui::PanelState>(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::<u64>(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::<f32>(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<String> =
|
||||
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<String>, 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<f32> {
|
||||
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<String>, 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::<u32>(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::<Vec<(String, egui::Rect)>>(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<String> 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::<Vec<(String, egui::Rect)>>(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<String> 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));
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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::<bool>(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::<egui::Id>(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<Vec<(usize, Vec<Tool>)>> = 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<Tool>)]> = 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::<bool>(prev_mod_id)).unwrap_or(false);
|
||||
let slot_click = resp.clicked() && ui.data(|d| d.get_temp::<egui::Id>(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::<egui::Id>(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::<egui::Id>(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<Vec<(usize, Vec<Tool>)>> = 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<Tool>)]> = 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::<bool>(prev_mod_id)).unwrap_or(false);
|
||||
let slot_click = resp.clicked() && ui.data(|d| d.get_temp::<egui::Id>(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::<egui::Id>(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::<egui::Id>(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::<egui::Id>(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::<bool>(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::<bool>(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::<bool>(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::<bool>(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)]
|
||||
|
||||
@@ -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<ToolbarSide, D::Error> {
|
||||
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::<Panels>(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::<Panels>(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::<Panels>(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::<Panels>(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::<Panels>(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).
|
||||
|
||||
@@ -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.<index>` (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
|
||||
|
||||
Reference in new issue
Block a user