mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-13 21:52:54 +00:00
Fix dock-edge resize bounds and cancellation
This commit is contained in:
1 parent
e15347b781
commit
d8970452fd
5 files changed
+467
-129
No files matched your search
@@ -729,7 +729,7 @@ impl PanelSet {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ pub fn dock_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
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 {
|
||||
@@ -70,7 +71,7 @@ pub fn dock_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
return;
|
||||
}
|
||||
let iconic = metrics.iconic_only;
|
||||
let natural_min = if iconic { crate::panel_docking::ICONIC_WIDTH } else { metrics.minimum.max(min_dock) };
|
||||
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);
|
||||
@@ -98,6 +99,7 @@ pub fn dock_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
};
|
||||
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 };
|
||||
@@ -114,7 +116,7 @@ pub fn dock_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
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).clamp(lower, upper) };
|
||||
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) {
|
||||
@@ -123,78 +125,76 @@ pub fn dock_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
panel_w = (actual + rw).clamp(lower, upper);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
ui.ctx().data_mut(|data| data.insert_temp(egui::Id::unique("photo-dock-panel-width"), panel_w));
|
||||
if drag.is_some() && resizable {
|
||||
crate::panel_docking::set_end_dock_total(app, (panel_w - rw).clamp(min_visible, max_dock));
|
||||
}
|
||||
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 _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));
|
||||
}
|
||||
},
|
||||
);
|
||||
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));
|
||||
}
|
||||
},
|
||||
);
|
||||
#[cfg(test)]
|
||||
ui.ctx().data_mut(|data| data.insert_temp(egui::Id::unique("photo-dock-panel-rect"), _panel.response.rect));
|
||||
crate::dock::persist(app, ui.ctx());
|
||||
crate::color_panel_ui::persist(app);
|
||||
}
|
||||
@@ -214,7 +214,13 @@ pub(crate) fn clear_dock_edge_drag(ctx: &egui::Context) {
|
||||
/// narrow width and return the rest to the canvas).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn dock_panel_width(ctx: &egui::Context) -> f32 {
|
||||
ctx.data(|data| data.get_temp::<f32>(egui::Id::unique("photo-dock-panel-width")).unwrap_or(0.0))
|
||||
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
|
||||
@@ -289,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));
|
||||
|
||||
@@ -206,7 +206,8 @@ fn scale_columns(columns: &mut [Column<String>], total: f32) -> f32 {
|
||||
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();
|
||||
for _ in 0..8 {
|
||||
// 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;
|
||||
@@ -759,6 +760,21 @@ fn canvas_alone_layout(layout: &Layout<String>) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply an external width request before the dock-edge panel measures its allocation.
|
||||
/// Standard rendering consumes the same queue. Native PanelState migration stays in `show`,
|
||||
/// after this request and 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;
|
||||
}
|
||||
ctx.data_mut(|data| data.remove::<f32>(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) {
|
||||
@@ -777,10 +793,7 @@ 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(),
|
||||
@@ -984,7 +997,8 @@ 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, keeping its other columns' widths.
|
||||
/// `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) {
|
||||
@@ -1000,7 +1014,8 @@ pub(crate) fn set_dock_width(app: &mut PhotocraftApp, width: f32) {
|
||||
// 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();
|
||||
if current >= columns_minimum(columns) && (current - width).abs() < 0.5 {
|
||||
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);
|
||||
@@ -1016,15 +1031,21 @@ pub(crate) fn set_dock_width(app: &mut PhotocraftApp, width: f32) {
|
||||
commit(app, workspace, returns);
|
||||
}
|
||||
|
||||
/// Set the end dock's total width in place, keeping its columns' proportions and the horizontal
|
||||
/// split that holds the dock at its edge in step. Creates the custom workspace from the legacy
|
||||
/// dock the first time. Returns the resulting displayed width, or `None` when there is no end dock.
|
||||
/// 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()?;
|
||||
|
||||
@@ -1607,19 +1607,16 @@ fn dock_panel_draws_the_actual_minimum_during_and_after_a_drag() {
|
||||
app.ui.docking = Some(Workspace::new(layout));
|
||||
let mut h = dock_edge_harness(app, vec2(1280.0, 800.0));
|
||||
let rail = if h.state().ui.panels.rail { 36.0 } else { 0.0 };
|
||||
let edge = 1280.0 - (dock_width(h.state()) + rail);
|
||||
h.event(Event::PointerMoved(pos2(edge, 400.0)));
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: pos2(edge, 400.0), button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerMoved(pos2(edge + 240.0, 400.0)));
|
||||
let start = begin_outer_resize(&mut h);
|
||||
let edge = start.x;
|
||||
h.event(Event::PointerMoved(pos2(edge + 240.0, start.y)));
|
||||
h.run_steps(2);
|
||||
let during = dock_width(h.state());
|
||||
let drawn = crate::panels::dock_panel_width(&h.ctx);
|
||||
assert!((during - 440.0).abs() < 1.0, "the model stops at the 440-point minimum, got {during}");
|
||||
assert!((drawn - (during + rail)).abs() < 2.0, "the panel follows the stored width, got {drawn} for {during}+{rail}");
|
||||
h.event(Event::PointerButton { pos: pos2(edge + 240.0, 400.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
h.event(Event::PointerButton { pos: pos2(edge + 240.0, start.y), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
let after = dock_width(h.state());
|
||||
assert!((after - during).abs() < 1.0, "the release keeps the model width, got {after} then {during}");
|
||||
let drawn = crate::panels::dock_panel_width(&h.ctx);
|
||||
@@ -1629,37 +1626,32 @@ fn dock_panel_draws_the_actual_minimum_during_and_after_a_drag() {
|
||||
/// A workspace replacement during a drag invalidates the stored gesture before it can write.
|
||||
#[test]
|
||||
fn replacing_the_workspace_cancels_the_dock_drag() {
|
||||
let mut app = app();
|
||||
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers"})).unwrap();
|
||||
let mut h = dock_edge_harness(app, vec2(1280.0, 800.0));
|
||||
let rail = if h.state().ui.panels.rail { 36.0 } else { 0.0 };
|
||||
let edge = 1280.0 - (dock_width(h.state()) + rail);
|
||||
h.event(Event::PointerMoved(pos2(edge, 400.0)));
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: pos2(edge, 400.0), button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerMoved(pos2(edge + 120.0, 400.0)));
|
||||
h.run_steps(2);
|
||||
assert!(crate::panels::dock_edge_drag_active(&h.ctx), "the drag started");
|
||||
|
||||
let mut replacement = legacy(h.state());
|
||||
{
|
||||
let root = replacement.root.as_mut().expect("root");
|
||||
let dock = match root {
|
||||
Node::Split { second, .. } => second.as_mut(),
|
||||
_ => root,
|
||||
};
|
||||
let Node::Dock { columns, .. } = dock else { panic!("the end dock is missing") };
|
||||
columns[0].width = 520.0;
|
||||
for release_first in [false, true] {
|
||||
let mut app = app();
|
||||
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers"})).unwrap();
|
||||
let mut h = dock_edge_harness(app, vec2(1280.0, 800.0));
|
||||
let start = begin_outer_resize(&mut h);
|
||||
h.event(Event::PointerMoved(start + vec2(20.0, 0.0)));
|
||||
h.run_steps(1);
|
||||
let mut replacement = legacy(h.state());
|
||||
dock_columns_mut(replacement.root.as_mut().expect("root")).expect("end dock")[0].width = 520.0;
|
||||
let generation = h.state().ui.docking_generation;
|
||||
crate::dock::apply(h.state_mut(), &json!({"docking": Workspace::new(replacement)}));
|
||||
assert_eq!(h.state().ui.docking_generation, generation.wrapping_add(1));
|
||||
assert!((dock_width(h.state()) - 520.0).abs() < 0.5, "the installed baseline is known before any frame");
|
||||
if release_first {
|
||||
release_outer_resize(&mut h, start + vec2(100.0, 0.0));
|
||||
} else {
|
||||
h.run_steps(1);
|
||||
}
|
||||
assert!(!crate::panels::dock_edge_drag_active(&h.ctx), "the first frame clears the stale gesture");
|
||||
assert!((dock_width(h.state()) - 520.0).abs() < 0.5, "the first frame must not overwrite the replacement");
|
||||
if !release_first {
|
||||
release_outer_resize(&mut h, start + vec2(100.0, 0.0));
|
||||
}
|
||||
h.run_steps(2);
|
||||
assert!((dock_width(h.state()) - 520.0).abs() < 0.5, "release and idle retain the installed width");
|
||||
}
|
||||
h.state_mut().ui.docking = Some(Workspace::new(replacement));
|
||||
h.state_mut().ui.docking_generation += 1;
|
||||
h.run_steps(2);
|
||||
assert!(!crate::panels::dock_edge_drag_active(&h.ctx), "the old workspace drag is cleared");
|
||||
let target = dock_width(h.state());
|
||||
h.event(Event::PointerButton { pos: pos2(edge + 120.0, 400.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
assert!((dock_width(h.state()) - target).abs() < 1.0, "the release does not overwrite the new workspace: got {} from {target}", dock_width(h.state()));
|
||||
}
|
||||
|
||||
/// A window too narrow for the column minima does not panic while resizing and keeps the panel
|
||||
@@ -1683,9 +1675,11 @@ fn constrained_width_does_not_panic_while_resizing() {
|
||||
let mut h = dock_edge_harness(app, vec2(300.0, 600.0));
|
||||
let drawn = crate::panels::dock_panel_width(&h.ctx);
|
||||
assert!(drawn <= 80.0, "the dock stays within the narrow window, got {drawn}");
|
||||
let edge = 300.0 - drawn;
|
||||
h.event(Event::PointerMoved(pos2(edge, 300.0)));
|
||||
h.event(Event::PointerButton { pos: pos2(edge, 300.0), button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
let start = outer_handle(&h).center();
|
||||
let edge = start.x;
|
||||
h.event(Event::PointerMoved(start));
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: start, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerMoved(pos2(edge + 40.0, 300.0)));
|
||||
h.run_steps(2);
|
||||
@@ -1693,3 +1687,316 @@ fn constrained_width_does_not_panic_while_resizing() {
|
||||
h.run_steps(3);
|
||||
assert!(crate::panels::dock_panel_width(&h.ctx) <= 80.0, "the panel stays within the narrow window after release");
|
||||
}
|
||||
|
||||
fn outer_handle(h: &Harness<'static, PhotocraftApp>) -> egui::Rect {
|
||||
h.ctx.read_response(egui::Id::unique("dock-edge-handle")).expect("the rendered resize handle").rect
|
||||
}
|
||||
|
||||
fn allocated_dock(h: &Harness<'static, PhotocraftApp>) -> egui::Rect {
|
||||
h.ctx.data(|data| data.get_temp::<egui::Rect>(egui::Id::unique("photo-dock-panel-rect")).expect("the allocated dock rectangle"))
|
||||
}
|
||||
|
||||
fn begin_outer_resize(h: &mut Harness<'static, PhotocraftApp>) -> Pos2 {
|
||||
let start = outer_handle(h).center();
|
||||
h.event(Event::PointerMoved(start));
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: start, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
assert!(crate::panels::dock_edge_drag_active(&h.ctx), "Sense::drag starts on the press frame");
|
||||
start
|
||||
}
|
||||
|
||||
fn release_outer_resize(h: &mut Harness<'static, PhotocraftApp>, pos: Pos2) {
|
||||
// No preceding PointerMoved: the release event alone supplies the final position.
|
||||
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
}
|
||||
|
||||
fn app_with_columns(widths: &[f32], iconic: bool) -> PhotocraftApp {
|
||||
assert!(widths.len() <= PANELS.len());
|
||||
let columns = widths
|
||||
.iter()
|
||||
.zip(PANELS.iter())
|
||||
.enumerate()
|
||||
.map(|(index, (width, (id, _, _)))| {
|
||||
let mut column = Column::new(vec![craft_ui::docking::Group::new(vec![(*id).to_owned()], 0)], *width);
|
||||
if iconic {
|
||||
column.iconic = true;
|
||||
column.iconic_width = Some(*width);
|
||||
column.width = 290.0 + index as f32;
|
||||
}
|
||||
column
|
||||
})
|
||||
.collect();
|
||||
let root = with_canvas(Node::Dock { side: Side::End, columns }, widths.iter().sum::<f32>().min(520.0));
|
||||
let mut app = app();
|
||||
app.ui.docking = Some(Workspace::new(Layout { root: Some(root), floating: Vec::new() }));
|
||||
assert!(valid_workspace(app.ui.docking.as_ref().unwrap()), "fixtures must be accepted saved workspaces");
|
||||
app
|
||||
}
|
||||
|
||||
fn end_columns(app: &PhotocraftApp) -> &[Column<String>] {
|
||||
let Some(Node::Dock { columns, .. }) = lay(app).root.as_ref().and_then(end_dock_node) else { panic!("the end dock is missing") };
|
||||
columns
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_total_requests_normalize_each_populated_column() {
|
||||
let mut app = app_with_columns(&[290.0, 180.0], false);
|
||||
set_dock_width(&mut app, 470.0);
|
||||
assert!((dock_width(&app) - 470.0).abs() < 0.5);
|
||||
assert!(end_columns(&app).iter().all(|column| column.width >= MIN_COLUMN_WIDTH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redistribution_converges_with_more_than_eight_saturating_columns() {
|
||||
let mut app = app_with_columns(&[221.0, 800221.0, 889109.75, 900220.75, 901807.9, 902072.25, 902124.94, 902137.9, 902141.9, 902242.4], false);
|
||||
set_dock_width(&mut app, 2300.5);
|
||||
assert!((dock_width(&app) - 2300.5).abs() < 0.5, "attainable target, got {}", dock_width(&app));
|
||||
assert!(end_columns(&app).iter().all(|column| column.width >= MIN_COLUMN_WIDTH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_iconic_caps_redistribute_both_existing_and_new_saturation() {
|
||||
for iconic_width in [100.0, ICONIC_MAX_WIDTH] {
|
||||
let mut app = app_with_columns(&[290.0, 220.0], false);
|
||||
let first = &mut dock_columns_mut(app.ui.docking.as_mut().unwrap().layout.root.as_mut().unwrap()).unwrap()[0];
|
||||
first.iconic = true;
|
||||
first.iconic_width = Some(iconic_width);
|
||||
assert!((set_end_dock_total(&mut app, 800.0).unwrap() - 800.0).abs() < 0.5);
|
||||
assert_eq!(end_columns(&app)[0].iconic_width, Some(ICONIC_MAX_WIDTH));
|
||||
assert_eq!(end_columns(&app)[0].width, 290.0, "iconic resizing preserves expanded width");
|
||||
assert!((end_columns(&app)[1].width - 560.0).abs() < 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_iconic_totals_stop_at_both_bounds_without_losing_expanded_widths() {
|
||||
let mut app = app_with_columns(&[100.0, 200.0], true);
|
||||
let expanded: Vec<_> = end_columns(&app).iter().map(|column| column.width).collect();
|
||||
for (requested, expected) in [(1000.0, 480.0), (1.0, 72.0), (300.0, 300.0)] {
|
||||
assert!((set_end_dock_total(&mut app, requested).unwrap() - expected).abs() < 0.5);
|
||||
for (column, expanded) in end_columns(&app).iter().zip(&expanded) {
|
||||
assert!((ICONIC_WIDTH..=ICONIC_MAX_WIDTH).contains(&display_width(column)));
|
||||
assert_eq!(column.width, *expanded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_dock_setter_reports_legacy_noop_and_rejects_without_mutation() {
|
||||
let mut app = app();
|
||||
let current = dock_width(&app);
|
||||
assert_eq!(set_end_dock_total(&mut app, current), Some(current));
|
||||
assert!(app.ui.docking.is_none(), "same-width legacy requests must not customize");
|
||||
for rejected in [0.0, -1.0, f32::NAN, f32::INFINITY] {
|
||||
assert_eq!(set_end_dock_total(&mut app, rejected), None);
|
||||
assert!(app.ui.docking.is_none());
|
||||
}
|
||||
app.session.prefs.edit(|prefs| prefs.workspace_locked = true);
|
||||
assert_eq!(set_end_dock_total(&mut app, current + 100.0), None);
|
||||
assert!(app.ui.docking.is_none());
|
||||
app.session.prefs.edit(|prefs| prefs.workspace_locked = false);
|
||||
assert_eq!(set_end_dock_total(&mut app, 400.0), Some(400.0));
|
||||
assert!((dock_width(&app) - 400.0).abs() < 0.5);
|
||||
let mut empty = app_with_columns(&[], false);
|
||||
let before = empty.ui.docking.clone();
|
||||
assert_eq!(set_end_dock_total(&mut empty, 400.0), None);
|
||||
assert_eq!(empty.ui.docking, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_idle_expanded_dock_uses_its_stored_width_not_the_gesture_minimum() {
|
||||
let mut h = dock_edge_harness(app_with_columns(&[220.0], false), vec2(1280.0, 800.0));
|
||||
let rail = if h.state().ui.panels.rail { 36.0 } else { 0.0 };
|
||||
assert!((allocated_dock(&h).width() - 220.0 - rail).abs() < 1.0);
|
||||
assert_eq!(dock_width(h.state()), 220.0);
|
||||
let start = begin_outer_resize(&mut h);
|
||||
assert_eq!(dock_width(h.state()), 220.0, "the press records the existing idle width");
|
||||
release_outer_resize(&mut h, start);
|
||||
assert_eq!(dock_width(h.state()), 250.0, "even an unmoved gesture applies the resize minimum");
|
||||
assert!((allocated_dock(&h).width() - 250.0 - rail).abs() < 1.0);
|
||||
h.run_steps(2);
|
||||
assert_eq!(dock_width(h.state()), 250.0, "idle keeps the normalized gesture result");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_only_position_updates_model_and_actual_geometry_on_that_frame() {
|
||||
for dir in [crate::direction::Dir::Ltr, crate::direction::Dir::Rtl] {
|
||||
let _direction = crate::direction::direction_scope(dir);
|
||||
for theme in [crate::theme::ThemeKind::ProMedium, crate::theme::ThemeKind::Classic] {
|
||||
for rail in [false, true] {
|
||||
let mut app = app_with_columns(&[400.0], false);
|
||||
app.ui.panels.rail = rail;
|
||||
app.ui.theme = theme;
|
||||
let rw = if rail { if theme.is_pro() { 36.0 } else { 44.0 } } else { 0.0 };
|
||||
let mut h = dock_panel_harness(app, vec2(1280.0, 800.0), theme);
|
||||
let start = begin_outer_resize(&mut h);
|
||||
let sign = if crate::direction::is_rtl() { 1.0 } else { -1.0 };
|
||||
h.event(Event::PointerMoved(start + vec2(sign * 30.0, 0.0)));
|
||||
h.run_steps(1);
|
||||
assert!((dock_width(h.state()) - 430.0).abs() < 0.5);
|
||||
assert!((allocated_dock(&h).width() - 430.0 - rw).abs() < 1.0);
|
||||
release_outer_resize(&mut h, start + vec2(sign * 80.0, 0.0));
|
||||
assert!(!crate::panels::dock_edge_drag_active(&h.ctx));
|
||||
assert!((dock_width(h.state()) - 480.0).abs() < 0.5, "the release-only position is applied immediately");
|
||||
let released = allocated_dock(&h);
|
||||
assert!((released.width() - 480.0 - rw).abs() < 1.0, "the rail is counted once");
|
||||
h.run_steps(2);
|
||||
assert!((allocated_dock(&h).width() - released.width()).abs() < 0.5, "idle does not jump");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_width_requests_supersede_held_and_release_first_gestures() {
|
||||
for release_first in [false, true] {
|
||||
let mut h = dock_edge_harness(app_with_columns(&[400.0], false), vec2(1280.0, 800.0));
|
||||
let start = begin_outer_resize(&mut h);
|
||||
h.event(Event::PointerMoved(start + vec2(30.0, 0.0)));
|
||||
h.run_steps(1);
|
||||
let ctx = h.ctx.clone();
|
||||
assert_eq!(control_set(h.state_mut(), &ctx, json!({"dockWidth":520.0}))["ok"], true);
|
||||
assert!(!crate::panels::dock_edge_drag_active(&h.ctx), "the external request invalidates the gesture before another frame");
|
||||
if release_first {
|
||||
release_outer_resize(&mut h, start + vec2(100.0, 0.0));
|
||||
} else {
|
||||
h.run_steps(1);
|
||||
}
|
||||
assert!((dock_width(h.state()) - 520.0).abs() < 0.5, "the request is installed on its first frame");
|
||||
let rail = if h.state().ui.panels.rail { 36.0 } else { 0.0 };
|
||||
assert!(
|
||||
(allocated_dock(&h).width() - 520.0 - rail).abs() < 1.0,
|
||||
"release_first={release_first}: the FIRST processing frame allocates the requested model width plus one rail, got {:?}",
|
||||
allocated_dock(&h),
|
||||
);
|
||||
if !release_first {
|
||||
h.event(Event::PointerMoved(start + vec2(100.0, 0.0)));
|
||||
h.run_steps(1);
|
||||
assert!((dock_width(h.state()) - 520.0).abs() < 0.5, "later held movement cannot restore the old width");
|
||||
release_outer_resize(&mut h, start + vec2(140.0, 0.0));
|
||||
}
|
||||
h.run_steps(2);
|
||||
assert!((dock_width(h.state()) - 520.0).abs() < 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_iconic_resize_is_cancelled_when_the_window_narrows() {
|
||||
for release_first in [false, true] {
|
||||
let mut h = dock_edge_harness(app_with_columns(&[100.0], true), vec2(1280.0, 800.0));
|
||||
let start = begin_outer_resize(&mut h);
|
||||
h.event(Event::PointerMoved(start - vec2(40.0, 0.0)));
|
||||
h.run_steps(1);
|
||||
let before = dock_width(h.state());
|
||||
let expanded = end_columns(h.state())[0].width;
|
||||
assert!((before - 140.0).abs() < 0.5);
|
||||
h.set_size(vec2(300.0, 600.0));
|
||||
if release_first {
|
||||
release_outer_resize(&mut h, start + vec2(100.0, 0.0));
|
||||
} else {
|
||||
h.event(Event::PointerMoved(start + vec2(100.0, 0.0)));
|
||||
h.run_steps(1);
|
||||
}
|
||||
assert!(!crate::panels::dock_edge_drag_active(&h.ctx));
|
||||
assert_eq!(dock_width(h.state()), before, "narrowing cannot commit the stale pointer width");
|
||||
assert!(allocated_dock(&h).width() <= 44.5, "the allocated panel remains bounded");
|
||||
if !release_first {
|
||||
release_outer_resize(&mut h, start + vec2(140.0, 0.0));
|
||||
}
|
||||
h.set_size(vec2(1280.0, 800.0));
|
||||
h.run_steps(2);
|
||||
assert_eq!(dock_width(h.state()), before);
|
||||
assert_eq!(end_columns(h.state())[0].width, expanded);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_iconic_columns_disable_resizing_below_their_combined_minimum() {
|
||||
let mut h = dock_edge_harness(app_with_columns(&[100.0, 100.0], true), vec2(342.0, 600.0));
|
||||
let original = dock_width(h.state());
|
||||
let start = outer_handle(&h).center();
|
||||
h.event(Event::PointerMoved(start));
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: start, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
assert!(!crate::panels::dock_edge_drag_active(&h.ctx), "50 available points cannot fit two 36-point columns");
|
||||
h.event(Event::PointerMoved(start + vec2(100.0, 0.0)));
|
||||
h.run_steps(1);
|
||||
release_outer_resize(&mut h, start + vec2(120.0, 0.0));
|
||||
assert_eq!(dock_width(h.state()), original);
|
||||
h.set_size(vec2(1280.0, 800.0));
|
||||
h.run_steps(2);
|
||||
assert_eq!(dock_width(h.state()), original, "widening retains the pre-constraint iconic widths");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrained_composed_layout_reserves_the_canvas_with_or_without_a_rail() {
|
||||
for dir in [crate::direction::Dir::Ltr, crate::direction::Dir::Rtl] {
|
||||
let _direction = crate::direction::direction_scope(dir);
|
||||
for theme in [crate::theme::ThemeKind::ProMedium, crate::theme::ThemeKind::Classic] {
|
||||
for rail in [false, true] {
|
||||
let mut app = app_with_columns(&[290.0, 220.0], false);
|
||||
app.ui.panels.rail = rail;
|
||||
app.ui.theme = theme;
|
||||
app.ui.panels.toolbar_side = if crate::direction::is_rtl() { crate::state::ToolbarSide::Left } else { crate::state::ToolbarSide::Right };
|
||||
let mut ready = false;
|
||||
let mut h = Harness::builder().with_size(vec2(400.0, 600.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
move |ui, app: &mut PhotocraftApp| {
|
||||
if !ready {
|
||||
PhotocraftApp::setup_context(ui.ctx(), theme);
|
||||
ready = true;
|
||||
return;
|
||||
}
|
||||
crate::panels::dock_panel(app, ui);
|
||||
crate::panels::toolbar(app, ui);
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| {
|
||||
ui.ctx().data_mut(|data| data.insert_temp(egui::Id::unique("constrained-canvas-rect"), ui.max_rect()));
|
||||
});
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(4);
|
||||
let assert_geometry = |h: &Harness<'static, PhotocraftApp>| {
|
||||
let canvas = h.ctx.data(|data| data.get_temp::<egui::Rect>(egui::Id::unique("constrained-canvas-rect")).unwrap());
|
||||
assert!(canvas.width() >= 199.5, "rail={rail}, dir={dir:?}: canvas {canvas:?}, dock {:?}", allocated_dock(h));
|
||||
assert!((dock_width(h.state()) - 510.0).abs() < 0.5, "the constrained display does not corrupt model minima");
|
||||
assert!(allocated_dock(h).width() <= 144.5, "actual allocation, not requested width");
|
||||
};
|
||||
assert_geometry(&h);
|
||||
let start = outer_handle(&h).center();
|
||||
h.event(Event::PointerMoved(start));
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: start, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
assert!(!crate::panels::dock_edge_drag_active(&h.ctx));
|
||||
release_outer_resize(&mut h, start + vec2(60.0, 0.0));
|
||||
assert_geometry(&h);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_external_width_precedes_but_does_not_eagerly_consume_native_migration() {
|
||||
let mut app = app();
|
||||
app.last_canvas_rect = egui::Rect::NOTHING;
|
||||
let mut h = dock_edge_harness(app, vec2(1280.0, 800.0));
|
||||
assert!(!h.state().last_canvas_rect.is_positive(), "the dock-only harness has not laid out a canvas");
|
||||
assert!(h.state().ui.docking.is_none());
|
||||
let key = crate::panels::panel_state_id(&h.ctx, "dock");
|
||||
let state = egui::PanelState { outer_rect: egui::Rect::from_min_size(egui::pos2(900.0, 0.0), vec2(380.0, 700.0)) };
|
||||
h.ctx.data_mut(|data| data.insert_persisted(key, state));
|
||||
h.run_steps(1);
|
||||
assert!(h.state().ui.docking.is_none(), "native migration still waits for canvas readiness");
|
||||
assert!(h.ctx.data_mut(|data| data.get_persisted::<egui::PanelState>(key)).is_some());
|
||||
crate::panels::request_dock_width(&h.ctx, 520.0);
|
||||
h.run_steps(1);
|
||||
assert!((dock_width(h.state()) - 520.0).abs() < 0.5, "the explicit request takes precedence over the old native width");
|
||||
let rail = if h.state().ui.panels.rail { 36.0 } else { 0.0 };
|
||||
assert!((allocated_dock(&h).width() - 520.0 - rail).abs() < 1.0, "first-frame allocation uses that same width");
|
||||
assert!(h.ctx.data_mut(|data| data.get_persisted::<egui::PanelState>(key)).is_none(), "the migrated state is consumed once");
|
||||
h.run_steps(2);
|
||||
assert!((dock_width(h.state()) - 520.0).abs() < 0.5);
|
||||
}
|
||||
@@ -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`). It stays empty on the web, which has no clock
|
||||
- `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 (every column scales, not only the outer one), 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 (every column scales, not only the outer one), requested within 250..max(520, window width − 400); column minimums and iconic caps determine the achieved model width, and constrained dock-edge rendering preserves canvas space; `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