Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 7278b7dd22 Dock: fixed-height panel groups with splitters, collapse, reorder and a remembered layout
Panel group heights no longer follow their content (#88): switching between a
pixel and an adjustment layer used to grow Properties and squeeze Layers.

- New `dock` module: `DockLayout` (order, heights, collapsed) in `UiState::dock`
  (serde default, settable with `ui.set {dock}`). The last expanded group fills
  the column; content taller than its group scrolls inside it.
- Drag the gap between groups to resize (min heights, clamped to the window);
  double-click a tab or use the panel menu (hamburger, now live) to collapse,
  move a group up/down, or close it; drag a tab strip to reorder (blocked by
  Lock Workspace).
- Layout saved with New Workspace, reset by Reset Workspace (default heights,
  order and tabs), and remembered across launches in `prefs.panelLayout` while
  Remember Workspace Changes is on.
- #129: the icon rail toggled panel visibility, so a stray click made a panel
  vanish. It now shows / expands / collapses a docked group and never hides it.
  Window > <panel> and Window > Color/Gradients/... expand a collapsed group
  instead of hiding it, so the menu always brings a panel back.

Tests: kittests for stable rects across layer kinds (Pro and Studio), splitter
drag + UI-state round trip + prefs persistence, reset/new workspace, double-click
collapse and strip-drag reorder (and lock), tiny windows, rail/Window menu
recovery, and a click-around test over layers, canvas, toolbar and options bar.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 01:36:39 -04:00
14 changed files with 996 additions and 131 deletions

No files matched your search

+4
View File
@@ -722,6 +722,10 @@ pub struct Preferences {
pub workspaces: BTreeMap<String, Value>,
/// Window › Workspace › Lock Workspace: panels can't be moved.
pub workspace_locked: bool,
/// The live panel layout (dock group heights, order, collapsed groups, open panels), saved
/// as the user changes it and restored at launch when Workspace › Remember Workspace
/// Changes is on. JSON owned by the shell.
pub panel_layout: Value,
/// File › Scripts › Script Events Manager: event → script bindings.
pub script_events: crate::automate_cmds::ScriptEvents,
}
+7
View File
@@ -135,6 +135,13 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest
Err(e) => return err(e),
}
}
// Dock group order, heights and collapsed groups (see `dock::DockLayout`).
if let Some(d) = p.get("dock") {
match serde_json::from_value(d.clone()) {
Ok(v) => app.ui.dock = v,
Err(e) => return err(e),
}
}
if let Some(i) = app.session.active_index() {
if let Some(z) = p.get("zoom").and_then(Value::as_f64) {
app.ui.views[i].zoom = (z as f32).clamp(0.01, 64.0);
+473
View File
@@ -0,0 +1,473 @@
//! Right-dock layout (#88): panel groups with fixed heights, like Photoshop's dock columns.
//!
//! A group's height never follows its content: content taller than the group scrolls inside
//! it. The last expanded group (Layers by default) fills what the others leave. Drag the gap
//! between two groups to resize them, double-click a tab (or use the panel menu) to collapse a
//! group to its tab strip, and drag a tab strip to move the group up or down the column
//! (unless Window › Workspace › Lock Workspace is on).
//!
//! The layout is [`DockLayout`] in `UiState::dock` (serialisable, drivable with `ui.set`), saved
//! with Window › Workspace › New Workspace…, reset by Reset Workspace, and remembered across
//! launches in the preferences (`panelLayout`) while Remember Workspace Changes is on.
use std::collections::BTreeMap;
use egui::{Rect, Sense, Stroke, pos2, vec2};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::PhotocraftApp;
use crate::state::{DockTabs, Panels};
use crate::theme::Tokens;
use crate::widgets;
/// A dock panel group (one tab strip).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Group {
/// Color | Swatches | Gradients | Patterns.
Color,
/// Properties | Adjustments.
Properties,
/// Navigator | Histogram | Info.
Navigator,
/// History | Actions | Layer Comps.
History,
/// Layers | Channels | Paths.
Layers,
}
impl Group {
/// Photoshop Essentials order, top to bottom.
pub const ALL: [Group; 5] = [Group::Color, Group::Properties, Group::Navigator, Group::History, Group::Layers];
pub fn key(self) -> &'static str {
match self {
Group::Color => "color",
Group::Properties => "properties",
Group::Navigator => "navigator",
Group::History => "history",
Group::Layers => "layers",
}
}
/// Height (points, tab strip included) a group gets until the user resizes it.
pub fn default_height(self) -> f32 {
match self {
Group::Color => 200.0,
Group::Properties => 340.0,
Group::Navigator => 230.0,
Group::History => 220.0,
Group::Layers => 320.0,
}
}
/// The smallest height an expanded group can be dragged or squeezed to.
pub fn min_height(self) -> f32 {
match self {
Group::Layers => 140.0,
_ => 80.0,
}
}
pub fn tabs(self, pro: bool) -> &'static [&'static str] {
match self {
// Photoshop Essentials: Color | Swatches | Gradients | Patterns.
Group::Color if pro => &["Color", "Swatches", "Gradients", "Patterns"],
Group::Color => &["Swatches", "Color", "Gradients", "Patterns"],
Group::Properties => &["Properties", "Adjustments"],
Group::Navigator => &["Navigator", "Histogram", "Info"],
Group::History => &["History", "Actions", "Layer Comps"],
Group::Layers => &["Layers", "Channels", "Paths"],
}
}
/// Tabs that lay out their own scrolling list and footer (they fill the group).
pub fn scrolls_itself(self, tab: usize) -> bool {
matches!((self, tab), (Group::Layers, 0) | (Group::History, 0))
}
fn tab_mut(self, tabs: &mut DockTabs) -> &mut usize {
match self {
Group::Color => &mut tabs.color,
Group::Properties => &mut tabs.properties,
Group::Navigator => &mut tabs.navigator,
Group::History => &mut tabs.history,
Group::Layers => &mut tabs.layers,
}
}
/// The group for a `panels` / `dockTabs` key ("color", "properties", …).
pub fn from_key(key: &str) -> Option<Group> {
Group::ALL.into_iter().find(|g| g.key() == key)
}
pub fn shown(self, panels: &Panels) -> bool {
match self {
Group::Color => panels.color,
Group::Properties => panels.properties,
Group::Navigator => panels.navigator,
Group::History => panels.history,
Group::Layers => panels.layers,
}
}
fn shown_mut(self, panels: &mut Panels) -> &mut bool {
match self {
Group::Color => &mut panels.color,
Group::Properties => &mut panels.properties,
Group::Navigator => &mut panels.navigator,
Group::History => &mut panels.history,
Group::Layers => &mut panels.layers,
}
}
}
/// Order, heights and collapsed state of the dock groups.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct DockLayout {
/// Top-to-bottom order; groups missing here keep their default place after the listed ones.
pub order: Vec<Group>,
/// Heights the user dragged groups to (points, tab strip included). Unset = default.
pub heights: BTreeMap<Group, f32>,
/// Groups collapsed to their tab strip.
pub collapsed: Vec<Group>,
}
/// Gap between groups; it is also the splitter's grab area.
pub const GAP: f32 = 6.0;
/// Upper bound on a stored height (guards against absurd values from `ui.set`).
const MAX_HEIGHT: f32 = 4000.0;
impl DockLayout {
/// Every group once, in display order.
pub fn order(&self) -> Vec<Group> {
let mut out: Vec<Group> = Vec::with_capacity(Group::ALL.len());
for g in self.order.iter().chain(Group::ALL.iter()) {
if !out.contains(g) {
out.push(*g);
}
}
out
}
pub fn is_collapsed(&self, g: Group) -> bool {
self.collapsed.contains(&g)
}
pub fn set_collapsed(&mut self, g: Group, on: bool) {
self.collapsed.retain(|c| *c != g);
if on {
self.collapsed.push(g);
}
}
/// The stored (or default) height, sanitised.
pub fn height(&self, g: Group) -> f32 {
match self.heights.get(&g) {
Some(h) if h.is_finite() => h.clamp(g.min_height(), MAX_HEIGHT),
_ => g.default_height(),
}
}
/// Move `g` so it is drawn just before `before` (or last when `None`).
pub fn move_group(&mut self, g: Group, before: Option<Group>) {
if before == Some(g) {
return;
}
let mut order = self.order();
order.retain(|x| *x != g);
let at = before.and_then(|b| order.iter().position(|x| *x == b)).unwrap_or(order.len());
order.insert(at, g);
self.order = order;
}
/// Lay out the `shown` groups (in display order) in a column `avail` points tall with
/// `strip`-high tab strips. Returns each group's height. The last expanded group fills the
/// rest; when the column is too short the others give way down to their minimum heights.
pub fn heights_for(&self, shown: &[Group], avail: f32, strip: f32) -> Vec<(Group, f32)> {
let avail = if avail.is_finite() { avail.max(0.0) } else { 0.0 };
let filler = shown.iter().rposition(|g| !self.is_collapsed(*g));
let mut hs: Vec<f32> = shown
.iter()
.enumerate()
.map(|(i, g)| {
if self.is_collapsed(*g) {
strip
} else if Some(i) == filler {
0.0
} else {
self.height(*g)
}
})
.collect();
if let Some(f) = filler {
let gaps = GAP * shown.len().saturating_sub(1) as f32;
let min_fill = shown.get(f).map_or(0.0, |g| g.min_height());
let used: f32 = hs.iter().sum::<f32>() + gaps;
let mut deficit = (used + min_fill - avail).max(0.0);
// Squeeze the expanded groups nearest the filler first.
for i in (0..f).rev() {
if deficit <= 0.0 {
break;
}
let Some(g) = shown.get(i) else { continue };
if self.is_collapsed(*g) {
continue;
}
if let Some(h) = hs.get_mut(i) {
let give = (*h - g.min_height()).max(0.0).min(deficit);
*h -= give;
deficit -= give;
}
}
let rest = avail - hs.iter().sum::<f32>() - gaps;
if let Some(h) = hs.get_mut(f) {
*h = rest.max(min_fill);
}
}
shown.iter().copied().zip(hs).collect()
}
}
/// Show `g` and expand it (Window › <panel>, the icon rail): a panel asked for is always
/// brought back, whatever state it was left in (#129).
pub fn reveal(app: &mut PhotocraftApp, g: Group) {
*g.shown_mut(&mut app.ui.panels) = true;
app.ui.dock.set_collapsed(g, false);
}
/// Icon rail click: a hidden group is shown, a collapsed one expanded and an expanded one
/// collapsed to its tab strip. A docked group is never hidden from the rail (it used to
/// toggle visibility, so one stray click made a panel vanish: #129); `docked` is false for
/// Studio's floating Properties card, which the rail shows and hides.
pub fn rail_click(app: &mut PhotocraftApp, g: Group, docked: bool) {
if !g.shown(&app.ui.panels) {
reveal(app, g);
} else if !docked {
*g.shown_mut(&mut app.ui.panels) = false;
} else {
let collapse = !app.ui.dock.is_collapsed(g);
app.ui.dock.set_collapsed(g, collapse);
}
}
/// Per-group interactions collected while drawing, applied afterwards.
enum Action {
ToggleCollapse(Group),
Close(Group),
Move(Group, Option<Group>),
}
/// Rects of the groups drawn last frame (screen points), for tests and automation.
pub fn last_rects(ctx: &egui::Context) -> Vec<(Group, Rect)> {
ctx.data(|d| d.get_temp::<Vec<(Group, Rect)>>(rects_id())).unwrap_or_default()
}
fn rects_id() -> egui::Id {
egui::Id::new("dock-group-rects")
}
/// Draw the `shown` groups (any order; the layout decides) filling `ui`. `body` draws one
/// group's tab content.
pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut body: impl FnMut(&mut PhotocraftApp, &mut egui::Ui, Group, usize)) {
let t = Tokens::get(ui.ctx());
let strip = if t.pro { 28.0 } else { 40.0 };
let order: Vec<Group> = app.ui.dock.order().into_iter().filter(|g| shown.contains(g)).collect();
let area = ui.available_rect_before_wrap();
let heights = app.ui.dock.heights_for(&order, area.height(), strip);
let locked = app.session.prefs().workspace_locked;
let rects = rects_after_layout(&heights, area);
let mut actions: Vec<Action> = Vec::new();
let mut dragging: Option<Group> = None;
for (i, (g, rect)) in rects.iter().copied().enumerate() {
let collapsed = app.ui.dock.is_collapsed(g);
let mut child = ui.new_child(egui::UiBuilder::new().id_salt(("dock-group", g.key())).max_rect(rect));
child.set_clip_rect(rect.intersect(ui.clip_rect()));
child.spacing_mut().item_spacing.y = if t.pro { 0.0 } else { 6.0 };
let before = *g.tab_mut(&mut app.ui.dock_tabs);
let mut sel = before;
let tabs = g.tabs(t.pro);
let resp = widgets::card_ex(&mut child, g.key(), tabs, &mut sel, collapsed, |ui, tab| {
let inner = ui.available_height().max(0.0);
if g.scrolls_itself(tab) {
ui.set_min_height(inner);
body(app, ui, g, tab);
} else {
egui::ScrollArea::vertical()
.id_salt(("dock-scroll", g.key(), tab))
.max_height(inner)
.auto_shrink([false, false])
.show(ui, |ui| body(app, ui, g, tab));
}
});
// The body may switch tabs itself (Adjustments jumps back to Properties).
if sel != before {
*g.tab_mut(&mut app.ui.dock_tabs) = sel.min(tabs.len().saturating_sub(1));
}
if resp.strip.double_clicked() || resp.tab_double_clicked {
actions.push(Action::ToggleCollapse(g));
}
if !locked && resp.strip.dragged() {
dragging = Some(g);
}
if !locked
&& resp.strip.drag_stopped()
&& let Some(p) = ui.ctx().pointer_interact_pos()
{
actions.push(Action::Move(g, drop_before(&order, &rects, g, p.y)));
}
if dragging == Some(g) {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
egui::Popup::menu(&resp.menu).show(|ui| {
ui.set_min_width(170.0);
if ui.button(if collapsed { "Expand Panel Group" } else { "Collapse Panel Group" }).clicked() {
actions.push(Action::ToggleCollapse(g));
ui.close();
}
let pos = order.iter().position(|x| *x == g).unwrap_or(0);
if ui.add_enabled(!locked && pos > 0, egui::Button::new("Move Group Up")).clicked() {
actions.push(Action::Move(g, order.get(pos.saturating_sub(1)).copied()));
ui.close();
}
if ui.add_enabled(!locked && pos + 1 < order.len(), egui::Button::new("Move Group Down")).clicked() {
actions.push(Action::Move(g, order.get(pos + 2).copied()));
ui.close();
}
ui.separator();
if ui.button("Close Tab Group").clicked() {
actions.push(Action::Close(g));
ui.close();
}
});
// Splitter in the gap below this group: resizes it against the next expanded group.
if i + 1 < rects.len() && !collapsed && rects.iter().skip(i + 1).any(|(n, _)| !app.ui.dock.is_collapsed(*n)) {
let gap = Rect::from_min_size(pos2(rect.left(), rect.bottom()), vec2(rect.width(), GAP)).expand2(vec2(0.0, 2.0));
let sresp = ui.interact(gap, ui.id().with(("dock-splitter", g.key())), Sense::drag());
if sresp.hovered() || sresp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
ui.painter().line_segment([gap.left_center(), gap.right_center()], Stroke::new(2.0, t.accent.gamma_multiply(0.7)));
}
if sresp.dragged() {
resize(&mut app.ui.dock, &heights, i, sresp.drag_delta().y);
}
}
}
// Drop indicator while a group is dragged by its tab strip.
if let (Some(g), Some(p)) = (dragging, ui.ctx().pointer_interact_pos()) {
let before = drop_before(&order, &rects, g, p.y);
let line_y = match before.and_then(|b| rects.iter().find(|(x, _)| *x == b)) {
Some((_, r)) => r.top() - GAP / 2.0,
None => rects.last().map_or(area.top(), |(_, r)| r.bottom() + GAP / 2.0),
};
ui.painter().line_segment([pos2(area.left(), line_y), pos2(area.right(), line_y)], Stroke::new(3.0, t.accent));
}
ui.ctx().data_mut(|d| d.insert_temp(rects_id(), rects));
ui.advance_cursor_after_rect(area);
for a in actions {
match a {
Action::ToggleCollapse(g) => {
let on = !app.ui.dock.is_collapsed(g);
app.ui.dock.set_collapsed(g, on);
}
Action::Close(g) => *g.shown_mut(&mut app.ui.panels) = false,
Action::Move(g, before) => {
if before != Some(g) {
app.ui.dock.move_group(g, before);
}
}
}
}
}
fn rects_after_layout(heights: &[(Group, f32)], area: Rect) -> Vec<(Group, Rect)> {
let mut y = area.top();
heights
.iter()
.map(|(g, h)| {
let r = Rect::from_min_size(pos2(area.left(), y), vec2(area.width(), h.max(0.0)));
y = r.bottom() + GAP;
(*g, r)
})
.collect()
}
/// The group the dragged one lands before when released at `y` (`None` = last).
fn drop_before(order: &[Group], rects: &[(Group, Rect)], dragged: Group, y: f32) -> Option<Group> {
let hit = rects.iter().find(|(_, r)| y < r.center().y).map(|(g, _)| *g);
match hit {
// Dropping onto itself, or just below itself, keeps the place.
Some(g) if g == dragged => Some(dragged),
Some(g) => {
let after_self = order.iter().position(|x| *x == dragged).zip(order.iter().position(|x| *x == g)).is_some_and(|(a, b)| b == a + 1);
if after_self { Some(dragged) } else { Some(g) }
}
None => None,
}
}
/// Splitter `i` (below group `i`) dragged by `dy`: group `i` grows or shrinks against the next
/// expanded group (or the filler, which absorbs the difference).
fn resize(layout: &mut DockLayout, heights: &[(Group, f32)], i: usize, dy: f32) {
if !dy.is_finite() || dy == 0.0 {
return;
}
let Some(&(g, h)) = heights.get(i) else { return };
let filler = heights.iter().rposition(|(g, _)| !layout.is_collapsed(*g));
let Some(j) = heights.iter().enumerate().skip(i + 1).find(|(_, (n, _))| !layout.is_collapsed(*n)).map(|(j, _)| j) else { return };
let Some(&(n, nh)) = heights.get(j) else { return };
let new_h = (h + dy).clamp(g.min_height(), (h + nh - n.min_height()).max(g.min_height()));
layout.heights.insert(g, new_h);
if Some(j) != filler {
layout.heights.insert(n, (nh - (new_h - h)).max(n.min_height()));
}
}
/// What `prefs.panelLayout` holds: the live layout and open panels.
fn snapshot(app: &PhotocraftApp) -> Value {
json!({"workspace": app.ui.workspace, "panels": app.ui.panels, "dockTabs": app.ui.dock_tabs, "dock": app.ui.dock})
}
/// Remember the layout in the preferences once the user lets go of the mouse (Workspace ›
/// Remember Workspace Changes). Cheap: a small JSON compare per frame.
pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) {
if !app.session.prefs().workspace.remember_workspace_changes || ctx.input(|i| i.pointer.any_down()) {
return;
}
let now = snapshot(app);
if app.session.prefs().panel_layout != now {
app.session.prefs.edit(|p| p.panel_layout = now);
}
}
/// 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 {
return;
}
let saved = app.session.prefs().panel_layout.clone();
apply(app, &saved);
if let Some(ws) = saved.get("workspace").and_then(Value::as_str) {
app.ui.workspace = ws.to_string();
}
}
/// Apply the `panels`, `dockTabs` and `dock` parts of a saved layout (a workspace or
/// `panelLayout`). Missing or invalid parts are left alone.
pub fn apply(app: &mut PhotocraftApp, v: &Value) {
if let Some(p) = v.get("panels").and_then(|p| serde_json::from_value(p.clone()).ok()) {
app.ui.panels = p;
}
if let Some(t) = v.get("dockTabs").and_then(|t| serde_json::from_value(t.clone()).ok()) {
app.ui.dock_tabs = t;
}
if let Some(d) = v.get("dock").and_then(|d| serde_json::from_value(d.clone()).ok()) {
app.ui.dock = d;
}
}
#[cfg(test)]
#[path = "dock_tests.rs"]
mod tests;
+350
View File
@@ -0,0 +1,350 @@
//! Dock layout tests (#88): fixed group heights, splitters, collapse, reorder, persistence.
use egui::{Modifiers, PointerButton, Pos2, Rect, vec2};
use egui_kittest::Harness;
use serde_json::json;
use super::*;
use crate::theme::ThemeKind;
const ESSENTIALS: [Group; 3] = [Group::Color, Group::Properties, Group::Layers];
#[test]
fn last_expanded_group_fills_the_column() {
let l = DockLayout::default();
let hs = l.heights_for(&ESSENTIALS, 800.0, 28.0);
assert_eq!(hs[0], (Group::Color, Group::Color.default_height()));
assert_eq!(hs[1], (Group::Properties, Group::Properties.default_height()));
let total: f32 = hs.iter().map(|(_, h)| h).sum::<f32>() + 2.0 * GAP;
assert!((total - 800.0).abs() < 1e-3, "{hs:?}");
// Collapsed groups shrink to the tab strip; the one above Layers keeps its height.
let mut l = DockLayout::default();
l.set_collapsed(Group::Color, true);
let hs = l.heights_for(&ESSENTIALS, 800.0, 28.0);
assert_eq!(hs[0].1, 28.0);
assert_eq!(hs[1].1, Group::Properties.default_height());
// Layers collapsed: Properties becomes the filler.
l.set_collapsed(Group::Layers, true);
let hs = l.heights_for(&ESSENTIALS, 800.0, 28.0);
assert_eq!(hs[2].1, 28.0);
assert!((hs[1].1 - (800.0 - 56.0 - 2.0 * GAP)).abs() < 1e-3);
}
#[test]
fn short_columns_squeeze_groups_down_to_their_minimum_and_never_go_negative() {
let l = DockLayout::default();
let hs = l.heights_for(&ESSENTIALS, 500.0, 28.0);
assert_eq!(hs[2].1, Group::Layers.min_height(), "Layers keeps its minimum: {hs:?}");
assert_eq!(hs[0].1, Group::Color.default_height(), "the group farthest from Layers gives way last");
assert!(hs[1].1 < Group::Properties.default_height());
for avail in [0.0, -50.0, 1.0, f32::NAN, f32::INFINITY, 1e9] {
for (g, h) in l.heights_for(&ESSENTIALS, avail, 28.0) {
assert!(h.is_finite() && h >= 0.0, "{g:?} at {avail}: {h}");
}
}
assert!(l.heights_for(&[], 500.0, 28.0).is_empty());
}
#[test]
fn bad_stored_values_are_sanitised() {
let mut l = DockLayout { order: vec![Group::Layers, Group::Layers, Group::Color], ..Default::default() };
assert_eq!(l.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Navigator, Group::History]);
for bad in [f32::NAN, -10.0, f32::INFINITY, 1e12] {
l.heights.insert(Group::Color, bad);
let h = l.height(Group::Color);
assert!(h.is_finite() && h >= Group::Color.min_height() && h <= MAX_HEIGHT, "{bad} -> {h}");
}
// Unknown fields, wrong types and old UI state all load.
let back: DockLayout = serde_json::from_value(json!({"order": ["layers"], "bogus": 1})).unwrap();
assert_eq!(back.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Navigator, Group::History]);
let mut ui = serde_json::to_value(crate::state::UiState::default()).unwrap();
ui.as_object_mut().unwrap().remove("dock");
let ui: crate::state::UiState = serde_json::from_value(ui).unwrap();
assert_eq!(ui.dock, DockLayout::default());
}
#[test]
fn move_group_reorders() {
let mut l = DockLayout::default();
l.move_group(Group::Layers, Some(Group::Color));
assert_eq!(l.order()[0], Group::Layers);
l.move_group(Group::Layers, None);
assert_eq!(l.order().last(), Some(&Group::Layers));
l.move_group(Group::Color, Some(Group::Color));
assert_eq!(l.order()[0], Group::Color);
}
fn app_with_layers() -> (PhotocraftApp, photocraft_doc::LayerId, photocraft_doc::LayerId) {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
let pixel = app.session.active().unwrap().active_layer.unwrap();
app.run("layer.newAdjustmentLayer.curves", json!({})).unwrap();
let adj = app.session.active().unwrap().active_layer.unwrap();
assert_ne!(pixel, adj);
app.sync_views();
(app, pixel, adj)
}
fn harness(app: PhotocraftApp, size: egui::Vec2, theme: ThemeKind) -> Harness<'static, PhotocraftApp> {
// 60 fps steps, so two clicks a frame apart count as a double-click.
let mut h = Harness::builder().with_size(size).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut PhotocraftApp| {
let ctx = ui.ctx().clone();
if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
crate::panels::right_dock(app, ui);
egui::CentralPanel::default().show(ui, |_| {});
},
app,
);
PhotocraftApp::setup_context(&h.ctx, theme);
h.state_mut().ui.theme = theme;
h.run_steps(4);
h
}
fn rect_of(h: &Harness<'static, PhotocraftApp>, g: Group) -> Rect {
last_rects(&h.ctx).into_iter().find(|(x, _)| *x == g).map(|(_, r)| r).unwrap_or_else(|| panic!("{g:?} not drawn"))
}
fn drag(h: &mut Harness<'static, PhotocraftApp>, from: Pos2, to: Pos2) {
h.event(egui::Event::PointerMoved(from));
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for i in 1..=6 {
h.event(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 6.0)));
h.run_steps(1);
}
h.event(egui::Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
#[test]
fn switching_layer_kinds_keeps_the_layers_panel_still() {
for theme in [ThemeKind::ProMedium, ThemeKind::Studio] {
let (app, pixel, adj) = app_with_layers();
let mut h = harness(app, vec2(1200.0, 800.0), theme);
let rects = |h: &Harness<'static, PhotocraftApp>| last_rects(&h.ctx);
let with_adj = rects(&h);
assert!(with_adj.iter().any(|(g, _)| *g == Group::Layers));
h.state_mut().run("layer.select", json!({"layer": pixel.0})).unwrap();
h.run_steps(4);
assert_eq!(rects(&h), with_adj, "{theme:?}: selecting a pixel layer moved the dock groups");
h.state_mut().run("layer.select", json!({"layer": adj.0})).unwrap();
h.run_steps(4);
assert_eq!(rects(&h), with_adj, "{theme:?}: selecting an adjustment layer moved the dock groups");
// Properties ↔ Adjustments tabs don't move anything either.
h.state_mut().ui.dock_tabs.properties = 1;
h.run_steps(3);
assert_eq!(rects(&h), with_adj);
}
}
#[test]
fn dragging_the_splitter_resizes_and_survives_a_ui_state_round_trip() {
let (app, _, _) = app_with_layers();
let mut h = harness(app, vec2(1200.0, 800.0), ThemeKind::ProMedium);
let props = rect_of(&h, Group::Properties);
let layers = rect_of(&h, Group::Layers);
let split = Pos2::new(props.center().x, props.bottom() + GAP / 2.0);
drag(&mut h, split, split - vec2(0.0, 90.0));
let props2 = rect_of(&h, Group::Properties);
let layers2 = rect_of(&h, Group::Layers);
assert!((props2.height() - (props.height() - 90.0)).abs() < 2.0, "{props:?} -> {props2:?}");
assert!((layers2.top() - (layers.top() - 90.0)).abs() < 2.0, "{layers:?} -> {layers2:?}");
assert_eq!(layers2.bottom(), layers.bottom());
let stored = h.state().ui.dock.heights.get(&Group::Properties).copied().unwrap();
// Dragging past the minimum stops at it.
let split = Pos2::new(props2.center().x, props2.bottom() + GAP / 2.0);
drag(&mut h, split, split - vec2(0.0, 600.0));
assert_eq!(rect_of(&h, Group::Properties).height(), Group::Properties.min_height());
let from = Pos2::new(split.x, rect_of(&h, Group::Properties).bottom() + GAP / 2.0);
drag(&mut h, from, split);
assert!((h.state().ui.dock.heights[&Group::Properties] - stored).abs() < 2.0);
// Save and restore the UI state (what `ui.inspect` / `ui.set` and workspaces carry).
let saved = serde_json::to_value(&h.state().ui).unwrap();
let (mut app2, _, _) = app_with_layers();
app2.ui = serde_json::from_value(saved).unwrap();
let h2 = harness(app2, vec2(1200.0, 800.0), ThemeKind::ProMedium);
assert_eq!(last_rects(&h2.ctx), last_rects(&h.ctx));
// Remembered in the preferences once the mouse is up, and restored at the next launch.
let prefs = h.state().session.prefs_to_json();
let mut s2 = photocraft_engine::Session::new();
s2.load_prefs_json(&prefs).unwrap();
let mut app3 = PhotocraftApp::new(s2, crate::Services::default());
restore(&mut app3);
assert_eq!(app3.ui.dock, h.state().ui.dock);
// …unless Remember Workspace Changes is off.
let mut s3 = photocraft_engine::Session::new();
s3.load_prefs_json(&prefs).unwrap();
s3.prefs.edit(|p| p.workspace.remember_workspace_changes = false);
let mut app4 = PhotocraftApp::new(s3, crate::Services::default());
restore(&mut app4);
assert_eq!(app4.ui.dock, DockLayout::default());
}
#[test]
fn reset_workspace_restores_the_default_layout_and_new_workspaces_keep_theirs() {
let (app, _, _) = app_with_layers();
let mut h = harness(app, vec2(1200.0, 800.0), ThemeKind::ProMedium);
let ctx = h.ctx.clone();
let default = last_rects(&h.ctx);
h.state_mut().ui.dock.heights.insert(Group::Properties, 120.0);
h.state_mut().ui.dock.set_collapsed(Group::Color, true);
crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.newWorkspace", json!({"name": "Tall Layers"})).unwrap();
let mine = h.state().ui.dock.clone();
crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.essentials", json!({})).unwrap();
assert_eq!(h.state().ui.dock, DockLayout::default());
h.state_mut().ui.dock.heights.insert(Group::Color, 300.0);
crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.resetWorkspace", json!({})).unwrap();
h.run_steps(3);
assert_eq!(last_rects(&h.ctx), default);
crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.select", json!({"name": "Tall Layers"})).unwrap();
assert_eq!(h.state().ui.dock, mine);
}
#[test]
fn double_clicking_a_tab_collapses_and_dragging_a_strip_reorders() {
let (app, _, _) = app_with_layers();
let mut h = harness(app, vec2(1200.0, 800.0), ThemeKind::ProMedium);
let color = rect_of(&h, Group::Color);
let tab = color.left_top() + vec2(20.0, 13.0);
h.event(egui::Event::PointerMoved(tab));
h.run_steps(1);
for _ in 0..2 {
h.event(egui::Event::PointerButton { pos: tab, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
h.event(egui::Event::PointerButton { pos: tab, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(2);
assert!(h.state().ui.dock.is_collapsed(Group::Color));
assert!(rect_of(&h, Group::Color).height() < 40.0);
h.state_mut().ui.dock.set_collapsed(Group::Color, false);
h.run_steps(3);
// Drag the Layers tab strip (right of its tabs) above Color.
let layers = rect_of(&h, Group::Layers);
let strip = Pos2::new(layers.right() - 60.0, layers.top() + 13.0);
let to = rect_of(&h, Group::Color).left_top() + vec2(120.0, 10.0);
drag(&mut h, strip, to);
assert_eq!(h.state().ui.dock.order().first(), Some(&Group::Layers));
// A locked workspace keeps the order.
h.state_mut().session.prefs.edit(|p| p.workspace_locked = true);
let layers = rect_of(&h, Group::Layers);
drag(&mut h, Pos2::new(layers.right() - 60.0, layers.top() + 13.0), Pos2::new(layers.right() - 60.0, 790.0));
assert_eq!(h.state().ui.dock.order().first(), Some(&Group::Layers));
}
#[test]
fn tiny_windows_do_not_panic() {
for theme in [ThemeKind::ProMedium, ThemeKind::Studio] {
for (w, ht) in [(40.0, 30.0), (300.0, 80.0), (600.0, 200.0), (2000.0, 120.0)] {
let (mut app, _, _) = app_with_layers();
app.ui.panels.history = true;
app.ui.panels.navigator = true;
let mut h = harness(app, vec2(w, ht), theme);
for (_, r) in last_rects(&h.ctx) {
assert!(r.height() >= 0.0 && r.is_finite());
}
// Drag a splitter way off-screen.
if let Some((_, r)) = last_rects(&h.ctx).first().copied() {
let p = Pos2::new(r.center().x, r.bottom() + GAP / 2.0);
drag(&mut h, p, p + vec2(0.0, 5000.0));
drag(&mut h, p, p - vec2(0.0, 5000.0));
}
}
}
}
#[test]
fn the_rail_and_window_menu_never_lose_a_panel() {
let (mut app, _, _) = app_with_layers();
let ctx = egui::Context::default();
// The rail collapses and expands a docked group; it never hides it (#129).
rail_click(&mut app, Group::Layers, true);
assert!(app.ui.panels.layers && app.ui.dock.is_collapsed(Group::Layers));
rail_click(&mut app, Group::Layers, true);
assert!(app.ui.panels.layers && !app.ui.dock.is_collapsed(Group::Layers));
// Window › Layers on a collapsed group expands it instead of hiding it.
app.ui.dock.set_collapsed(Group::Layers, true);
crate::menus::invoke(&mut app, &ctx, "window.panel.layers", json!({})).unwrap();
assert!(app.ui.panels.layers && !app.ui.dock.is_collapsed(Group::Layers));
app.ui.dock.set_collapsed(Group::Color, true);
crate::menus::invoke(&mut app, &ctx, "window.toggle.color", json!({})).unwrap();
assert!(app.ui.panels.color && !app.ui.dock.is_collapsed(Group::Color));
app.ui.dock.set_collapsed(Group::Color, true);
crate::menus::invoke(&mut app, &ctx, "window.panel.gradients", json!({})).unwrap();
assert!(app.ui.panels.color && !app.ui.dock.is_collapsed(Group::Color) && app.ui.dock_tabs.color == 2);
// Hidden panels come back from the Window menu, expanded, on their tab.
app.ui.panels.history = false;
app.ui.dock.set_collapsed(Group::History, true);
crate::menus::invoke(&mut app, &ctx, "window.panel.actions", json!({})).unwrap();
assert!(app.ui.panels.history && !app.ui.dock.is_collapsed(Group::History) && app.ui.dock_tabs.history == 1);
// Reset Workspace brings back everything the preset shows.
app.ui.panels = crate::state::Panels { layers: false, properties: false, color: false, ..Default::default() };
app.ui.dock_tabs.layers = 2;
app.ui.dock.set_collapsed(Group::Properties, true);
crate::menus::invoke(&mut app, &ctx, "window.workspace.resetWorkspace", json!({})).unwrap();
assert!(app.ui.panels.layers && app.ui.panels.properties && app.ui.panels.color);
assert_eq!(app.ui.dock, DockLayout::default());
assert_eq!(app.ui.dock_tabs.layers, 0);
}
/// #129: clicking around the whole UI (layers, canvas, tools, options bar) never switches,
/// hides or moves a dock panel.
#[test]
fn clicking_around_the_ui_keeps_the_panels_put() {
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let (app, _, _) = app_with_layers();
app
});
h.run_steps(8);
let panels = h.state().ui.panels.clone();
let tabs = h.state().ui.dock_tabs;
let rects = last_rects(&h.ctx);
assert!(rects.iter().any(|(g, _)| *g == Group::Layers), "{rects:?}");
let layers = rect_of(&h, Group::Layers);
let canvas = h.state().last_canvas_rect;
let mut points: Vec<Pos2> = Vec::new();
// Layer rows (eyes, thumbnails, names) and the Layers footer.
for dy in [118.0, 140.0, 150.0, 170.0, 182.0, 200.0] {
for x in [layers.left() + 22.0, layers.left() + 50.0, layers.center().x] {
points.push(Pos2::new(x, layers.top() + dy));
}
}
// The canvas, the toolbar column and the options bar.
points.extend([canvas.center(), canvas.left_top() + vec2(40.0, 40.0), canvas.right_bottom() - vec2(30.0, 30.0)]);
for y in (110..460).step_by(33) {
points.push(Pos2::new(20.0, y as f32));
}
for x in (40..1100).step_by(70) {
points.push(Pos2::new(x as f32, 50.0));
}
for p in points {
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.drop_at(p);
h.run_steps(2);
h.key_press(egui::Key::Escape);
h.run_steps(2);
// Popups and dialogs opened by a click are fine; panels changing aren't.
let app = h.state();
assert_eq!(app.ui.panels.layers, panels.layers, "click at {p:?} hid/showed Layers");
assert_eq!(app.ui.panels.properties, panels.properties, "click at {p:?} hid/showed Properties");
assert_eq!(app.ui.panels.color, panels.color, "click at {p:?} hid/showed Color");
assert_eq!(app.ui.dock_tabs, tabs, "click at {p:?} switched a dock tab");
assert!(app.ui.dock.collapsed.is_empty(), "click at {p:?} collapsed a group");
if app.ui.dialogs.is_empty() && app.ui.shell.dialog.is_none() {
assert_eq!(last_rects(&h.ctx), rects, "click at {p:?} moved the dock groups");
}
h.state_mut().ui.dialogs.clear();
}
}
+1
View File
@@ -29,6 +29,7 @@ pub mod dialogs;
pub mod discard_ui;
pub mod distort_ui;
pub mod doc_props_ui;
pub mod dock;
pub mod enable_rules;
pub mod eraser_ui;
pub mod export_dialog;
+11
View File
@@ -372,6 +372,11 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
Ok(json!({"dialog": crate::adjust_dialog::open(app, a).ok_or("no document")?}))
}
t if t.starts_with("window.toggle.") => {
// A shown but collapsed dock group is expanded rather than hidden (#129).
if let Some(g) = crate::dock::Group::from_key(&t["window.toggle.".len()..]).filter(|g| g.shown(&app.ui.panels) && app.ui.dock.is_collapsed(*g)) {
crate::dock::reveal(app, g);
return Ok(Value::Null);
}
let p = &mut app.ui.panels;
let slot = match &t["window.toggle.".len()..] {
"layers" => &mut p.layers,
@@ -385,6 +390,9 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
_ => return Err(format!("unknown panel in {t}")),
};
*slot = !*slot;
if let Some(g) = crate::dock::Group::from_key(&t["window.toggle.".len()..]).filter(|g| g.shown(&app.ui.panels)) {
crate::dock::reveal(app, g);
}
Ok(Value::Null)
}
// Layer › Layer Style › <effect>… opens the Layer Style dialog on that effect.
@@ -812,6 +820,9 @@ pub fn apply_workspace(app: &mut PhotocraftApp) {
if crate::workspace_ui::apply_custom(app) {
return;
}
// Presets use the default group order, heights and tabs (Reset brings everything back).
app.ui.dock = Default::default();
app.ui.dock_tabs = Default::default();
let p = &mut app.ui.panels;
let (nav, color, layers, history, props) = match app.ui.workspace.as_str() {
"Photography" => (true, false, true, true, true),
+59 -89
View File
@@ -716,7 +716,7 @@ pub fn right_dock(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if t.pro {
dock_panels(app, ui, &p, &t);
}
// Narrow icon rail (always visible) that toggles panels.
// Narrow icon rail (always visible): shows, expands or collapses panel groups.
let (rw, rb) = if t.pro { (36.0, 28.0) } else { (44.0, 32.0) };
egui::Panel::right("rail").resizable(false).exact_size(rw).frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(4, 8))).show(
ui,
@@ -724,23 +724,20 @@ pub fn right_dock(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let r = ui.max_rect();
ui.painter().line_segment([r.left_top() - vec2(6.0, 8.0), r.left_bottom() + vec2(-6.0, 8.0)], Stroke::new(1.0, t.separator));
ui.spacing_mut().item_spacing.y = 4.0;
let entries: [(&str, &str, bool); 5] = [
("sliders-horizontal", "Properties", p.properties),
("navigation", "Navigator", p.navigator),
("palette", "Color & Swatches", p.color),
("layers", "Layers", p.layers),
("clock", "History", p.history),
use crate::dock::Group;
let entries: [(&str, &str, Group); 5] = [
("sliders-horizontal", "Properties", Group::Properties),
("navigation", "Navigator", Group::Navigator),
("palette", "Color & Swatches", Group::Color),
("layers", "Layers", Group::Layers),
("clock", "History", Group::History),
];
for (icon, name, on) in entries {
for (icon, name, g) in entries {
// Studio floats Properties outside the dock.
let docked = t.pro || g != Group::Properties;
let on = g.shown(&p) && !(docked && app.ui.dock.is_collapsed(g));
if icons::rail_button(ui, icon, rb, on, name).clicked() {
let panels = &mut app.ui.panels;
match name {
"Properties" => panels.properties = !panels.properties,
"Navigator" => panels.navigator = !panels.navigator,
"Color & Swatches" => panels.color = !panels.color,
"Layers" => panels.layers = !panels.layers,
_ => panels.history = !panels.history,
}
crate::dock::rail_click(app, g, docked);
}
}
},
@@ -748,11 +745,23 @@ pub fn right_dock(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if !t.pro {
dock_panels(app, ui, &p, &t);
}
crate::dock::persist(app, ui.ctx());
}
fn dock_panels(app: &mut PhotocraftApp, ui: &mut egui::Ui, p: &crate::state::Panels, t: &Tokens) {
let p = p.clone();
if !(p.layers || p.history || p.color || p.navigator || (t.pro && p.properties)) {
use crate::dock::Group;
// Floating in Studio, Properties docks only in Pro (Photoshop).
let shown: Vec<Group> = [
(Group::Color, p.color),
(Group::Properties, t.pro && p.properties),
(Group::Navigator, p.navigator),
(Group::History, p.history),
(Group::Layers, p.layers),
]
.into_iter()
.filter_map(|(g, on)| on.then_some(g))
.collect();
if shown.is_empty() {
return;
}
let margin = if t.pro { 2 } else { 8 };
@@ -762,75 +771,36 @@ fn dock_panels(app: &mut PhotocraftApp, ui: &mut egui::Ui, p: &crate::state::Pan
.size_range(250.0..=520.0)
.frame(egui::Frame::NONE.fill(t.dock).inner_margin(egui::Margin::same(margin)))
.show(ui, |ui| {
let draw = |app: &mut PhotocraftApp, ui: &mut egui::Ui| {
ui.spacing_mut().item_spacing.y = if t.pro { 0.0 } else { 6.0 };
// Photoshop Essentials order: Color, Properties, (Navigator, History), Layers last and filling.
if p.color {
let mut sel = app.ui.dock_tabs.color;
// Photoshop Essentials: Color | Swatches | Gradients | Patterns.
let tabs: &[&str] = if t.pro { &["Color", "Swatches", "Gradients", "Patterns"] } else { &["Swatches", "Color", "Gradients", "Patterns"] };
let pro = t.pro;
widgets::card(ui, "color", tabs, &mut sel, |ui, tab| match (pro, tab) {
(_, 2) => crate::preset_panels::gradients_panel(app, ui),
(_, 3) => crate::preset_panels::patterns_panel(app, ui),
(true, 0) => color_field(app, ui),
(true, _) => swatches(app, ui),
(false, 0) => swatches(app, ui),
(false, _) => color_picker(app, ui),
});
app.ui.dock_tabs.color = sel;
}
if t.pro && p.properties {
let mut sel = app.ui.dock_tabs.properties;
// Leave Layers at least ~240 px; Properties scrolls within what remains.
let reserve = if p.layers { 240.0 } else { 40.0 };
let max_h = (ui.available_height() - reserve - 40.0).max(70.0);
widgets::card(ui, "properties", &["Properties", "Adjustments"], &mut sel, |ui, tab| {
egui::ScrollArea::vertical()
.id_salt("props-scroll")
.max_height(max_h)
.auto_shrink([false, true])
.show(ui, |ui| if tab == 0 { properties_body(app, ui) } else { adjustments_grid(app, ui) });
});
app.ui.dock_tabs.properties = sel;
}
if p.navigator {
let mut sel = app.ui.dock_tabs.navigator;
widgets::card(ui, "navigator", &["Navigator", "Histogram", "Info"], &mut sel, |ui, tab| match tab {
0 => navigator(app, ui),
1 => crate::tone::histogram_panel(app, ui),
_ => info_panel(app, ui),
});
app.ui.dock_tabs.navigator = sel;
}
if p.history {
let mut sel = app.ui.dock_tabs.history;
widgets::card(ui, "history", &["History", "Actions", "Layer Comps"], &mut sel, |ui, tab| match tab {
0 => history(app, ui),
1 => crate::actions::panel(app, ui),
_ => crate::comps_ui::panel(app, ui),
});
app.ui.dock_tabs.history = sel;
}
if p.layers {
let mut sel = app.ui.dock_tabs.layers;
widgets::card(ui, "layers", &["Layers", "Channels", "Paths"], &mut sel, |ui, tab| match tab {
0 => layers(app, ui),
1 => channels(app, ui),
_ => crate::vector_ui::paths_panel(app, ui),
});
app.ui.dock_tabs.layers = sel;
}
};
if t.pro {
// Fixed panels on top, Layers takes the rest (it scrolls internally).
draw(app, ui);
} else {
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| draw(app, ui));
}
// Groups keep their heights whatever they show (#88): see `dock`.
crate::dock::show(app, ui, &shown, dock_body);
});
}
/// One dock group's tab content; `dock` bounds it and scrolls it when it's taller.
fn dock_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, group: crate::dock::Group, tab: usize) {
use crate::dock::Group;
let pro = Tokens::get(ui.ctx()).pro;
match (group, tab) {
(Group::Color, 2) => crate::preset_panels::gradients_panel(app, ui),
(Group::Color, 3) => crate::preset_panels::patterns_panel(app, ui),
(Group::Color, 0) if pro => color_field(app, ui),
(Group::Color, _) if pro => swatches(app, ui),
(Group::Color, 0) => swatches(app, ui),
(Group::Color, _) => color_picker(app, ui),
(Group::Properties, 0) => properties_body(app, ui),
(Group::Properties, _) => adjustments_grid(app, ui),
(Group::Navigator, 0) => navigator(app, ui),
(Group::Navigator, 1) => crate::tone::histogram_panel(app, ui),
(Group::Navigator, _) => info_panel(app, ui),
(Group::History, 0) => history(app, ui),
(Group::History, 1) => crate::actions::panel(app, ui),
(Group::History, _) => crate::comps_ui::panel(app, ui),
(Group::Layers, 0) => layers(app, ui),
(Group::Layers, 1) => channels(app, ui),
(Group::Layers, _) => crate::vector_ui::paths_panel(app, ui),
}
}
/// Photoshop's Info panel: colour under the pointer (RGB and CMYK), position, selection size.
fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
@@ -1162,7 +1132,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let rows = doc.walk();
let ctx = ui.ctx().clone();
let footer = 38.0;
let fill = t.pro && ui.available_height() > footer + 60.0;
let fill = ui.available_height() > footer + 60.0;
let rows_h = if fill { ui.available_height() - footer } else { f32::INFINITY };
egui::ScrollArea::vertical()
.id_salt("layer-rows")
@@ -1564,10 +1534,10 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
let mut target: Option<isize> = None;
let all = entries.iter().map(|e| (e.clone(), false)).chain(redo.iter().map(|e| (e.clone(), true)));
// Leave room for Layers below (the dock gives Layers a height budget).
let reserve = if app.ui.panels.layers { 250.0 } else { 0.0 };
let max_h = (ui.available_height() - reserve - 40.0).clamp(60.0, 360.0);
egui::ScrollArea::vertical().id_salt("history-rows").max_height(max_h).auto_shrink([false, true]).show(ui, |ui| {
// The dock gives History a fixed height: the rows scroll above the footer.
let footer = if t.pro { 34.0 } else { 0.0 };
let max_h = (ui.available_height() - footer).max(40.0);
egui::ScrollArea::vertical().id_salt("history-rows").max_height(max_h).auto_shrink([false, false]).show(ui, |ui| {
for (i, (e, is_redo)) in all.enumerate() {
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
if i == current {
+1
View File
@@ -101,6 +101,7 @@ pub fn load(app: &mut PhotocraftApp) {
{
app.ui.status = format!("Preferences were reset: {e}");
}
crate::dock::restore(app);
app.prefs_rt.saved_rev = app.session.prefs.rev();
if app.session.prefs().file_handling.recover_on_launch
&& let Some(recover) = app.services.recover.as_mut()
+3 -1
View File
@@ -83,11 +83,13 @@ pub fn menu(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option<Result<
}
let want = params.get("show").and_then(Value::as_bool);
if let Some(tab) = color_tab(id) {
let showing = app.ui.panels.color && app.ui.dock_tabs.color == tab;
// A collapsed Color group counts as not showing: the item expands it (#129).
let showing = app.ui.panels.color && app.ui.dock_tabs.color == tab && !app.ui.dock.is_collapsed(crate::dock::Group::Color);
let show = want.unwrap_or(!showing);
app.ui.panels.color = show || (app.ui.panels.color && app.ui.dock_tabs.color != tab);
if show {
app.ui.dock_tabs.color = tab;
crate::dock::reveal(app, crate::dock::Group::Color);
}
return Some(Ok(json!({"visible": show})));
}
+4
View File
@@ -587,6 +587,9 @@ pub struct UiState {
pub workspace: String,
pub palette_open: bool,
pub dock_tabs: DockTabs,
/// Right-dock group order, heights and collapsed groups (see `dock`).
#[serde(default)]
pub dock: crate::dock::DockLayout,
/// Brush Settings: selected section (0 = Brush Tip Shape) and tab (0 settings, 1 Brushes).
#[serde(default)]
pub brush_section: usize,
@@ -651,6 +654,7 @@ impl Default for UiState {
workspace: "Essentials".into(),
palette_open: false,
dock_tabs: DockTabs::default(),
dock: Default::default(),
brush_section: 0,
brush_tab: 0,
selection_mode: 0,
+10 -3
View File
@@ -453,15 +453,22 @@ fn flag_param(p: &Value, cur: bool) -> bool {
fn run(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result<Value, String> {
if let Some((panel, tab)) = panel_tab(app, id) {
let group = crate::dock::Group::from_key(panel);
let collapsed = group.is_some_and(|g| app.ui.dock.is_collapsed(g));
let (vis, cur) = panel_state(app, panel);
// Like Photoshop: choosing a visible panel's menu item again hides it.
if *vis && *cur == tab && !id.starts_with("type.panels.") {
// Like Photoshop: choosing a visible panel's menu item again hides it. A collapsed
// group is expanded instead, so the menu item always brings the panel back (#129).
if *vis && *cur == tab && !collapsed && !id.starts_with("type.panels.") {
*vis = false;
} else {
*vis = true;
*cur = tab;
}
return Ok(json!({"panel": panel, "tab": tab, "visible": *vis}));
let visible = *vis;
if let Some(g) = group.filter(|_| visible) {
crate::dock::reveal(app, g);
}
return Ok(json!({"panel": panel, "tab": tab, "visible": visible}));
}
let o = &mut app.ui.view;
if let Some(k) = id.strip_prefix("view.show.") {
+68 -30
View File
@@ -22,48 +22,82 @@ pub fn surface(ui: &Ui, rect: Rect, fill: Color32, raised: bool) {
/// A dock card: rounded container with a header of pill tabs and optional trailing actions.
/// Returns the index of the selected tab.
pub fn card(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, body: impl FnOnce(&mut Ui, usize)) {
let _ = card_ex(ui, id, tabs, selected, false, body);
}
/// What happened on a card's tab strip this frame (see [`card_ex`]).
pub struct CardResponse {
/// The tab strip background: drag to move the group, double-click to collapse it.
pub strip: Response,
/// The panel menu button (hamburger in Pro, ellipsis in Studio).
pub menu: Response,
/// A tab was double-clicked (Photoshop collapses the group).
pub tab_double_clicked: bool,
}
/// [`card`] that can be collapsed to its tab strip and reports strip and menu interactions.
pub fn card_ex(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, collapsed: bool, body: impl FnOnce(&mut Ui, usize)) -> CardResponse {
let t = Tokens::get(ui.ctx());
if t.pro {
return pro_panel(ui, id, tabs, selected, body);
return pro_panel(ui, id, tabs, selected, collapsed, body);
}
let frame = egui::Frame::NONE
.fill(t.card)
.stroke(Stroke::new(1.0, t.card_border))
.corner_radius(CornerRadius::same(t.radius as u8))
.inner_margin(egui::Margin { left: 8, right: 8, top: 6, bottom: 10 });
frame.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
for (i, name) in tabs.iter().enumerate() {
if pill_tab(ui, name, *selected == i).clicked() {
*selected = i;
}
.inner_margin(egui::Margin { left: 8, right: 8, top: 6, bottom: if collapsed { 6 } else { 10 } });
let out = frame
.show(ui, |ui| {
ui.set_width(ui.available_width());
// Registered before the tabs so they keep their clicks; drags fall through to it.
let strip_rect = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), 22.0));
let strip = ui.interact(strip_rect, ui.id().with((id, "strip")), Sense::click_and_drag());
let mut double = false;
let menu = ui
.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
for (i, name) in tabs.iter().enumerate() {
let r = pill_tab(ui, name, *selected == i);
double |= r.double_clicked();
if r.clicked() {
*selected = i;
}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 0.0;
crate::icons::button(ui, "ellipsis", 22.0, false, &format!("{} options", tabs.get(*selected).copied().unwrap_or(id)))
})
.inner
})
.inner;
if !collapsed {
ui.add_space(6.0);
body(ui, *selected);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 0.0;
let _ = crate::icons::button(ui, "ellipsis", 22.0, false, &format!("{} options", tabs.get(*selected).copied().unwrap_or(id)));
});
});
ui.add_space(6.0);
body(ui, *selected);
});
CardResponse { strip, menu, tab_double_clicked: double }
})
.inner;
ui.add_space(6.0);
out
}
/// Photoshop-grammar panel group: dark tab strip with flat tabs, flat body, hamburger menu.
fn pro_panel(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, body: impl FnOnce(&mut Ui, usize)) {
fn pro_panel(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, collapsed: bool, body: impl FnOnce(&mut Ui, usize)) -> CardResponse {
let t = Tokens::get(ui.ctx());
let width = ui.available_width();
// Tab strip.
// Tab strip. Its background senses drags (move the group) and double-clicks (collapse);
// the tabs, registered after it, keep their clicks.
let (strip, _) = ui.allocate_exact_size(vec2(width, 26.0), Sense::hover());
ui.painter().rect_filled(strip, CornerRadius { nw: 3, ne: 3, sw: 0, se: 0 }, t.tab_strip);
let strip_resp = ui.interact(strip, ui.id().with((id, "strip")), Sense::click_and_drag());
let rounding = if collapsed { CornerRadius::same(3) } else { CornerRadius { nw: 3, ne: 3, sw: 0, se: 0 } };
ui.painter().rect_filled(strip, rounding, t.tab_strip);
let mut x = strip.left();
let mut double = false;
for (i, name) in tabs.iter().enumerate() {
let galley = ui.painter().layout_no_wrap((*name).to_owned(), egui::FontId::proportional(11.5), t.text);
let r = Rect::from_min_size(pos2(x, strip.top()), vec2(galley.size().x + 22.0, strip.height()));
let resp = ui.interact(r, ui.id().with((id, "tab", i)), Sense::click());
let active = *selected == i;
let active = *selected == i && !collapsed;
if active {
ui.painter().rect_filled(r, CornerRadius { nw: if i == 0 { 3 } else { 0 }, ne: 0, sw: 0, se: 0 }, t.card);
} else if resp.hovered() {
@@ -77,6 +111,7 @@ fn pro_panel(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, body: i
t.text_faint
};
ui.painter().galley_with_override_text_color(r.center() - galley.size() / 2.0, galley, color);
double |= resp.double_clicked();
if resp.clicked() {
*selected = i;
}
@@ -91,15 +126,18 @@ fn pro_panel(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, body: i
ui.painter().line_segment([pos2(menu.center().x - 5.0, y), pos2(menu.center().x + 5.0, y)], Stroke::new(1.0, c));
}
// Body.
egui::Frame::NONE
.fill(t.card)
.corner_radius(CornerRadius { nw: 0, ne: 0, sw: 3, se: 3 })
.inner_margin(egui::Margin { left: 8, right: 8, top: 8, bottom: 8 })
.show(ui, |ui| {
ui.set_width(width - 16.0);
body(ui, *selected);
});
if !collapsed {
egui::Frame::NONE
.fill(t.card)
.corner_radius(CornerRadius { nw: 0, ne: 0, sw: 3, se: 3 })
.inner_margin(egui::Margin { left: 8, right: 8, top: 8, bottom: 8 })
.show(ui, |ui| {
ui.set_width(width - 16.0);
body(ui, *selected);
});
}
ui.add_space(2.0);
CardResponse { strip: strip_resp, menu: mresp, tab_double_clicked: double }
}
pub fn pill_tab(ui: &mut Ui, label: &str, selected: bool) -> Response {
+4 -7
View File
@@ -174,7 +174,7 @@ fn new_workspace(app: &mut PhotocraftApp, p: &Value) -> Result<Value, String> {
return Err(format!("\"{name}\" is a built-in workspace"));
}
let prefs = app.session.prefs().clone();
let mut ws = json!({"panels": app.ui.panels, "dockTabs": app.ui.dock_tabs});
let mut ws = json!({"panels": app.ui.panels, "dockTabs": app.ui.dock_tabs, "dock": app.ui.dock});
if p.get("keyboardShortcuts").and_then(Value::as_bool) == Some(true) {
ws["shortcuts"] = json!(prefs.shortcuts);
}
@@ -221,12 +221,9 @@ fn select_workspace(app: &mut PhotocraftApp, p: &Value) -> Result<Value, String>
/// Apply the current workspace when it is a saved one. Returns false for the built-in presets.
pub fn apply_custom(app: &mut PhotocraftApp) -> bool {
let Some(ws) = app.session.prefs().workspaces.get(&app.ui.workspace).cloned() else { return false };
if let Ok(p) = serde_json::from_value(ws["panels"].clone()) {
app.ui.panels = p;
}
if let Ok(t) = serde_json::from_value(ws["dockTabs"].clone()) {
app.ui.dock_tabs = t;
}
// Workspaces saved before the dock layout existed get the default heights.
app.ui.dock = Default::default();
crate::dock::apply(app, &ws);
let (sc, menus, toolbar) = (ws.get("shortcuts").cloned(), ws.get("menus").cloned(), ws.get("toolbar").cloned());
if sc.is_some() || menus.is_some() || toolbar.is_some() {
app.session.prefs.edit(|p| {
+1 -1
View File
@@ -35,7 +35,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
- `engine.commands`: list commands with enablement
- `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, menu tree, window size). `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
- `ui.set {tool?, panels?, dockTabs?, maskTarget?, zoom?, center?, fit?, theme?, brushSize?, brushSection?, brushTab?}`: change UI state (`brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes)
- `ui.set {tool?, panels?, dockTabs?, dock?, maskTarget?, zoom?, center?, fit?, theme?, brushSize?, brushSection?, brushTab?}`: change UI state (`brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip)
- `ui.menu.invoke {id}` / `ui.menu.list`: activate a menu item by id; list the menu tree
- `ui.dialog.open {kind, fields?}` (kinds `newDocument`, `about`, `layerStyle {effect?}`, `colorPicker {target: foreground|background}`, `command {command}`) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog}` / `ui.dialog.cancel {dialog}`
- `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows