mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 18:27:02 -06:00
Tab set the panels, the toolbar and the Control bar to one value on each press. Two presses turned on a Control bar that had been off. When the toolbar was hidden with Window › Tools, Tab showed the toolbar and the Control bar instead of hiding the panels. Tab now records which of the three showed when it hides them, and the next Tab shows those again. Panels shown with Shift+Tab in between stay shown. While the panels are hidden and the toolbar or the Control bar shows, as after Shift+Tab, Tab hides those bars instead of showing all three. When the toolbar or the Control bar was shown another way after Tab hid them, the next Tab hides what shows and records that instead. A switch of workspace clears the record. When none of the three shows and Tab hid none, Tab shows all three, as before. The record is saved with the other UI settings, so the first Tab after a restart shows the same bars. Docs: docs/target-app-parity.md.
495 lines
19 KiB
Rust
495 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.tab_hid = None;
|
|
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}");
|
|
}
|
|
}
|
|
}
|