ui: Panels::rail and Panels::menu_bar let an embedding app hide the icon rail and the menu bar (#1960)

The right dock's icon rail and the title bar with the in-window menus were
always drawn. An app that embeds PhotoCraft's UI and offers a simpler view, or
draws its own bar, could hide every other panel through `ui.panels` but not
these two.

Two Panels flags, both on by default and with `#[serde(default = "yes")]`, so
saved settings and the current UI are unchanged. `ui.set` accepts them like
the other panel flags. The title bar's caption buttons, where PhotoCraft draws
its own, go with it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
ROSSA AI 2026-10-09 20:47:12 -05:00 committed by GitHub
parent 16ad6e1079
commit 5ed094a940
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 95 additions and 26 deletions

View File

@ -1275,7 +1275,7 @@ impl eframe::App for PhotocraftApp {
if !chrome && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
let _ = menus::invoke(self, &ctx, "view.screenMode.standard", serde_json::json!({}));
}
if chrome {
if chrome && self.ui.panels.menu_bar {
panels::title_bar(self, ui);
}
if chrome && self.ui.panels.options_bar {

View File

@ -1371,32 +1371,34 @@ 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): 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,
|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;
use crate::dock::Group;
let entries: [(&str, &str, Group); 5] = [
("sliders-horizontal", tl!("Properties"), Group::Properties),
("navigation", tl!("Navigator"), Group::Navigator),
("palette", tl!("Color & Swatches"), Group::Color),
("layers", tl!("Layers"), Group::Layers),
("clock", tl!("History"), Group::History),
];
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() {
crate::dock::rail_click(app, g, docked);
// Narrow icon rail, unless an embedding app hides it: shows, expands or collapses panel groups.
if p.rail {
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,
|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;
use crate::dock::Group;
let entries: [(&str, &str, Group); 5] = [
("sliders-horizontal", tl!("Properties"), Group::Properties),
("navigation", tl!("Navigator"), Group::Navigator),
("palette", tl!("Color & Swatches"), Group::Color),
("layers", tl!("Layers"), Group::Layers),
("clock", tl!("History"), Group::History),
];
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() {
crate::dock::rail_click(app, g, docked);
}
}
}
},
);
},
);
}
if !t.pro {
dock_panels(app, ui, &p, &t);
}
@ -3935,6 +3937,56 @@ mod toolbar_tests {
}
}
#[cfg(test)]
mod embedding_tests {
use super::*;
use egui_kittest::kittest::Queryable;
fn whole_app(rail: bool, menu_bar: bool) -> egui_kittest::Harness<'static, PhotocraftApp> {
let mut h = egui_kittest::Harness::builder().with_size(vec2(1280.0, 800.0)).with_max_steps(64).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.ui.panels.rail = rail;
app.ui.panels.menu_bar = menu_bar;
app
});
h.run_steps(4);
h
}
fn drawn(h: &egui_kittest::Harness<'_, PhotocraftApp>, panel: &str) -> bool {
egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new(panel)).is_some()
}
/// An app that embeds the UI can hide the icon rail and the menu bar; both are on by default.
#[test]
fn rail_and_menu_bar_follow_their_panel_flags() {
let panels = crate::state::Panels::default();
assert!(panels.rail && panels.menu_bar);
let h = whole_app(true, true);
assert!(drawn(&h, "rail") && drawn(&h, "title_bar"));
assert!(h.query_by_label("File").is_some());
let h = whole_app(false, false);
assert!(!drawn(&h, "rail"), "the icon rail is drawn");
assert!(!drawn(&h, "title_bar"), "the title bar is drawn");
assert!(h.query_by_label("File").is_none(), "the menus are drawn");
assert!(drawn(&h, "dock"), "the dock itself stays");
}
/// Settings saved before the flags existed load with both on.
#[test]
fn panels_without_the_flags_deserialize_with_both_on() {
let mut v = serde_json::to_value(crate::state::Panels::default()).unwrap();
let m = v.as_object_mut().unwrap();
m.remove("rail");
m.remove("menu_bar");
let p: crate::state::Panels = serde_json::from_value(v).unwrap();
assert!(p.rail && p.menu_bar);
}
}
#[cfg(test)]
mod layer_drag_edge_scroll_tests {
use super::*;

View File

@ -336,6 +336,13 @@ pub struct Panels {
/// What Tab hid (toolbar, options bar, dock), so a second Tab brings back just those.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hidden_by_tab: Option<[bool; 3]>,
/// The right dock's icon rail. An app that embeds PhotoCraft's UI can hide it for a simpler view.
#[serde(default = "yes")]
pub rail: bool,
/// The title bar with the in-window menus. An app that embeds PhotoCraft's UI and draws its own
/// bar can hide it; the caption buttons of a custom title bar go with it.
#[serde(default = "yes")]
pub menu_bar: bool,
/// The Gradient tool's options-bar swatch opened the Gradient Editor window (`gradient_ui`).
/// Photoshop opens its Gradient Editor from that swatch; this is the same idea, drawn as a
/// floating window rather than a modal so the canvas stays usable while a gradient is edited.
@ -359,6 +366,8 @@ impl Default for Panels {
toolbar_double: false,
dock: true,
hidden_by_tab: None,
rail: true,
menu_bar: true,
gradient_editor: false,
}
}

View File

@ -175,6 +175,12 @@ fn new_workspace(app: &mut PhotocraftApp, p: &Value) -> Result<Value, String> {
}
let prefs = app.session.prefs().clone();
let mut ws = json!({"panels": app.ui.panels, "dockTabs": app.ui.dock_tabs, "dock": app.ui.dock, "timelineOpen": app.ui.timeline.open});
// The icon rail and menu bar flags belong to an embedding app's session, not to a saved
// layout: a workspace never brings back a window without its menus.
if let Some(panels) = ws.get_mut("panels").and_then(Value::as_object_mut) {
panels.remove("rail");
panels.remove("menu_bar");
}
if p.get("keyboardShortcuts").and_then(Value::as_bool) == Some(true) {
ws["shortcuts"] = json!(prefs.shortcuts);
}
@ -497,6 +503,8 @@ mod tests {
});
inv(&mut app, "window.workspace.newWorkspace", json!({"name": "Retouch", "keyboardShortcuts": true})).unwrap();
assert_eq!(app.ui.workspace, "Retouch");
let saved = &app.session.prefs().workspaces["Retouch"]["panels"];
assert!(saved.get("menu_bar").is_none() && saved.get("rail").is_none(), "embedding-only flags are not saved");
assert!(inv(&mut app, "window.workspace.newWorkspace", json!({"name": "Painting"})).is_err());
assert!(inv(&mut app, "window.workspace.newWorkspace", json!({"name": " "})).is_err());
// The current workspace can't be deleted.