diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 18a4a93eb..409020e3b 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -826,661 +826,4 @@ pub(crate) fn library_items(ui: &mut Ui, libs: &[LibraryRef], checked: &dyn Fn(& } #[cfg(test)] -mod tests { - use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; - use serde_json::Value; - use vectorcraft_color::Paint; - use vectorcraft_engine::Session; - - use super::*; - use crate::panels::swatches::SwatchLibraries; - - fn app() -> VectorcraftApp { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - app - } - - fn context() -> egui::Context { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - ctx - } - - /// One frame of the library panel with `events` and `modifiers` held. - fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, modifiers: Modifiers, time: f64) { - let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))); - let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect(); - let input = egui::RawInput { events, time: Some(time), screen_rect, ..Default::default() }; - let mut out = ctx.run_ui(input, |ui| show_window(app, ui.ctx())); - out.textures_delta.clear(); - } - - /// The panel opened last. - fn shown(app: &VectorcraftApp) -> String { - last_open(&app.ui).expect("a library panel is open").panel() - } - - /// Click the tile or row of `name` in the panel opened last with `modifiers` (frames from - /// `time` to `time + 0.5`; a new window takes a sizing frame first). - fn click(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str, modifiers: Modifiers, time: f64) { - frame(app, ctx, vec![], Modifiers::NONE, time); - frame(app, ctx, vec![], Modifiers::NONE, time + 0.25); - let at = ctx.read_response(tile_id(&shown(app), name)).unwrap_or_else(|| panic!("no tile for {name}")).rect.center(); - let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers }; - frame(app, ctx, vec![Event::PointerMoved(at), button(true), button(false)], modifiers, time + 0.5); - } - - fn undo_len(app: &VectorcraftApp) -> usize { - app.session.doc().unwrap().history.undo.len() - } - - #[test] - fn clicking_a_library_swatch_adds_and_applies_it_in_one_step() { - let mut app = app(); - let ctx = context(); - let r = app.run("window.swatchLibrary", json!({"library": "Pastels"})).unwrap(); - assert_eq!((r["open"].as_str(), r["count"].as_u64(), r["panel"].as_str()), (Some("pastels"), Some(24), Some("library:swatches:pastels"))); - assert_eq!(last_open(&app.ui).map(|p| (p.kind.as_str(), p.id.as_str())), Some(("swatches", "pastels"))); - let undo = undo_len(&app); - click(&mut app, &ctx, "Pastel Teal", Modifiers::NONE, 0.0); - let d = &app.session.doc().unwrap().doc; - let added = d.swatch("Pastel Teal").expect("added to the document"); - assert_eq!(app.session.paint.fill, Paint::solid(added.paint.color().unwrap()), "applied to the fill"); - assert_eq!(undo_len(&app), undo + 1); - // Alt-click paints the stroke (the inactive proxy). - click(&mut app, &ctx, "Soft Blue", Modifiers::ALT, 2.0); - let blue = app.session.doc().unwrap().doc.swatch("Soft Blue").and_then(|w| w.paint.color()).unwrap(); - assert_eq!(app.session.paint.stroke.color(), Some(blue)); - assert!(app.session.fill_active, "Alt keeps the active proxy"); - // A library that doesn't exist is an error; null closes the panel. - assert!(app.run("window.swatchLibrary", json!({"library": "nope"})).is_err()); - app.run("window.swatchLibrary", json!({"library": null})).unwrap(); - assert!(last_open(&app.ui).is_none()); - } - - #[test] - fn modifier_clicks_select_and_add_to_swatches_adds_the_selection() { - let mut app = app(); - let ctx = context(); - app.run("window.swatchLibrary", json!({"library": "earth-tones"})).unwrap(); - click(&mut app, &ctx, "Clay 1", Modifiers::COMMAND, 0.0); - click(&mut app, &ctx, "Ochre", Modifiers::NONE, 2.0); - assert_eq!(selection(&ctx, "library:swatches:earth-tones"), ["Ochre"], "a folder click selects its group"); - click(&mut app, &ctx, "Clay 3", Modifiers::COMMAND, 4.0); - assert!(app.session.doc().unwrap().doc.swatch("Clay 3").is_none(), "modifier clicks only select"); - let sel = selection(&ctx, "library:swatches:earth-tones"); - SwatchLibraries::add(&mut app, "earth-tones", sel); - let d = &app.session.doc().unwrap().doc; - assert_eq!(d.swatch_groups.iter().find(|g| g.name == "Ochre").map(|g| g.swatches.len()), Some(6)); - assert!(d.swatches.iter().any(|w| w.name == "Clay 3")); - // The selection belongs to its library's panel. - app.run("window.swatchLibrary", json!({"library": "brights"})).unwrap(); - assert!(selection(&ctx, "library:swatches:brights").is_empty()); - } - - #[test] - fn a_plain_click_sets_where_a_shift_click_range_starts() { - let mut app = app(); - let ctx = context(); - app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap(); - let (_, lib) = SwatchLibraries::get(&app, "pastels").unwrap(); - let order: Vec = SwatchLibraries::rows(&lib, "").iter().map(|r| r.name.to_string()).collect(); - click(&mut app, &ctx, &order[1], Modifiers::NONE, 0.0); - click(&mut app, &ctx, &order[4], Modifiers::SHIFT, 2.0); - assert_eq!(selection(&ctx, "library:swatches:pastels"), order[1..=4], "the range starts at the plain click"); - // A plain click after a Cmd/Ctrl-click moves the anchor from the Cmd/Ctrl-clicked item. - click(&mut app, &ctx, &order[8], Modifiers::COMMAND, 4.0); - click(&mut app, &ctx, &order[2], Modifiers::NONE, 6.0); - click(&mut app, &ctx, &order[3], Modifiers::SHIFT, 8.0); - assert_eq!(selection(&ctx, "library:swatches:pastels"), order[2..=3]); - let d = &app.session.doc().unwrap().doc; - assert!(d.swatch(&order[2]).is_some(), "a plain click still adds the swatch"); - assert!(d.swatch(&order[3]).is_none(), "a Shift-click only selects"); - } - - #[test] - fn find_filters_list_view_and_arrows_step_through_the_libraries() { - let mut app = app(); - let ctx = context(); - app.run("window.swatchLibrary", json!({"library": "web-safe-216"})).unwrap(); - let panel = "library:swatches:web-safe-216"; - app.run("ui.libraryPanel", json!({"panel": panel, "view": "smallList"})).unwrap(); - ctx.data_mut(|d| d.insert_temp(egui::Id::unique(key(panel, "find")), "#FF00".to_string())); - frame(&mut app, &ctx, vec![], Modifiers::NONE, 0.0); - frame(&mut app, &ctx, vec![], Modifiers::NONE, 0.5); - assert!(ctx.read_response(tile_id(panel, "#FF0033")).is_some()); - assert!(ctx.read_response(tile_id(panel, "#00FF00")).is_none()); - let first = SWATCH_IDS[0]; - let last = SwatchLibraries::list(&app).pop().unwrap().id; - assert_eq!(last, "radial-glows", "gradient libraries come after the swatch libraries"); - assert_eq!(neighbour::(&app, first, -1), Some(last), "wraps around"); - assert_eq!(neighbour::(&app, "web-safe-216", 1).as_deref(), Some(SWATCH_IDS[1])); - } - - const SWATCH_IDS: [&str; 9] = [ - "web-safe-216", - "grays-neutrals", - "earth-tones", - "skin-tone-ramps", - "pastels", - "brights", - "metallic-gradients", - "perceptual-scales", - "harmony-sets", - ]; - - #[test] - fn the_window_submenu_lists_every_library() { - let app = app(); - let ids: Vec = crate::menus::menu_entries(&app) - .into_iter() - .filter(|e| e.path == ["Window", "Swatch Libraries"] && e.command.as_deref() == Some("window.swatchLibrary")) - .map(|e| e.params["library"].clone()) - .collect(); - let all = vectorcraft_engine::cmd::swatchlib::libraries(&app.session); - let listed = |category: &str| all.iter().filter(|l| l.category == category).map(|l| json!(l.id)).collect::>(); - assert_eq!(ids, listed("builtIn")); - assert_eq!(ids, SWATCH_IDS.map(|i| json!(i))); - let gradients: Vec = crate::menus::menu_entries(&app) - .into_iter() - .filter(|e| e.path == ["Window", "Swatch Libraries", "Gradients"]) - .map(|e| e.params["library"].clone()) - .collect(); - assert!(gradients.len() >= 5); - assert_eq!(gradients, listed("gradients")); - let defaults = crate::menus::menu_entries(&app).into_iter().any(|e| e.command.as_deref() == Some("swatch.resetDefaults")); - assert!(defaults, "Default Swatches is listed"); - } - - #[test] - fn save_dialog_writes_user_libraries_that_the_window_menu_lists() { - let dir = std::env::temp_dir().join(format!("vc-library-panel-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let mut app = app(); - app.session.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); - let user_items = |app: &VectorcraftApp| -> Vec<(String, String)> { - crate::menus::menu_entries(app) - .into_iter() - .filter(|e| e.path == ["Window", "Swatch Libraries", "User Defined"]) - .map(|e| (e.label, e.command.unwrap_or_default())) - .collect() - }; - assert!(user_items(&app).is_empty(), "empty slots are hidden"); - app.run("ui.saveSwatchLibrary", json!({"names": ["Brights"]})).unwrap(); - let d = app.ui.dialog.as_mut().expect("Save Swatch Library opens"); - assert_eq!((d.kind.as_str(), d.bool("user")), (crate::dialogs::save_swatch_library::KIND, true)); - d.fields.insert("name".into(), json!("Loud")); - d.fields.insert("selectedOnly".into(), json!(true)); - let ctx = context(); - let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))), ..Default::default() }; - ctx.run_ui(input, |ui| crate::dialogs::show(&mut app, ui.ctx())).textures_delta.clear(); - assert!(app.ui.dialog.is_some(), "the dialog draws and stays open"); - crate::dialogs::confirm(&mut app).unwrap(); - assert!(app.ui.dialog.is_none()); - assert_eq!(user_items(&app), [("Loud".to_string(), "window.userSwatchLibrary1".to_string())]); - app.run("window.userSwatchLibrary1", json!({})).unwrap(); - assert_eq!(last_open(&app.ui).map(|p| p.id.as_str()), Some("user/Loud.vcswatches")); - let (_, lib) = SwatchLibraries::get(&app, "user/Loud.vcswatches").unwrap(); - assert_eq!((lib.groups.len(), lib.len()), (1, 5), "only the selected group"); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn saving_to_a_file_and_opening_a_palette_file() { - let written = std::rc::Rc::new(std::cell::RefCell::new(vec![])); - let w = written.clone(); - let services = crate::Services { - pick_save: Some(Box::new(|p: &crate::FilePick| Some(format!("/tmp/{}", p.name)))), - write: Some(Box::new(move |p: &str, b: &[u8]| { - w.borrow_mut().push((p.to_string(), b.to_vec())); - Ok(()) - })), - ..Default::default() - }; - let mut app = VectorcraftApp::new(Session::new(), services); - app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); - app.run("ui.saveSwatchLibrary", json!({})).unwrap(); - let d = app.ui.dialog.as_mut().unwrap(); - assert!(!d.bool("user") && !d.bool("__user"), "no user folder: a file"); - d.fields.insert("format".into(), json!("gpl")); - crate::dialogs::confirm(&mut app).unwrap(); - let (path, bytes) = written.borrow()[0].clone(); - assert!(path.ends_with(".gpl"), "{path}"); - let text = String::from_utf8(bytes).unwrap(); - assert!( - text.starts_with( - "GIMP Palette -" - ) && text.contains( - " Red -" - ), - "{text}" - ); - // Opening a palette file opens it in the library panel. - crate::io::open_bytes(&mut app, "Saved.gpl", text.as_bytes(), None).unwrap(); - let open = last_open(&app.ui).cloned().unwrap(); - assert!(open.id.starts_with("loaded/"), "{open:?}"); - assert_eq!(app.session.documents().len(), 1, "not opened as a document"); - let other = SwatchLibraries::list(&app).into_iter().find(|l| l.id == open.id).unwrap(); - assert_eq!(other.submenu, Some("Other Libraries")); - // Built-in libraries' names are interface labels; a file's is shown as it is. - for l in SwatchLibraries::list(&app) { - assert_eq!(builtin_library(&l.id), !matches!(l.submenu, Some("User Defined" | "Other Libraries")), "{}", l.id); - } - assert!(!builtin_library(&open.id) && builtin_library(vectorcraft_engine::cmd::swatchlib::DOCUMENT_SWATCHES)); - } - - #[test] - fn the_save_dialog_writes_swatch_exchange_files() { - let written = std::rc::Rc::new(std::cell::RefCell::new(vec![])); - let w = written.clone(); - let services = crate::Services { - pick_save: Some(Box::new(|p: &crate::FilePick| Some(format!("/tmp/{}", p.name)))), - write: Some(Box::new(move |p: &str, b: &[u8]| { - w.borrow_mut().push((p.to_string(), b.to_vec())); - Ok(()) - })), - ..Default::default() - }; - let mut app = VectorcraftApp::new(Session::new(), services); - app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); - app.run("ui.saveSwatchLibrary", json!({})).unwrap(); - let d = app.ui.dialog.as_mut().unwrap(); - d.fields.insert("format".into(), json!("ase")); - d.fields.insert("name".into(), json!("Brand Kit")); - let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); - assert!(text.contains("Swatch Exchange (.ase)") && text.contains("Swatch exchange files keep solid colors only."), "{text}"); - crate::dialogs::confirm(&mut app).unwrap(); - let (path, bytes) = written.borrow()[0].clone(); - assert_eq!(path, "/tmp/Brand Kit.ase", "the save panel suggests the name"); - let lib = vectorcraft_color::palette_io::read_bytes(&bytes, "x").unwrap(); - let solid = app.session.doc().unwrap().doc.swatches_iter().filter(|w| w.paint.color().is_some()).count(); - assert_eq!(lib.len(), solid, "every solid color of the document"); - // The web downloads the file under that name. - let downloads = std::rc::Rc::new(std::cell::RefCell::new(vec![])); - let dl = downloads.clone(); - let services = crate::Services { - download: Some(Box::new(move |name: &str, _: &[u8]| { - dl.borrow_mut().push(name.to_string()); - Ok(()) - })), - ..Default::default() - }; - let mut web = VectorcraftApp::new(Session::new(), services); - web.run("file.new", json!({"width": 100, "height": 100})).unwrap(); - web.run("ui.saveSwatchLibrary", json!({})).unwrap(); - let d = web.ui.dialog.as_mut().unwrap(); - d.fields.insert("format".into(), json!("ase")); - d.fields.insert("name".into(), json!(" Brand: Kit. ")); - crate::dialogs::confirm(&mut web).unwrap(); - assert_eq!(*downloads.borrow(), ["Brand- Kit.ase"], "the name made safe as a file name, as for User Defined"); - } - - #[test] - fn swatch_exchange_files_without_a_path_open_in_the_library_panel() { - // A file opened on the web has no path; open_bytes passes its bytes as dataBase64. - let mut app = app(); - crate::io::open_bytes(&mut app, "Brand.ase", &vectorcraft_testkit::ase::sample(), None).unwrap(); - let open = last_open(&app.ui).cloned().unwrap(); - assert_eq!(open.id, "loaded/Brand.ase"); - let (_, lib) = SwatchLibraries::get(&app, &open.id).unwrap(); - assert_eq!((lib.len(), lib.groups.len()), (4, 1)); - assert_eq!(app.session.documents().len(), 1, "not opened as a document"); - } - - fn docking(app: &VectorcraftApp) -> &craft_ui::docking::Layout { - app.ui.docking.as_ref().expect("library panels live in the shared layout") - } - - fn panel_of(reply: Value) -> String { - reply["panel"].as_str().expect("the reply names the panel").to_string() - } - - fn groups(app: &VectorcraftApp) -> Vec> { - docking(app).floating.iter().map(crate::panel_docking::window_panels).collect() - } - - #[test] - fn each_library_opens_in_a_panel_of_its_own() { - let mut app = app(); - // Without the shared layout yet, `window.panel` floats a library panel too. - app.run("window.panel", json!({"panel": "library:swatches:earth-tones"})).unwrap(); - assert!(is_open(&app.ui, "library:swatches:earth-tones")); - app.run("window.panel.close", json!({"panel": "library:swatches:earth-tones"})).unwrap(); - let pastels = panel_of(app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap()); - let brights = panel_of(app.run("window.swatchLibrary", json!({"library": "Brights"})).unwrap()); - let styles = panel_of(app.run("window.graphicStyleLibrary", json!({"library": "hand-drawn"})).unwrap()); - assert_eq!([&pastels, &brights, &styles], ["library:swatches:pastels", "library:swatches:brights", "library:graphicStyles:hand-drawn"]); - assert_eq!(groups(&app), [vec![pastels.clone()], vec![brights.clone()], vec![styles.clone()]], "each floats on its own"); - assert_ne!(docking(&app).floating[0].rect, docking(&app).floating[1].rect, "one beside another"); - assert_eq!(tab_label(&app, &brights).as_deref(), Some("Brights")); - // Opening an open library brings its panel forward. - app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap(); - assert_eq!(groups(&app).len(), 3); - assert_eq!(last_open(&app.ui).map(LibraryPanel::panel), Some(pastels.clone())); - // They group, dock and close as any panel, and `window.panel` shows one again. - app.run("window.panel.move", json!({"panel": brights, "anchor": pastels})).unwrap(); - assert!(groups(&app).contains(&vec![pastels.clone(), brights.clone()]), "{:?}", groups(&app)); - app.run("window.panel.dock", json!({"panel": styles})).unwrap(); - assert!(craft_ui::docking::Layout { root: docking(&app).root.clone(), floating: vec![] }.contains(&styles)); - app.run("window.panel.close", json!({"panel": brights})).unwrap(); - assert!(!is_open(&app.ui, &brights) && is_open(&app.ui, &pastels)); - app.run("window.panel", json!({"panel": brights})).unwrap(); - assert!(is_open(&app.ui, &brights)); - assert!(crate::panel_docking::valid(docking(&app))); - // `library: null` closes the panels of its kind. - app.run("window.swatchLibrary", json!({"library": null})).unwrap(); - assert!(!any_open::(&app.ui) && is_open(&app.ui, &styles)); - // What isn't a library panel's id. - for bad in ["library:nope:x", "library:swatches:", "library:swatches", "Library:swatches:pastels"] { - assert!(crate::menus::normalize_panel(bad).is_none(), "{bad}"); - } - assert!(app.run("ui.libraryPanel", json!({"panel": "layers"})).is_err()); - assert!(app.run("ui.libraryPanel", json!({"panel": pastels, "persistent": true})).is_err(), "closed and not persistent"); - assert!(app.run("ui.libraryPanel", json!({"panel": styles, "view": "huge"})).is_err()); - } - - /// A context whose frames report their accessibility tree. - fn accessible() -> egui::Context { - let ctx = context(); - ctx.enable_accesskit(); - ctx - } - - /// One frame of `draw` with `events`, after the frames `ctx` ran. - fn draw_frame( - app: &mut VectorcraftApp, - ctx: &egui::Context, - draw: &dyn Fn(&mut VectorcraftApp, &mut Ui), - events: Vec, - ) -> egui::FullOutput { - let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))); - let time = Some(ctx.input(|i| i.time) + 0.1); - let mut out = ctx.run_ui(egui::RawInput { events, time, screen_rect, ..Default::default() }, |ui| draw(app, ui)); - out.textures_delta.clear(); - out - } - - /// Whether the control `draw` shows that screen readers name `label` is enabled, and its center. - fn control(app: &mut VectorcraftApp, ctx: &egui::Context, draw: &dyn Fn(&mut VectorcraftApp, &mut Ui), label: &str) -> (bool, Pos2) { - draw_frame(app, ctx, draw, vec![]); - let out = draw_frame(app, ctx, draw, vec![]); - let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; - let (_, node) = nodes.iter().find(|(_, n)| n.label().map(str::trim) == Some(label)).unwrap_or_else(|| panic!("no control named {label:?}")); - let b = node.bounds().expect("bounds"); - (!node.is_disabled(), Pos2::new((b.x0 + b.x1) as f32 * 0.5, (b.y0 + b.y1) as f32 * 0.5)) - } - - /// A click at `at` in a frame of `draw`. - fn click_at(app: &mut VectorcraftApp, ctx: &egui::Context, draw: &dyn Fn(&mut VectorcraftApp, &mut Ui), at: Pos2) { - let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; - draw_frame(app, ctx, draw, vec![Event::PointerMoved(at)]); - draw_frame(app, ctx, draw, vec![button(true), button(false)]); - } - - /// The app restarted with the preferences `app` saves. - fn restarted(app: &VectorcraftApp) -> VectorcraftApp { - let saved: crate::UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap(); - let mut next = VectorcraftApp::new(Session::new(), Default::default()); - next.ui = saved.sanitized(); - restore(&mut next); - next - } - - #[test] - fn persistent_panels_reopen_at_startup_with_their_settings() { - let dir = std::env::temp_dir().join(format!("vc-persistent-libraries-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let gpl = dir.join("Brand.gpl"); - std::fs::write(&gpl, "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n").unwrap(); - let mut app = app(); - let pastels = panel_of(app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap()); - let brights = panel_of(app.run("window.swatchLibrary", json!({"library": "brights"})).unwrap()); - let brand = panel_of(app.run("window.swatchLibrary.other", json!({"path": gpl.to_string_lossy()})).unwrap()); - let r = app.run("ui.libraryPanel", json!({"panel": pastels, "persistent": true, "view": "largeList", "showFind": false})).unwrap(); - assert_eq!((r["persistent"].as_bool(), r["view"].as_str(), r["showFind"].as_bool()), (Some(true), Some("largeList"), Some(false))); - app.run("ui.libraryPanel", json!({"panel": brand, "persistent": true})).unwrap(); - app.run("window.panel.float", json!({"panel": pastels, "x": 40, "y": 90})).unwrap(); - // A persistent panel reopens even when it was closed. - app.run("window.panel.close", json!({"panel": brand})).unwrap(); - let next = restarted(&app); - assert!(is_open(&next.ui, &pastels) && is_open(&next.ui, &brand), "{}", next.ui.status); - assert!(!is_open(&next.ui, &brights), "a panel that isn't persistent closed with the app"); - let group = docking(&next).floating.iter().find(|g| g.contains(&pastels)).unwrap(); - assert_eq!(group.rect[..2], [40.0, 90.0], "where it was"); - let s = settings(&next.ui, "swatches", "pastels").unwrap(); - assert_eq!((s.view, s.hide_find, s.persistent), (Some(View::LargeList), true, true)); - let (_, id) = parse(&brand).unwrap(); - assert!(SwatchLibraries::get(&next, id).is_some_and(|(_, lib)| lib.swatch("Red").is_some()), "read again from its file"); - assert!(next.ui.status.is_empty(), "{}", next.ui.status); - assert!(next.ui.docking_hidden.keys().all(|p| p != &brights)); - // Unchecked, a panel no longer reopens; one whose file is gone reopens as missing, with a note. - let mut next = next; - next.run("ui.libraryPanel", json!({"panel": pastels, "persistent": false})).unwrap(); - std::fs::remove_file(&gpl).unwrap(); - let last = restarted(&next); - assert!(!is_open(&last.ui, &pastels) && is_open(&last.ui, &brand)); - assert!(last.ui.status.contains("Brand.gpl"), "{}", last.ui.status); - assert_eq!(last.ui.library_panels.iter().map(LibraryPanel::panel).collect::>(), [brand]); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn a_persistent_panel_whose_file_is_gone_is_listed_as_missing_until_located() { - let dir = std::env::temp_dir().join(format!("vc-missing-library-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("moved")).unwrap(); - let palette = "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n"; - let gpl = dir.join("Brand.gpl"); - std::fs::write(&gpl, palette).unwrap(); - let mut app = app(); - let brand = panel_of(app.run("window.swatchLibrary.other", json!({"path": gpl.to_string_lossy()})).unwrap()); - app.run("ui.libraryPanel", json!({"panel": brand, "persistent": true, "view": "smallList"})).unwrap(); - std::fs::remove_file(&gpl).unwrap(); - // The panel reopens showing its library as missing, and the preferences keep it. - let mut next = restarted(&app); - tidy(&mut next); - assert!(is_open(&next.ui, &brand), "{}", next.ui.status); - assert!(next.ui.status.contains("Brand.gpl"), "{}", next.ui.status); - let (_, gone) = parse(&brand).unwrap(); - let s = settings(&next.ui, "swatches", gone).cloned().unwrap(); - assert_eq!((s.path, s.persistent, s.view), (Some(gpl.to_string_lossy().into_owned()), true, Some(View::SmallList))); - assert_eq!(tab_label(&next, &brand).as_deref(), Some("Brand")); - // Closed, it opens again by its library's id. - next.run("window.panel.close", json!({"panel": brand})).unwrap(); - let r = next.run("window.swatchLibrary", json!({"library": gone})).unwrap(); - assert_eq!((r["panel"].as_str(), r["name"].as_str(), r["count"].as_u64()), (Some(brand.as_str()), Some("Brand"), Some(0))); - assert!(is_open(&next.ui, &brand)); - let text = crate::tests_labels::painted_text(&mut next, |app, ui| show(app, ui, &brand)); - let shown = format!("Missing: {}", gpl.to_string_lossy()); - assert!(text.contains(&shown), "{text}"); - let ctx = accessible(); - let body = |app: &mut VectorcraftApp, ui: &mut Ui| show(app, ui, &brand); - assert!(!control(&mut next, &ctx, &body, "Locate Library…").0, "dimmed without an open dialog"); - let panel_menu = |app: &mut VectorcraftApp, ui: &mut Ui| menu(app, ui, &brand); - assert!(!control(&mut next, &ctx, &panel_menu, "Next Library").0, "no library to step from"); - let mut next = restarted(&next); - assert!(is_open(&next.ui, &brand), "still listed after another restart"); - // Locate Library… reads the file where it is now and shows it in the same panel. - assert!(next.run("ui.libraryPanel.locate", json!({"panel": "layers"})).is_err()); - let moved = dir.join("moved").join("Brand.gpl"); - std::fs::write(&moved, palette).unwrap(); - let picked = moved.to_string_lossy().into_owned(); - next.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))); - let (enabled, at) = control(&mut next, &ctx, &body, "Locate Library…"); - assert!(enabled); - click_at(&mut next, &ctx, &body, at); - let located = panel_id("swatches", &format!("loaded/{}", moved.to_string_lossy())); - assert!(!is_open(&next.ui, &brand) && is_open(&next.ui, &located), "{}", next.ui.status); - let (_, id) = parse(&located).unwrap(); - assert!(SwatchLibraries::get(&next, id).is_some_and(|(_, lib)| lib.swatch("Red").is_some())); - let s = settings(&next.ui, "swatches", id).cloned().unwrap(); - assert_eq!((s.path, s.persistent, s.view), (Some(moved.to_string_lossy().into_owned()), true, Some(View::SmallList))); - assert!(settings(&next.ui, "swatches", gone).is_none()); - let located_menu = |app: &mut VectorcraftApp, ui: &mut Ui| menu(app, ui, &located); - assert!(control(&mut next, &ctx, &located_menu, "Next Library").0); - let last = restarted(&next); - assert!(is_open(&last.ui, &located) && last.ui.status.is_empty(), "{}", last.ui.status); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn locating_a_library_open_in_its_own_panel_brings_that_panel_forward() { - let dir = std::env::temp_dir().join(format!("vc-locate-open-library-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let palette = "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n"; - let gpl = dir.join("Brand.gpl"); - std::fs::write(&gpl, palette).unwrap(); - let mut app = app(); - let brand = panel_of(app.run("window.swatchLibrary.other", json!({"path": gpl.to_string_lossy()})).unwrap()); - app.run("ui.libraryPanel", json!({"panel": brand, "persistent": true})).unwrap(); - std::fs::remove_file(&gpl).unwrap(); - let mut next = restarted(&app); - let copy = dir.join("Copy.gpl"); - std::fs::write(©, palette).unwrap(); - let other = panel_of(next.run("window.swatchLibrary.other", json!({"path": copy.to_string_lossy()})).unwrap()); - next.run("window.panel.close", json!({"panel": brand})).unwrap(); - assert!(next.run("ui.libraryPanel.locate", json!({"panel": brand, "path": copy.to_string_lossy()})).is_err(), "a closed panel"); - next.run("window.panel", json!({"panel": brand})).unwrap(); - let r = next.run("ui.libraryPanel.locate", json!({"panel": brand, "path": copy.to_string_lossy()})).unwrap(); - assert_eq!((r["panel"].as_str(), r["persistent"].as_bool()), (Some(other.as_str()), Some(true))); - assert!(!is_open(&next.ui, &brand) && is_open(&next.ui, &other)); - assert!(next.ui.library_panels.iter().all(|p| p.panel() != brand), "the missing panel is forgotten"); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn locate_library_leaves_a_panel_whose_library_is_there_alone() { - let dir = std::env::temp_dir().join(format!("vc-locate-found-library-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let gpl = dir.join("Brand.gpl"); - std::fs::write(&gpl, "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n").unwrap(); - let mut app = app(); - let pastels = panel_of(app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap()); - for persistent in [false, true] { - app.run("ui.libraryPanel", json!({"panel": pastels, "persistent": persistent})).unwrap(); - let e = app.run("ui.libraryPanel.locate", json!({"panel": pastels, "path": gpl.to_string_lossy()})).unwrap_err(); - assert!(e.contains("isn't missing its library"), "{e}"); - assert!(is_open(&app.ui, &pastels), "persistent: {persistent}"); - assert_eq!(settings(&app.ui, "swatches", "pastels").map(|s| s.persistent), Some(persistent)); - } - assert!(SwatchLibraries::get(&app, &format!("loaded/{}", gpl.to_string_lossy())).is_none(), "the file isn't loaded"); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn locate_library_reads_a_missing_library_whose_panel_is_no_longer_persistent() { - let dir = std::env::temp_dir().join(format!("vc-locate-not-persistent-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("moved")).unwrap(); - let palette = "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n"; - let gpl = dir.join("Brand.gpl"); - std::fs::write(&gpl, palette).unwrap(); - let mut app = app(); - let brand = panel_of(app.run("window.swatchLibrary.other", json!({"path": gpl.to_string_lossy()})).unwrap()); - app.run("ui.libraryPanel", json!({"panel": brand, "persistent": true})).unwrap(); - std::fs::remove_file(&gpl).unwrap(); - let mut next = restarted(&app); - // After a command turns Persistent off, the panel stays open on its missing library until the next frame's tidy. - next.run("ui.libraryPanel", json!({"panel": brand, "persistent": false})).unwrap(); - assert!(is_open(&next.ui, &brand)); - let moved = dir.join("moved").join("Brand.gpl"); - std::fs::write(&moved, palette).unwrap(); - let r = next.run("ui.libraryPanel.locate", json!({"panel": brand, "path": moved.to_string_lossy()})).unwrap(); - let located = panel_id("swatches", &format!("loaded/{}", moved.to_string_lossy())); - assert_eq!((r["panel"].as_str(), r["persistent"].as_bool()), (Some(located.as_str()), Some(false))); - assert!(!is_open(&next.ui, &brand) && is_open(&next.ui, &located), "{}", next.ui.status); - let (_, id) = parse(&located).unwrap(); - assert!(SwatchLibraries::get(&next, id).is_some_and(|(_, lib)| lib.swatch("Red").is_some())); - tidy(&mut next); - assert!(is_open(&next.ui, &located), "its library is there now"); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn an_open_panel_whose_library_is_missing_is_brought_forward_by_its_id_when_not_persistent() { - let dir = std::env::temp_dir().join(format!("vc-open-not-persistent-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let gpl = dir.join("Brand.gpl"); - std::fs::write(&gpl, "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n").unwrap(); - let mut app = app(); - let brand = panel_of(app.run("window.swatchLibrary.other", json!({"path": gpl.to_string_lossy()})).unwrap()); - app.run("ui.libraryPanel", json!({"panel": brand, "persistent": true})).unwrap(); - std::fs::remove_file(&gpl).unwrap(); - let mut next = restarted(&app); - // After a command turns Persistent off, the panel stays open on its missing library until the next frame's tidy. - next.run("ui.libraryPanel", json!({"panel": brand, "persistent": false})).unwrap(); - let (_, gone) = parse(&brand).unwrap(); - let r = next.run("window.swatchLibrary", json!({"library": gone})).unwrap(); - assert_eq!((r["panel"].as_str(), r["name"].as_str(), r["count"].as_u64()), (Some(brand.as_str()), Some("Brand"), Some(0))); - assert!(is_open(&next.ui, &brand)); - tidy(&mut next); - assert!(!is_open(&next.ui, &brand), "a panel that isn't persistent still closes"); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn previous_and_next_show_the_neighbouring_library_in_the_same_panel() { - let mut app = app(); - let first = panel_of(app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap()); - app.run("window.panel.float", json!({"panel": first, "x": 50, "y": 60})).unwrap(); - app.run("ui.libraryPanel", json!({"panel": first, "persistent": true, "view": "smallList"})).unwrap(); - step::(&mut app, "pastels", 1); - let next = panel_id("swatches", SWATCH_IDS[5]); - assert!(!is_open(&app.ui, &first) && is_open(&app.ui, &next), "{}", app.ui.status); - let group = docking(&app).floating.iter().find(|g| g.contains(&next)).unwrap(); - assert_eq!((group.panels().count(), group.rect[0], group.rect[1]), (1, 50.0, 60.0), "in its place"); - let s = settings(&app.ui, "swatches", SWATCH_IDS[5]).unwrap(); - assert_eq!((s.view, s.persistent), (Some(View::SmallList), true), "the panel keeps its settings"); - assert!(settings(&app.ui, "swatches", "pastels").is_none()); - assert!(crate::panel_docking::valid(docking(&app))); - // A library open in a panel of its own is brought forward instead. - app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap(); - let r = app.run("ui.libraryPanel", json!({"panel": next, "library": "Pastels"})).unwrap(); - assert_eq!(r["panel"], json!(first)); - assert!(is_open(&app.ui, &next) && is_open(&app.ui, &first)); - } - - #[test] - fn a_panel_closes_when_its_library_is_gone() { - let dir = std::env::temp_dir().join(format!("vc-gone-library-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("Gone.gpl"), "GIMP Palette\nName: Gone\n#\n0 0 0\tInk\n").unwrap(); - std::fs::write(dir.join("Kept.gpl"), "GIMP Palette\nName: Kept\n#\n0 0 0\tInk\n").unwrap(); - let mut app = app(); - app.session.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); - let panel = panel_of(app.run("window.swatchLibrary", json!({"library": "user/Gone.gpl"})).unwrap()); - let kept = panel_of(app.run("window.swatchLibrary", json!({"library": "user/Kept.gpl"})).unwrap()); - app.run("ui.libraryPanel", json!({"panel": kept, "persistent": true})).unwrap(); - tidy(&mut app); - assert!(is_open(&app.ui, &panel)); - std::fs::remove_file(dir.join("Gone.gpl")).unwrap(); - std::fs::remove_file(dir.join("Kept.gpl")).unwrap(); - app.session.swatch_libraries.rescan(); - tidy(&mut app); - assert!(!is_open(&app.ui, &panel)); - // A persistent panel stays open and shows its library as missing. - assert!(is_open(&app.ui, &kept)); - assert_eq!(tab_label(&app, &kept).as_deref(), Some("Kept")); - let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui, &kept)); - assert!(text.contains("Missing: user/Kept.gpl"), "{text}"); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn the_panel_menu_has_persistent() { - let mut app = app(); - let panel = panel_of(app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap()); - let text = crate::tests_labels::painted_text(&mut app, |app, ui| menu(app, ui, &panel)); - for item in ["Add to Swatches", "Small List View", "Show Find Field", "Previous Library", "Persistent", "Close Library"] { - assert!(text.contains(item), "{item}: {text}"); - } - } -} +mod tests; diff --git a/crates/ui-egui/src/panels/library_panel/tests.rs b/crates/ui-egui/src/panels/library_panel/tests.rs new file mode 100644 index 000000000..ac01d4f44 --- /dev/null +++ b/crates/ui-egui/src/panels/library_panel/tests.rs @@ -0,0 +1,656 @@ +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use serde_json::Value; +use vectorcraft_color::Paint; +use vectorcraft_engine::Session; + +use super::*; +use crate::panels::swatches::SwatchLibraries; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app +} + +fn context() -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx +} + +/// One frame of the library panel with `events` and `modifiers` held. +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, modifiers: Modifiers, time: f64) { + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))); + let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect(); + let input = egui::RawInput { events, time: Some(time), screen_rect, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| show_window(app, ui.ctx())); + out.textures_delta.clear(); +} + +/// The panel opened last. +fn shown(app: &VectorcraftApp) -> String { + last_open(&app.ui).expect("a library panel is open").panel() +} + +/// Click the tile or row of `name` in the panel opened last with `modifiers` (frames from +/// `time` to `time + 0.5`; a new window takes a sizing frame first). +fn click(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str, modifiers: Modifiers, time: f64) { + frame(app, ctx, vec![], Modifiers::NONE, time); + frame(app, ctx, vec![], Modifiers::NONE, time + 0.25); + let at = ctx.read_response(tile_id(&shown(app), name)).unwrap_or_else(|| panic!("no tile for {name}")).rect.center(); + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers }; + frame(app, ctx, vec![Event::PointerMoved(at), button(true), button(false)], modifiers, time + 0.5); +} + +fn undo_len(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().history.undo.len() +} + +#[test] +fn clicking_a_library_swatch_adds_and_applies_it_in_one_step() { + let mut app = app(); + let ctx = context(); + let r = app.run("window.swatchLibrary", json!({"library": "Pastels"})).unwrap(); + assert_eq!((r["open"].as_str(), r["count"].as_u64(), r["panel"].as_str()), (Some("pastels"), Some(24), Some("library:swatches:pastels"))); + assert_eq!(last_open(&app.ui).map(|p| (p.kind.as_str(), p.id.as_str())), Some(("swatches", "pastels"))); + let undo = undo_len(&app); + click(&mut app, &ctx, "Pastel Teal", Modifiers::NONE, 0.0); + let d = &app.session.doc().unwrap().doc; + let added = d.swatch("Pastel Teal").expect("added to the document"); + assert_eq!(app.session.paint.fill, Paint::solid(added.paint.color().unwrap()), "applied to the fill"); + assert_eq!(undo_len(&app), undo + 1); + // Alt-click paints the stroke (the inactive proxy). + click(&mut app, &ctx, "Soft Blue", Modifiers::ALT, 2.0); + let blue = app.session.doc().unwrap().doc.swatch("Soft Blue").and_then(|w| w.paint.color()).unwrap(); + assert_eq!(app.session.paint.stroke.color(), Some(blue)); + assert!(app.session.fill_active, "Alt keeps the active proxy"); + // A library that doesn't exist is an error; null closes the panel. + assert!(app.run("window.swatchLibrary", json!({"library": "nope"})).is_err()); + app.run("window.swatchLibrary", json!({"library": null})).unwrap(); + assert!(last_open(&app.ui).is_none()); +} + +#[test] +fn modifier_clicks_select_and_add_to_swatches_adds_the_selection() { + let mut app = app(); + let ctx = context(); + app.run("window.swatchLibrary", json!({"library": "earth-tones"})).unwrap(); + click(&mut app, &ctx, "Clay 1", Modifiers::COMMAND, 0.0); + click(&mut app, &ctx, "Ochre", Modifiers::NONE, 2.0); + assert_eq!(selection(&ctx, "library:swatches:earth-tones"), ["Ochre"], "a folder click selects its group"); + click(&mut app, &ctx, "Clay 3", Modifiers::COMMAND, 4.0); + assert!(app.session.doc().unwrap().doc.swatch("Clay 3").is_none(), "modifier clicks only select"); + let sel = selection(&ctx, "library:swatches:earth-tones"); + SwatchLibraries::add(&mut app, "earth-tones", sel); + let d = &app.session.doc().unwrap().doc; + assert_eq!(d.swatch_groups.iter().find(|g| g.name == "Ochre").map(|g| g.swatches.len()), Some(6)); + assert!(d.swatches.iter().any(|w| w.name == "Clay 3")); + // The selection belongs to its library's panel. + app.run("window.swatchLibrary", json!({"library": "brights"})).unwrap(); + assert!(selection(&ctx, "library:swatches:brights").is_empty()); +} + +#[test] +fn a_plain_click_sets_where_a_shift_click_range_starts() { + let mut app = app(); + let ctx = context(); + app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap(); + let (_, lib) = SwatchLibraries::get(&app, "pastels").unwrap(); + let order: Vec = SwatchLibraries::rows(&lib, "").iter().map(|r| r.name.to_string()).collect(); + click(&mut app, &ctx, &order[1], Modifiers::NONE, 0.0); + click(&mut app, &ctx, &order[4], Modifiers::SHIFT, 2.0); + assert_eq!(selection(&ctx, "library:swatches:pastels"), order[1..=4], "the range starts at the plain click"); + // A plain click after a Cmd/Ctrl-click moves the anchor from the Cmd/Ctrl-clicked item. + click(&mut app, &ctx, &order[8], Modifiers::COMMAND, 4.0); + click(&mut app, &ctx, &order[2], Modifiers::NONE, 6.0); + click(&mut app, &ctx, &order[3], Modifiers::SHIFT, 8.0); + assert_eq!(selection(&ctx, "library:swatches:pastels"), order[2..=3]); + let d = &app.session.doc().unwrap().doc; + assert!(d.swatch(&order[2]).is_some(), "a plain click still adds the swatch"); + assert!(d.swatch(&order[3]).is_none(), "a Shift-click only selects"); +} + +#[test] +fn find_filters_list_view_and_arrows_step_through_the_libraries() { + let mut app = app(); + let ctx = context(); + app.run("window.swatchLibrary", json!({"library": "web-safe-216"})).unwrap(); + let panel = "library:swatches:web-safe-216"; + app.run("ui.libraryPanel", json!({"panel": panel, "view": "smallList"})).unwrap(); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(key(panel, "find")), "#FF00".to_string())); + frame(&mut app, &ctx, vec![], Modifiers::NONE, 0.0); + frame(&mut app, &ctx, vec![], Modifiers::NONE, 0.5); + assert!(ctx.read_response(tile_id(panel, "#FF0033")).is_some()); + assert!(ctx.read_response(tile_id(panel, "#00FF00")).is_none()); + let first = SWATCH_IDS[0]; + let last = SwatchLibraries::list(&app).pop().unwrap().id; + assert_eq!(last, "radial-glows", "gradient libraries come after the swatch libraries"); + assert_eq!(neighbour::(&app, first, -1), Some(last), "wraps around"); + assert_eq!(neighbour::(&app, "web-safe-216", 1).as_deref(), Some(SWATCH_IDS[1])); +} + +const SWATCH_IDS: [&str; 9] = [ + "web-safe-216", + "grays-neutrals", + "earth-tones", + "skin-tone-ramps", + "pastels", + "brights", + "metallic-gradients", + "perceptual-scales", + "harmony-sets", +]; + +#[test] +fn the_window_submenu_lists_every_library() { + let app = app(); + let ids: Vec = crate::menus::menu_entries(&app) + .into_iter() + .filter(|e| e.path == ["Window", "Swatch Libraries"] && e.command.as_deref() == Some("window.swatchLibrary")) + .map(|e| e.params["library"].clone()) + .collect(); + let all = vectorcraft_engine::cmd::swatchlib::libraries(&app.session); + let listed = |category: &str| all.iter().filter(|l| l.category == category).map(|l| json!(l.id)).collect::>(); + assert_eq!(ids, listed("builtIn")); + assert_eq!(ids, SWATCH_IDS.map(|i| json!(i))); + let gradients: Vec = crate::menus::menu_entries(&app) + .into_iter() + .filter(|e| e.path == ["Window", "Swatch Libraries", "Gradients"]) + .map(|e| e.params["library"].clone()) + .collect(); + assert!(gradients.len() >= 5); + assert_eq!(gradients, listed("gradients")); + let defaults = crate::menus::menu_entries(&app).into_iter().any(|e| e.command.as_deref() == Some("swatch.resetDefaults")); + assert!(defaults, "Default Swatches is listed"); +} + +#[test] +fn save_dialog_writes_user_libraries_that_the_window_menu_lists() { + let dir = std::env::temp_dir().join(format!("vc-library-panel-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut app = app(); + app.session.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + let user_items = |app: &VectorcraftApp| -> Vec<(String, String)> { + crate::menus::menu_entries(app) + .into_iter() + .filter(|e| e.path == ["Window", "Swatch Libraries", "User Defined"]) + .map(|e| (e.label, e.command.unwrap_or_default())) + .collect() + }; + assert!(user_items(&app).is_empty(), "empty slots are hidden"); + app.run("ui.saveSwatchLibrary", json!({"names": ["Brights"]})).unwrap(); + let d = app.ui.dialog.as_mut().expect("Save Swatch Library opens"); + assert_eq!((d.kind.as_str(), d.bool("user")), (crate::dialogs::save_swatch_library::KIND, true)); + d.fields.insert("name".into(), json!("Loud")); + d.fields.insert("selectedOnly".into(), json!(true)); + let ctx = context(); + let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))), ..Default::default() }; + ctx.run_ui(input, |ui| crate::dialogs::show(&mut app, ui.ctx())).textures_delta.clear(); + assert!(app.ui.dialog.is_some(), "the dialog draws and stays open"); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(user_items(&app), [("Loud".to_string(), "window.userSwatchLibrary1".to_string())]); + app.run("window.userSwatchLibrary1", json!({})).unwrap(); + assert_eq!(last_open(&app.ui).map(|p| p.id.as_str()), Some("user/Loud.vcswatches")); + let (_, lib) = SwatchLibraries::get(&app, "user/Loud.vcswatches").unwrap(); + assert_eq!((lib.groups.len(), lib.len()), (1, 5), "only the selected group"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn saving_to_a_file_and_opening_a_palette_file() { + let written = std::rc::Rc::new(std::cell::RefCell::new(vec![])); + let w = written.clone(); + let services = crate::Services { + pick_save: Some(Box::new(|p: &crate::FilePick| Some(format!("/tmp/{}", p.name)))), + write: Some(Box::new(move |p: &str, b: &[u8]| { + w.borrow_mut().push((p.to_string(), b.to_vec())); + Ok(()) + })), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("ui.saveSwatchLibrary", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + assert!(!d.bool("user") && !d.bool("__user"), "no user folder: a file"); + d.fields.insert("format".into(), json!("gpl")); + crate::dialogs::confirm(&mut app).unwrap(); + let (path, bytes) = written.borrow()[0].clone(); + assert!(path.ends_with(".gpl"), "{path}"); + let text = String::from_utf8(bytes).unwrap(); + assert!( + text.starts_with( + "GIMP Palette +" + ) && text.contains( + " Red +" + ), + "{text}" + ); + // Opening a palette file opens it in the library panel. + crate::io::open_bytes(&mut app, "Saved.gpl", text.as_bytes(), None).unwrap(); + let open = last_open(&app.ui).cloned().unwrap(); + assert!(open.id.starts_with("loaded/"), "{open:?}"); + assert_eq!(app.session.documents().len(), 1, "not opened as a document"); + let other = SwatchLibraries::list(&app).into_iter().find(|l| l.id == open.id).unwrap(); + assert_eq!(other.submenu, Some("Other Libraries")); + // Built-in libraries' names are interface labels; a file's is shown as it is. + for l in SwatchLibraries::list(&app) { + assert_eq!(builtin_library(&l.id), !matches!(l.submenu, Some("User Defined" | "Other Libraries")), "{}", l.id); + } + assert!(!builtin_library(&open.id) && builtin_library(vectorcraft_engine::cmd::swatchlib::DOCUMENT_SWATCHES)); +} + +#[test] +fn the_save_dialog_writes_swatch_exchange_files() { + let written = std::rc::Rc::new(std::cell::RefCell::new(vec![])); + let w = written.clone(); + let services = crate::Services { + pick_save: Some(Box::new(|p: &crate::FilePick| Some(format!("/tmp/{}", p.name)))), + write: Some(Box::new(move |p: &str, b: &[u8]| { + w.borrow_mut().push((p.to_string(), b.to_vec())); + Ok(()) + })), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("ui.saveSwatchLibrary", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("format".into(), json!("ase")); + d.fields.insert("name".into(), json!("Brand Kit")); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + assert!(text.contains("Swatch Exchange (.ase)") && text.contains("Swatch exchange files keep solid colors only."), "{text}"); + crate::dialogs::confirm(&mut app).unwrap(); + let (path, bytes) = written.borrow()[0].clone(); + assert_eq!(path, "/tmp/Brand Kit.ase", "the save panel suggests the name"); + let lib = vectorcraft_color::palette_io::read_bytes(&bytes, "x").unwrap(); + let solid = app.session.doc().unwrap().doc.swatches_iter().filter(|w| w.paint.color().is_some()).count(); + assert_eq!(lib.len(), solid, "every solid color of the document"); + // The web downloads the file under that name. + let downloads = std::rc::Rc::new(std::cell::RefCell::new(vec![])); + let dl = downloads.clone(); + let services = crate::Services { + download: Some(Box::new(move |name: &str, _: &[u8]| { + dl.borrow_mut().push(name.to_string()); + Ok(()) + })), + ..Default::default() + }; + let mut web = VectorcraftApp::new(Session::new(), services); + web.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + web.run("ui.saveSwatchLibrary", json!({})).unwrap(); + let d = web.ui.dialog.as_mut().unwrap(); + d.fields.insert("format".into(), json!("ase")); + d.fields.insert("name".into(), json!(" Brand: Kit. ")); + crate::dialogs::confirm(&mut web).unwrap(); + assert_eq!(*downloads.borrow(), ["Brand- Kit.ase"], "the name made safe as a file name, as for User Defined"); +} + +#[test] +fn swatch_exchange_files_without_a_path_open_in_the_library_panel() { + // A file opened on the web has no path; open_bytes passes its bytes as dataBase64. + let mut app = app(); + crate::io::open_bytes(&mut app, "Brand.ase", &vectorcraft_testkit::ase::sample(), None).unwrap(); + let open = last_open(&app.ui).cloned().unwrap(); + assert_eq!(open.id, "loaded/Brand.ase"); + let (_, lib) = SwatchLibraries::get(&app, &open.id).unwrap(); + assert_eq!((lib.len(), lib.groups.len()), (4, 1)); + assert_eq!(app.session.documents().len(), 1, "not opened as a document"); +} + +fn docking(app: &VectorcraftApp) -> &craft_ui::docking::Layout { + app.ui.docking.as_ref().expect("library panels live in the shared layout") +} + +fn panel_of(reply: Value) -> String { + reply["panel"].as_str().expect("the reply names the panel").to_string() +} + +fn groups(app: &VectorcraftApp) -> Vec> { + docking(app).floating.iter().map(crate::panel_docking::window_panels).collect() +} + +#[test] +fn each_library_opens_in_a_panel_of_its_own() { + let mut app = app(); + // Without the shared layout yet, `window.panel` floats a library panel too. + app.run("window.panel", json!({"panel": "library:swatches:earth-tones"})).unwrap(); + assert!(is_open(&app.ui, "library:swatches:earth-tones")); + app.run("window.panel.close", json!({"panel": "library:swatches:earth-tones"})).unwrap(); + let pastels = panel_of(app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap()); + let brights = panel_of(app.run("window.swatchLibrary", json!({"library": "Brights"})).unwrap()); + let styles = panel_of(app.run("window.graphicStyleLibrary", json!({"library": "hand-drawn"})).unwrap()); + assert_eq!([&pastels, &brights, &styles], ["library:swatches:pastels", "library:swatches:brights", "library:graphicStyles:hand-drawn"]); + assert_eq!(groups(&app), [vec![pastels.clone()], vec![brights.clone()], vec![styles.clone()]], "each floats on its own"); + assert_ne!(docking(&app).floating[0].rect, docking(&app).floating[1].rect, "one beside another"); + assert_eq!(tab_label(&app, &brights).as_deref(), Some("Brights")); + // Opening an open library brings its panel forward. + app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap(); + assert_eq!(groups(&app).len(), 3); + assert_eq!(last_open(&app.ui).map(LibraryPanel::panel), Some(pastels.clone())); + // They group, dock and close as any panel, and `window.panel` shows one again. + app.run("window.panel.move", json!({"panel": brights, "anchor": pastels})).unwrap(); + assert!(groups(&app).contains(&vec![pastels.clone(), brights.clone()]), "{:?}", groups(&app)); + app.run("window.panel.dock", json!({"panel": styles})).unwrap(); + assert!(craft_ui::docking::Layout { root: docking(&app).root.clone(), floating: vec![] }.contains(&styles)); + app.run("window.panel.close", json!({"panel": brights})).unwrap(); + assert!(!is_open(&app.ui, &brights) && is_open(&app.ui, &pastels)); + app.run("window.panel", json!({"panel": brights})).unwrap(); + assert!(is_open(&app.ui, &brights)); + assert!(crate::panel_docking::valid(docking(&app))); + // `library: null` closes the panels of its kind. + app.run("window.swatchLibrary", json!({"library": null})).unwrap(); + assert!(!any_open::(&app.ui) && is_open(&app.ui, &styles)); + // What isn't a library panel's id. + for bad in ["library:nope:x", "library:swatches:", "library:swatches", "Library:swatches:pastels"] { + assert!(crate::menus::normalize_panel(bad).is_none(), "{bad}"); + } + assert!(app.run("ui.libraryPanel", json!({"panel": "layers"})).is_err()); + assert!(app.run("ui.libraryPanel", json!({"panel": pastels, "persistent": true})).is_err(), "closed and not persistent"); + assert!(app.run("ui.libraryPanel", json!({"panel": styles, "view": "huge"})).is_err()); +} + +/// A context whose frames report their accessibility tree. +fn accessible() -> egui::Context { + let ctx = context(); + ctx.enable_accesskit(); + ctx +} + +/// One frame of `draw` with `events`, after the frames `ctx` ran. +fn draw_frame( + app: &mut VectorcraftApp, + ctx: &egui::Context, + draw: &dyn Fn(&mut VectorcraftApp, &mut Ui), + events: Vec, +) -> egui::FullOutput { + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))); + let time = Some(ctx.input(|i| i.time) + 0.1); + let mut out = ctx.run_ui(egui::RawInput { events, time, screen_rect, ..Default::default() }, |ui| draw(app, ui)); + out.textures_delta.clear(); + out +} + +/// Whether the control `draw` shows that screen readers name `label` is enabled, and its center. +fn control(app: &mut VectorcraftApp, ctx: &egui::Context, draw: &dyn Fn(&mut VectorcraftApp, &mut Ui), label: &str) -> (bool, Pos2) { + draw_frame(app, ctx, draw, vec![]); + let out = draw_frame(app, ctx, draw, vec![]); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; + let (_, node) = nodes.iter().find(|(_, n)| n.label().map(str::trim) == Some(label)).unwrap_or_else(|| panic!("no control named {label:?}")); + let b = node.bounds().expect("bounds"); + (!node.is_disabled(), Pos2::new((b.x0 + b.x1) as f32 * 0.5, (b.y0 + b.y1) as f32 * 0.5)) +} + +/// A click at `at` in a frame of `draw`. +fn click_at(app: &mut VectorcraftApp, ctx: &egui::Context, draw: &dyn Fn(&mut VectorcraftApp, &mut Ui), at: Pos2) { + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + draw_frame(app, ctx, draw, vec![Event::PointerMoved(at)]); + draw_frame(app, ctx, draw, vec![button(true), button(false)]); +} + +/// The app restarted with the preferences `app` saves. +fn restarted(app: &VectorcraftApp) -> VectorcraftApp { + let saved: crate::UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap(); + let mut next = VectorcraftApp::new(Session::new(), Default::default()); + next.ui = saved.sanitized(); + restore(&mut next); + next +} + +#[test] +fn persistent_panels_reopen_at_startup_with_their_settings() { + let dir = std::env::temp_dir().join(format!("vc-persistent-libraries-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let gpl = dir.join("Brand.gpl"); + std::fs::write(&gpl, "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n").unwrap(); + let mut app = app(); + let pastels = panel_of(app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap()); + let brights = panel_of(app.run("window.swatchLibrary", json!({"library": "brights"})).unwrap()); + let brand = panel_of(app.run("window.swatchLibrary.other", json!({"path": gpl.to_string_lossy()})).unwrap()); + let r = app.run("ui.libraryPanel", json!({"panel": pastels, "persistent": true, "view": "largeList", "showFind": false})).unwrap(); + assert_eq!((r["persistent"].as_bool(), r["view"].as_str(), r["showFind"].as_bool()), (Some(true), Some("largeList"), Some(false))); + app.run("ui.libraryPanel", json!({"panel": brand, "persistent": true})).unwrap(); + app.run("window.panel.float", json!({"panel": pastels, "x": 40, "y": 90})).unwrap(); + // A persistent panel reopens even when it was closed. + app.run("window.panel.close", json!({"panel": brand})).unwrap(); + let next = restarted(&app); + assert!(is_open(&next.ui, &pastels) && is_open(&next.ui, &brand), "{}", next.ui.status); + assert!(!is_open(&next.ui, &brights), "a panel that isn't persistent closed with the app"); + let group = docking(&next).floating.iter().find(|g| g.contains(&pastels)).unwrap(); + assert_eq!(group.rect[..2], [40.0, 90.0], "where it was"); + let s = settings(&next.ui, "swatches", "pastels").unwrap(); + assert_eq!((s.view, s.hide_find, s.persistent), (Some(View::LargeList), true, true)); + let (_, id) = parse(&brand).unwrap(); + assert!(SwatchLibraries::get(&next, id).is_some_and(|(_, lib)| lib.swatch("Red").is_some()), "read again from its file"); + assert!(next.ui.status.is_empty(), "{}", next.ui.status); + assert!(next.ui.docking_hidden.keys().all(|p| p != &brights)); + // Unchecked, a panel no longer reopens; one whose file is gone reopens as missing, with a note. + let mut next = next; + next.run("ui.libraryPanel", json!({"panel": pastels, "persistent": false})).unwrap(); + std::fs::remove_file(&gpl).unwrap(); + let last = restarted(&next); + assert!(!is_open(&last.ui, &pastels) && is_open(&last.ui, &brand)); + assert!(last.ui.status.contains("Brand.gpl"), "{}", last.ui.status); + assert_eq!(last.ui.library_panels.iter().map(LibraryPanel::panel).collect::>(), [brand]); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_persistent_panel_whose_file_is_gone_is_listed_as_missing_until_located() { + let dir = std::env::temp_dir().join(format!("vc-missing-library-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("moved")).unwrap(); + let palette = "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n"; + let gpl = dir.join("Brand.gpl"); + std::fs::write(&gpl, palette).unwrap(); + let mut app = app(); + let brand = panel_of(app.run("window.swatchLibrary.other", json!({"path": gpl.to_string_lossy()})).unwrap()); + app.run("ui.libraryPanel", json!({"panel": brand, "persistent": true, "view": "smallList"})).unwrap(); + std::fs::remove_file(&gpl).unwrap(); + // The panel reopens showing its library as missing, and the preferences keep it. + let mut next = restarted(&app); + tidy(&mut next); + assert!(is_open(&next.ui, &brand), "{}", next.ui.status); + assert!(next.ui.status.contains("Brand.gpl"), "{}", next.ui.status); + let (_, gone) = parse(&brand).unwrap(); + let s = settings(&next.ui, "swatches", gone).cloned().unwrap(); + assert_eq!((s.path, s.persistent, s.view), (Some(gpl.to_string_lossy().into_owned()), true, Some(View::SmallList))); + assert_eq!(tab_label(&next, &brand).as_deref(), Some("Brand")); + // Closed, it opens again by its library's id. + next.run("window.panel.close", json!({"panel": brand})).unwrap(); + let r = next.run("window.swatchLibrary", json!({"library": gone})).unwrap(); + assert_eq!((r["panel"].as_str(), r["name"].as_str(), r["count"].as_u64()), (Some(brand.as_str()), Some("Brand"), Some(0))); + assert!(is_open(&next.ui, &brand)); + let text = crate::tests_labels::painted_text(&mut next, |app, ui| show(app, ui, &brand)); + let shown = format!("Missing: {}", gpl.to_string_lossy()); + assert!(text.contains(&shown), "{text}"); + let ctx = accessible(); + let body = |app: &mut VectorcraftApp, ui: &mut Ui| show(app, ui, &brand); + assert!(!control(&mut next, &ctx, &body, "Locate Library…").0, "dimmed without an open dialog"); + let panel_menu = |app: &mut VectorcraftApp, ui: &mut Ui| menu(app, ui, &brand); + assert!(!control(&mut next, &ctx, &panel_menu, "Next Library").0, "no library to step from"); + let mut next = restarted(&next); + assert!(is_open(&next.ui, &brand), "still listed after another restart"); + // Locate Library… reads the file where it is now and shows it in the same panel. + assert!(next.run("ui.libraryPanel.locate", json!({"panel": "layers"})).is_err()); + let moved = dir.join("moved").join("Brand.gpl"); + std::fs::write(&moved, palette).unwrap(); + let picked = moved.to_string_lossy().into_owned(); + next.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))); + let (enabled, at) = control(&mut next, &ctx, &body, "Locate Library…"); + assert!(enabled); + click_at(&mut next, &ctx, &body, at); + let located = panel_id("swatches", &format!("loaded/{}", moved.to_string_lossy())); + assert!(!is_open(&next.ui, &brand) && is_open(&next.ui, &located), "{}", next.ui.status); + let (_, id) = parse(&located).unwrap(); + assert!(SwatchLibraries::get(&next, id).is_some_and(|(_, lib)| lib.swatch("Red").is_some())); + let s = settings(&next.ui, "swatches", id).cloned().unwrap(); + assert_eq!((s.path, s.persistent, s.view), (Some(moved.to_string_lossy().into_owned()), true, Some(View::SmallList))); + assert!(settings(&next.ui, "swatches", gone).is_none()); + let located_menu = |app: &mut VectorcraftApp, ui: &mut Ui| menu(app, ui, &located); + assert!(control(&mut next, &ctx, &located_menu, "Next Library").0); + let last = restarted(&next); + assert!(is_open(&last.ui, &located) && last.ui.status.is_empty(), "{}", last.ui.status); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn locating_a_library_open_in_its_own_panel_brings_that_panel_forward() { + let dir = std::env::temp_dir().join(format!("vc-locate-open-library-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let palette = "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n"; + let gpl = dir.join("Brand.gpl"); + std::fs::write(&gpl, palette).unwrap(); + let mut app = app(); + let brand = panel_of(app.run("window.swatchLibrary.other", json!({"path": gpl.to_string_lossy()})).unwrap()); + app.run("ui.libraryPanel", json!({"panel": brand, "persistent": true})).unwrap(); + std::fs::remove_file(&gpl).unwrap(); + let mut next = restarted(&app); + let copy = dir.join("Copy.gpl"); + std::fs::write(©, palette).unwrap(); + let other = panel_of(next.run("window.swatchLibrary.other", json!({"path": copy.to_string_lossy()})).unwrap()); + next.run("window.panel.close", json!({"panel": brand})).unwrap(); + assert!(next.run("ui.libraryPanel.locate", json!({"panel": brand, "path": copy.to_string_lossy()})).is_err(), "a closed panel"); + next.run("window.panel", json!({"panel": brand})).unwrap(); + let r = next.run("ui.libraryPanel.locate", json!({"panel": brand, "path": copy.to_string_lossy()})).unwrap(); + assert_eq!((r["panel"].as_str(), r["persistent"].as_bool()), (Some(other.as_str()), Some(true))); + assert!(!is_open(&next.ui, &brand) && is_open(&next.ui, &other)); + assert!(next.ui.library_panels.iter().all(|p| p.panel() != brand), "the missing panel is forgotten"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn locate_library_leaves_a_panel_whose_library_is_there_alone() { + let dir = std::env::temp_dir().join(format!("vc-locate-found-library-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let gpl = dir.join("Brand.gpl"); + std::fs::write(&gpl, "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n").unwrap(); + let mut app = app(); + let pastels = panel_of(app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap()); + for persistent in [false, true] { + app.run("ui.libraryPanel", json!({"panel": pastels, "persistent": persistent})).unwrap(); + let e = app.run("ui.libraryPanel.locate", json!({"panel": pastels, "path": gpl.to_string_lossy()})).unwrap_err(); + assert!(e.contains("isn't missing its library"), "{e}"); + assert!(is_open(&app.ui, &pastels), "persistent: {persistent}"); + assert_eq!(settings(&app.ui, "swatches", "pastels").map(|s| s.persistent), Some(persistent)); + } + assert!(SwatchLibraries::get(&app, &format!("loaded/{}", gpl.to_string_lossy())).is_none(), "the file isn't loaded"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn locate_library_reads_a_missing_library_whose_panel_is_no_longer_persistent() { + let dir = std::env::temp_dir().join(format!("vc-locate-not-persistent-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("moved")).unwrap(); + let palette = "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n"; + let gpl = dir.join("Brand.gpl"); + std::fs::write(&gpl, palette).unwrap(); + let mut app = app(); + let brand = panel_of(app.run("window.swatchLibrary.other", json!({"path": gpl.to_string_lossy()})).unwrap()); + app.run("ui.libraryPanel", json!({"panel": brand, "persistent": true})).unwrap(); + std::fs::remove_file(&gpl).unwrap(); + let mut next = restarted(&app); + // After a command turns Persistent off, the panel stays open on its missing library until the next frame's tidy. + next.run("ui.libraryPanel", json!({"panel": brand, "persistent": false})).unwrap(); + assert!(is_open(&next.ui, &brand)); + let moved = dir.join("moved").join("Brand.gpl"); + std::fs::write(&moved, palette).unwrap(); + let r = next.run("ui.libraryPanel.locate", json!({"panel": brand, "path": moved.to_string_lossy()})).unwrap(); + let located = panel_id("swatches", &format!("loaded/{}", moved.to_string_lossy())); + assert_eq!((r["panel"].as_str(), r["persistent"].as_bool()), (Some(located.as_str()), Some(false))); + assert!(!is_open(&next.ui, &brand) && is_open(&next.ui, &located), "{}", next.ui.status); + let (_, id) = parse(&located).unwrap(); + assert!(SwatchLibraries::get(&next, id).is_some_and(|(_, lib)| lib.swatch("Red").is_some())); + tidy(&mut next); + assert!(is_open(&next.ui, &located), "its library is there now"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn an_open_panel_whose_library_is_missing_is_brought_forward_by_its_id_when_not_persistent() { + let dir = std::env::temp_dir().join(format!("vc-open-not-persistent-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let gpl = dir.join("Brand.gpl"); + std::fs::write(&gpl, "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n").unwrap(); + let mut app = app(); + let brand = panel_of(app.run("window.swatchLibrary.other", json!({"path": gpl.to_string_lossy()})).unwrap()); + app.run("ui.libraryPanel", json!({"panel": brand, "persistent": true})).unwrap(); + std::fs::remove_file(&gpl).unwrap(); + let mut next = restarted(&app); + // After a command turns Persistent off, the panel stays open on its missing library until the next frame's tidy. + next.run("ui.libraryPanel", json!({"panel": brand, "persistent": false})).unwrap(); + let (_, gone) = parse(&brand).unwrap(); + let r = next.run("window.swatchLibrary", json!({"library": gone})).unwrap(); + assert_eq!((r["panel"].as_str(), r["name"].as_str(), r["count"].as_u64()), (Some(brand.as_str()), Some("Brand"), Some(0))); + assert!(is_open(&next.ui, &brand)); + tidy(&mut next); + assert!(!is_open(&next.ui, &brand), "a panel that isn't persistent still closes"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn previous_and_next_show_the_neighbouring_library_in_the_same_panel() { + let mut app = app(); + let first = panel_of(app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap()); + app.run("window.panel.float", json!({"panel": first, "x": 50, "y": 60})).unwrap(); + app.run("ui.libraryPanel", json!({"panel": first, "persistent": true, "view": "smallList"})).unwrap(); + step::(&mut app, "pastels", 1); + let next = panel_id("swatches", SWATCH_IDS[5]); + assert!(!is_open(&app.ui, &first) && is_open(&app.ui, &next), "{}", app.ui.status); + let group = docking(&app).floating.iter().find(|g| g.contains(&next)).unwrap(); + assert_eq!((group.panels().count(), group.rect[0], group.rect[1]), (1, 50.0, 60.0), "in its place"); + let s = settings(&app.ui, "swatches", SWATCH_IDS[5]).unwrap(); + assert_eq!((s.view, s.persistent), (Some(View::SmallList), true), "the panel keeps its settings"); + assert!(settings(&app.ui, "swatches", "pastels").is_none()); + assert!(crate::panel_docking::valid(docking(&app))); + // A library open in a panel of its own is brought forward instead. + app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap(); + let r = app.run("ui.libraryPanel", json!({"panel": next, "library": "Pastels"})).unwrap(); + assert_eq!(r["panel"], json!(first)); + assert!(is_open(&app.ui, &next) && is_open(&app.ui, &first)); +} + +#[test] +fn a_panel_closes_when_its_library_is_gone() { + let dir = std::env::temp_dir().join(format!("vc-gone-library-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Gone.gpl"), "GIMP Palette\nName: Gone\n#\n0 0 0\tInk\n").unwrap(); + std::fs::write(dir.join("Kept.gpl"), "GIMP Palette\nName: Kept\n#\n0 0 0\tInk\n").unwrap(); + let mut app = app(); + app.session.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + let panel = panel_of(app.run("window.swatchLibrary", json!({"library": "user/Gone.gpl"})).unwrap()); + let kept = panel_of(app.run("window.swatchLibrary", json!({"library": "user/Kept.gpl"})).unwrap()); + app.run("ui.libraryPanel", json!({"panel": kept, "persistent": true})).unwrap(); + tidy(&mut app); + assert!(is_open(&app.ui, &panel)); + std::fs::remove_file(dir.join("Gone.gpl")).unwrap(); + std::fs::remove_file(dir.join("Kept.gpl")).unwrap(); + app.session.swatch_libraries.rescan(); + tidy(&mut app); + assert!(!is_open(&app.ui, &panel)); + // A persistent panel stays open and shows its library as missing. + assert!(is_open(&app.ui, &kept)); + assert_eq!(tab_label(&app, &kept).as_deref(), Some("Kept")); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui, &kept)); + assert!(text.contains("Missing: user/Kept.gpl"), "{text}"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn the_panel_menu_has_persistent() { + let mut app = app(); + let panel = panel_of(app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap()); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| menu(app, ui, &panel)); + for item in ["Add to Swatches", "Small List View", "Show Find Field", "Previous Library", "Persistent", "Close Library"] { + assert!(text.contains(item), "{item}: {text}"); + } +}