mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-16 04:06:53 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7278b7dd22 |
No files matched your search
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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})));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user