494 lines
19 KiB
Rust
494 lines
19 KiB
Rust
//! Window → Workspace: saved panel/bar layouts (Essentials, Essentials Classic, Painting, …),
|
|
//! Reset, New Workspace…, Manage Workspaces…. A workspace is a snapshot of `UiState` layout
|
|
//! flags; UI brightness and everything else is untouched when switching.
|
|
|
|
use std::sync::atomic::Ordering;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::state::{Dialog, DockTab, FloatingFlyout, FloatingPanels, UiState};
|
|
use crate::theme::Tokens;
|
|
use crate::{VectorcraftApp, widgets};
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
#[serde(default, rename_all = "camelCase")]
|
|
pub struct Workspace {
|
|
pub name: String,
|
|
pub control_bar: bool,
|
|
pub rulers: bool,
|
|
pub toolbar: bool,
|
|
pub toolbar_double: bool,
|
|
pub toolbar_advanced: bool,
|
|
pub task_bar: bool,
|
|
pub dock: bool,
|
|
pub dock_tab: DockTab,
|
|
/// The dock's tabbed group is collapsed to icons (built-in workspaces: expanded).
|
|
pub dock_collapsed: bool,
|
|
pub open_panel: Option<String>,
|
|
pub status_bar: bool,
|
|
/// Tool groups torn off the toolbar into floating strips (built-in workspaces: none).
|
|
pub floating_flyouts: Vec<FloatingFlyout>,
|
|
/// Panels dragged out of the dock, where they float (built-in workspaces: none).
|
|
pub floating_panels: Vec<FloatingPanels>,
|
|
/// Custom docking tree, including floating tab groups.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub docking: Option<craft_ui::docking::Layout<String>>,
|
|
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
|
pub docking_hidden: std::collections::BTreeMap<String, craft_ui::docking::Location<String>>,
|
|
/// Panels whose saved return destination is the iconic rail.
|
|
#[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
|
|
pub docking_icons: std::collections::BTreeSet<String>,
|
|
/// Where the Tools panel floats (built-in workspaces: docked).
|
|
pub toolbar_pos: Option<[f32; 2]>,
|
|
}
|
|
|
|
impl Default for Workspace {
|
|
fn default() -> Self {
|
|
let ui = UiState::default();
|
|
capture(&ui, "Essentials")
|
|
}
|
|
}
|
|
|
|
pub const ESSENTIALS: &str = "Essentials";
|
|
|
|
/// Built-in workspaces, in Illustrator's menu order (Essentials first).
|
|
pub fn builtins() -> Vec<Workspace> {
|
|
let base = Workspace::default();
|
|
let w = |name: &str, f: &dyn Fn(&mut Workspace)| {
|
|
let mut x = Workspace { name: name.into(), ..base.clone() };
|
|
f(&mut x);
|
|
x
|
|
};
|
|
vec![
|
|
w(ESSENTIALS, &|_| {}),
|
|
w("Essentials Classic", &|x| {
|
|
x.control_bar = true;
|
|
x.rulers = true;
|
|
x.toolbar_advanced = true;
|
|
x.task_bar = false;
|
|
x.open_panel = Some("color".into());
|
|
}),
|
|
w("Automation", &|x| {
|
|
x.control_bar = true;
|
|
x.toolbar_advanced = true;
|
|
x.dock_tab = DockTab::Layers;
|
|
x.open_panel = Some("actions".into());
|
|
}),
|
|
w("Layout", &|x| {
|
|
x.control_bar = true;
|
|
x.rulers = true;
|
|
x.toolbar_advanced = true;
|
|
x.dock_tab = DockTab::Layers;
|
|
x.open_panel = Some("align".into());
|
|
}),
|
|
w("Painting", &|x| {
|
|
x.control_bar = true;
|
|
x.toolbar_advanced = true;
|
|
x.toolbar_double = true;
|
|
x.open_panel = Some("brushes".into());
|
|
}),
|
|
w("Printing and Proofing", &|x| {
|
|
x.control_bar = true;
|
|
x.rulers = true;
|
|
x.toolbar_advanced = true;
|
|
x.open_panel = Some("info".into());
|
|
}),
|
|
w("Tracing", &|x| {
|
|
x.control_bar = true;
|
|
x.toolbar_advanced = true;
|
|
x.dock_tab = DockTab::Layers;
|
|
x.task_bar = false;
|
|
}),
|
|
w("Typography", &|x| {
|
|
x.control_bar = true;
|
|
x.rulers = true;
|
|
x.toolbar_advanced = true;
|
|
x.open_panel = Some("character".into());
|
|
}),
|
|
w("Web", &|x| {
|
|
x.control_bar = true;
|
|
x.rulers = true;
|
|
x.toolbar_advanced = true;
|
|
x.open_panel = Some("symbols".into());
|
|
}),
|
|
]
|
|
}
|
|
|
|
pub fn is_builtin(name: &str) -> bool {
|
|
builtins().iter().any(|w| w.name == name)
|
|
}
|
|
|
|
/// Snapshot the layout flags of `ui` as a workspace called `name`.
|
|
pub fn capture(ui: &UiState, name: &str) -> Workspace {
|
|
Workspace {
|
|
name: name.into(),
|
|
control_bar: ui.control_bar,
|
|
rulers: ui.view.rulers,
|
|
toolbar: ui.toolbar,
|
|
toolbar_double: ui.toolbar_double,
|
|
toolbar_advanced: ui.toolbar_advanced,
|
|
task_bar: ui.task_bar,
|
|
dock: ui.dock,
|
|
dock_tab: ui.dock_tab,
|
|
dock_collapsed: ui.dock_collapsed,
|
|
open_panel: ui.open_panel.clone(),
|
|
status_bar: ui.status_bar,
|
|
floating_flyouts: ui.floating_flyouts.clone(),
|
|
floating_panels: ui.floating_panels.clone(),
|
|
docking: ui.docking.clone(),
|
|
docking_hidden: ui.docking_hidden.clone(),
|
|
docking_icons: ui.docking_icons.clone(),
|
|
toolbar_pos: ui.toolbar_pos,
|
|
}
|
|
}
|
|
|
|
/// Apply a workspace's layout flags (brightness, prefs, shortcuts etc. untouched).
|
|
pub fn apply(ui: &mut UiState, w: &Workspace) {
|
|
ui.control_bar = w.control_bar;
|
|
ui.view.rulers = w.rulers;
|
|
ui.toolbar = w.toolbar;
|
|
ui.toolbar_double = w.toolbar_double;
|
|
ui.toolbar_advanced = w.toolbar_advanced;
|
|
ui.task_bar = w.task_bar;
|
|
ui.dock = w.dock;
|
|
ui.dock_tab = w.dock_tab;
|
|
ui.dock_collapsed = w.dock_collapsed;
|
|
ui.open_panel = w.open_panel.clone();
|
|
ui.status_bar = w.status_bar;
|
|
ui.floating_flyouts = w.floating_flyouts.clone();
|
|
ui.floating_panels = w.floating_panels.clone();
|
|
ui.docking_generation = ui.docking_generation.wrapping_add(1);
|
|
ui.docking = w.docking.clone().filter(crate::panel_docking::valid);
|
|
ui.docking_hidden = w.docking_hidden.clone();
|
|
ui.docking_icons =
|
|
w.docking_icons.iter().filter(|id| crate::state::ICON_PANELS.iter().any(|entry| entry.0 == id.as_str())).take(64).cloned().collect();
|
|
ui.toolbar_pos = w.toolbar_pos;
|
|
FloatingPanels::sanitize(&mut ui.floating_panels, &mut ui.toolbar_pos);
|
|
ui.flyout = None;
|
|
ui.workspace = w.name.clone();
|
|
}
|
|
|
|
/// A workspace by name (user workspaces shadow nothing: names are unique across both lists).
|
|
pub fn find(ui: &UiState, name: &str) -> Option<Workspace> {
|
|
ui.custom_workspaces
|
|
.iter()
|
|
.find(|w| w.name.eq_ignore_ascii_case(name))
|
|
.cloned()
|
|
.or_else(|| builtins().into_iter().find(|w| w.name.eq_ignore_ascii_case(name)))
|
|
}
|
|
|
|
/// Every workspace name: built-ins then user workspaces.
|
|
pub fn names(ui: &UiState) -> Vec<String> {
|
|
builtins().into_iter().map(|w| w.name).chain(ui.custom_workspaces.iter().map(|w| w.name.clone())).collect()
|
|
}
|
|
|
|
/// Save the current layout as `name` (replacing a user workspace of that name).
|
|
pub fn save_as(ui: &mut UiState, name: &str) -> Result<(), String> {
|
|
let name = name.trim();
|
|
if name.is_empty() {
|
|
return Err("workspace name can't be empty".into());
|
|
}
|
|
if is_builtin(name) {
|
|
return Err(format!("`{name}` is a built-in workspace; choose another name"));
|
|
}
|
|
let w = capture(ui, name);
|
|
match ui.custom_workspaces.iter_mut().find(|x| x.name == name) {
|
|
Some(x) => *x = w,
|
|
None => ui.custom_workspaces.push(w),
|
|
}
|
|
ui.workspace = name.into();
|
|
Ok(())
|
|
}
|
|
|
|
pub fn delete(ui: &mut UiState, name: &str) -> Result<(), String> {
|
|
let before = ui.custom_workspaces.len();
|
|
ui.custom_workspaces.retain(|w| w.name != name);
|
|
if ui.custom_workspaces.len() == before {
|
|
return Err(if is_builtin(name) { format!("`{name}` is built in and can't be deleted") } else { format!("no workspace `{name}`") });
|
|
}
|
|
if ui.workspace == name {
|
|
ui.workspace = ESSENTIALS.into();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn rename(ui: &mut UiState, from: &str, to: &str) -> Result<(), String> {
|
|
let to = to.trim();
|
|
if to.is_empty() || is_builtin(to) || ui.custom_workspaces.iter().any(|w| w.name == to) {
|
|
return Err(format!("`{to}` is not an available name"));
|
|
}
|
|
let w = ui.custom_workspaces.iter_mut().find(|w| w.name == from).ok_or_else(|| format!("no user workspace `{from}`"))?;
|
|
w.name = to.into();
|
|
if ui.workspace == from {
|
|
ui.workspace = to.into();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ---------- menu support (menu_tree has no app access) ----------
|
|
|
|
crate::shortcut_editor::ui_mirror!(fn names_store() -> Vec<&'static str>);
|
|
|
|
/// Mirror the user workspace names for the (static) menu tree.
|
|
pub fn sync(ui: &UiState) {
|
|
let same = names_store()
|
|
.read()
|
|
.map(|v| v.len() == ui.custom_workspaces.len() && v.iter().zip(&ui.custom_workspaces).all(|(a, b)| *a == b.name))
|
|
.unwrap_or(true);
|
|
if !same {
|
|
if let Ok(mut v) = names_store().write() {
|
|
*v = ui.custom_workspaces.iter().map(|w| crate::shortcut_editor::intern(&w.name)).collect();
|
|
}
|
|
crate::shortcut_editor::GENERATION.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
/// Window → Workspace submenu items.
|
|
pub fn menu_items() -> Vec<crate::menus::Item> {
|
|
use crate::menus::Item;
|
|
let mut v: Vec<Item> =
|
|
builtins().into_iter().map(|w| Item::Cmd(crate::shortcut_editor::intern(&w.name), "window.workspace", json!({"name": w.name}))).collect();
|
|
let custom = names_store().read().map(|v| v.clone()).unwrap_or_default();
|
|
if !custom.is_empty() {
|
|
v.push(Item::Sep);
|
|
v.extend(custom.into_iter().map(|n| Item::Cmd(n, "window.workspace", json!({"name": n}))));
|
|
}
|
|
v.push(Item::Sep);
|
|
v.push(Item::Cmd("Reset Essentials", "window.workspace.reset", Value::Null));
|
|
v.push(Item::Cmd("New Workspace…", "window.workspace.new", Value::Null));
|
|
v.push(Item::Cmd("Manage Workspaces…", "window.workspace.manage", Value::Null));
|
|
v
|
|
}
|
|
|
|
/// The app-bar workspace switcher's menu.
|
|
pub fn popup(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
|
|
ui.set_min_width(210.0);
|
|
ui.set_max_width(240.0);
|
|
let mut clicked: Option<(&'static str, Value)> = None;
|
|
for it in menu_items() {
|
|
match it {
|
|
crate::menus::Item::Sep => {
|
|
ui.separator();
|
|
}
|
|
crate::menus::Item::Cmd(label, id, p) => {
|
|
// As the Window menu draws it: custom workspace names (and Reset <name>) as they are.
|
|
let label = crate::menus::display_label(app, id, label);
|
|
let text = match crate::menus::checked(app, id, &p) {
|
|
Some(true) => format!("✓ {label}"),
|
|
Some(false) => format!(" {label}"),
|
|
None => format!(" {label}"),
|
|
};
|
|
if ui.add(egui::Button::new(text).min_size(egui::vec2(220.0, 0.0))).clicked() {
|
|
clicked = Some((id, if p.is_null() { json!({}) } else { p }));
|
|
ui.close();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
if let Some((id, p)) = clicked {
|
|
crate::menus::invoke(app, id, p);
|
|
}
|
|
}
|
|
|
|
// ---------- commands ----------
|
|
|
|
pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
|
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
|
|
let r = match id {
|
|
"window.workspace" => match s("name").and_then(|n| find(&app.ui, &n)) {
|
|
Some(w) => {
|
|
apply(&mut app.ui, &w);
|
|
Ok(json!({"workspace": w.name}))
|
|
}
|
|
None => Err(format!("unknown workspace (one of {})", names(&app.ui).join(", "))),
|
|
},
|
|
"window.workspace.reset" => {
|
|
let w = find(&app.ui, &app.ui.workspace.clone()).unwrap_or_default();
|
|
apply(&mut app.ui, &w);
|
|
Ok(json!({"workspace": w.name}))
|
|
}
|
|
"window.workspace.new" => match s("name") {
|
|
Some(n) => save_as(&mut app.ui, &n).map(|_| json!({"workspace": n})),
|
|
None => {
|
|
let n = (1..).map(|i| format!("Workspace {i}")).find(|n| find(&app.ui, n).is_none()).unwrap_or_default();
|
|
app.ui.dialog = Some(Dialog::new("newWorkspace", json!({"name": n})));
|
|
Ok(Value::Null)
|
|
}
|
|
},
|
|
"window.workspace.manage" => {
|
|
app.ui.dialog = Some(Dialog::new("manageWorkspaces", json!({"selected": "", "name": ""})));
|
|
Ok(Value::Null)
|
|
}
|
|
"window.workspace.delete" => delete(&mut app.ui, &s("name").unwrap_or_default()).map(|_| Value::Null),
|
|
"window.workspace.rename" => rename(&mut app.ui, &s("name").unwrap_or_default(), &s("to").unwrap_or_default()).map(|_| Value::Null),
|
|
"window.workspace.list" => Ok(json!({"current": app.ui.workspace, "workspaces": names(&app.ui), "custom": app.ui.custom_workspaces})),
|
|
_ => return None,
|
|
};
|
|
Some(r)
|
|
}
|
|
|
|
/// OK in New Workspace / Manage Workspaces.
|
|
pub fn confirm(app: &mut VectorcraftApp) -> Result<Value, String> {
|
|
let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };
|
|
let r = match d.kind.as_str() {
|
|
"newWorkspace" => save_as(&mut app.ui, &d.str("name")).map(|_| Value::Null),
|
|
_ => Ok(Value::Null),
|
|
};
|
|
if r.is_ok() {
|
|
app.ui.dialog = None;
|
|
}
|
|
r
|
|
}
|
|
|
|
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
|
let Some(mut d) = app.ui.dialog.clone() else { return };
|
|
let t = Tokens::get(ctx);
|
|
let (mut ok, mut cancel) = (false, false);
|
|
let mut action: Option<(&str, Value)> = None;
|
|
let manage = d.kind == "manageWorkspaces";
|
|
crate::dialogs::modal::show(ctx, "Workspace", egui::Id::new("dialog-workspace"), -40.0, 22, |ui| {
|
|
ui.set_width(340.0);
|
|
crate::dialogs::modal::heading(ui, if manage { tl!("Manage Workspaces") } else { tl!("New Workspace") });
|
|
ui.add_space(12.0);
|
|
if manage {
|
|
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
|
|
ui.set_min_height(140.0);
|
|
ui.set_width(ui.available_width());
|
|
for n in names(&app.ui) {
|
|
let builtin = is_builtin(&n);
|
|
let shown = crate::panels::label_or_name(&n, builtin);
|
|
let text = if builtin { egui::RichText::new(shown).color(t.text_dim) } else { egui::RichText::new(shown).color(t.text) };
|
|
if ui.selectable_label(d.str("selected") == n, text).clicked() && !builtin {
|
|
d.fields.insert("selected".into(), json!(n));
|
|
d.fields.insert("name".into(), json!(n));
|
|
}
|
|
}
|
|
});
|
|
ui.add_space(8.0);
|
|
let sel = d.str("selected");
|
|
ui.horizontal(|ui| {
|
|
let mut name = d.str("name");
|
|
if ui.add(egui::TextEdit::singleline(&mut name).desired_width(180.0)).changed() {
|
|
d.fields.insert("name".into(), json!(name));
|
|
}
|
|
if ui.button(tl!("New")).on_hover_text(tl!("Save the current layout under this name")).clicked() {
|
|
action = Some(("window.workspace.new", json!({"name": d.str("name")})));
|
|
}
|
|
ui.add_enabled_ui(!sel.is_empty(), |ui| {
|
|
if ui.button(tl!("Rename")).clicked() {
|
|
action = Some(("window.workspace.rename", json!({"name": sel, "to": d.str("name")})));
|
|
}
|
|
if ui.button(tl!("Delete")).clicked() {
|
|
action = Some(("window.workspace.delete", json!({"name": sel})));
|
|
}
|
|
});
|
|
});
|
|
} else {
|
|
ui.horizontal(|ui| {
|
|
ui.label(egui::RichText::new(tl!("Name:")).color(t.text_dim));
|
|
let mut name = d.str("name");
|
|
let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(240.0));
|
|
if r.changed() {
|
|
d.fields.insert("name".into(), json!(name));
|
|
}
|
|
});
|
|
ui.label(egui::RichText::new(tl!("Saves the current bars, toolbar and panel layout.")).color(t.text_dim).size(11.0));
|
|
}
|
|
ui.add_space(16.0);
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
if widgets::primary_button(ui, tl!("OK")).clicked() {
|
|
ok = true;
|
|
}
|
|
ui.add_space(8.0);
|
|
if !manage && widgets::secondary_button(ui, tl!("Cancel")).clicked() {
|
|
cancel = true;
|
|
}
|
|
});
|
|
});
|
|
if !manage && ctx.input(|i| i.key_pressed(egui::Key::Enter)) {
|
|
ok = true;
|
|
}
|
|
app.ui.dialog = Some(d);
|
|
if let Some((id, p)) = action {
|
|
match run_command(app, id, &p) {
|
|
Some(Err(e)) => app.status(e),
|
|
_ => {
|
|
if let Some(d) = app.ui.dialog.as_mut() {
|
|
d.fields.insert("selected".into(), json!(""));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if cancel {
|
|
app.ui.dialog = None;
|
|
} else if ok && let Err(e) = confirm(app) {
|
|
app.status(e);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn essentials_is_the_default_layout() {
|
|
let ui = UiState::default();
|
|
assert_eq!(capture(&ui, ESSENTIALS), builtins()[0]);
|
|
assert_eq!(ui.workspace, ESSENTIALS);
|
|
}
|
|
|
|
#[test]
|
|
fn apply_classic_then_reset_to_essentials_keeps_brightness() {
|
|
let mut ui = UiState { brightness: crate::theme::Brightness::Light, ..Default::default() };
|
|
let w = find(&ui, "Essentials Classic").unwrap();
|
|
apply(&mut ui, &w);
|
|
assert!(ui.control_bar && ui.view.rulers && ui.toolbar_advanced);
|
|
assert_eq!(ui.open_panel.as_deref(), Some("color"));
|
|
assert_eq!(ui.workspace, "Essentials Classic");
|
|
let w = find(&ui, "essentials").unwrap();
|
|
apply(&mut ui, &w);
|
|
assert!(!ui.control_bar && !ui.view.rulers);
|
|
assert_eq!(ui.brightness, crate::theme::Brightness::Light);
|
|
}
|
|
|
|
#[test]
|
|
fn save_apply_round_trip() {
|
|
let mut ui = UiState { control_bar: true, dock_tab: DockTab::Layers, open_panel: Some("stroke".into()), ..Default::default() };
|
|
save_as(&mut ui, "Mine").unwrap();
|
|
let w = find(&ui, ESSENTIALS).unwrap();
|
|
apply(&mut ui, &w);
|
|
assert!(!ui.control_bar);
|
|
let w = find(&ui, "Mine").unwrap();
|
|
apply(&mut ui, &w);
|
|
assert!(ui.control_bar && ui.dock_tab == DockTab::Layers && ui.open_panel.as_deref() == Some("stroke"));
|
|
// Survives the UiState JSON round trip.
|
|
let back: UiState = serde_json::from_value(serde_json::to_value(&ui).unwrap()).unwrap();
|
|
assert_eq!(back.custom_workspaces, ui.custom_workspaces);
|
|
assert_eq!(back.workspace, "Mine");
|
|
}
|
|
|
|
#[test]
|
|
fn builtin_names_are_protected() {
|
|
let mut ui = UiState::default();
|
|
assert!(save_as(&mut ui, "Painting").is_err());
|
|
assert!(save_as(&mut ui, " ").is_err());
|
|
assert!(delete(&mut ui, "Essentials").is_err());
|
|
save_as(&mut ui, "A").unwrap();
|
|
assert!(rename(&mut ui, "A", "Web").is_err());
|
|
rename(&mut ui, "A", "B").unwrap();
|
|
assert_eq!(ui.workspace, "B");
|
|
delete(&mut ui, "B").unwrap();
|
|
assert_eq!(ui.workspace, ESSENTIALS);
|
|
}
|
|
|
|
#[test]
|
|
fn all_requested_workspaces_exist() {
|
|
let ui = UiState::default();
|
|
for n in ["Essentials", "Essentials Classic", "Painting", "Typography", "Layout", "Tracing", "Web", "Automation"] {
|
|
assert!(find(&ui, n).is_some(), "{n}");
|
|
}
|
|
}
|
|
}
|