mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 07:42:42 +00:00
Clipboard: quitting on Linux takes every window off screen before the clipboard wait
518 lines
28 KiB
Rust
518 lines
28 KiB
Rust
use super::*;
|
||
|
||
fn ui_state_with_prefs(engine_prefs: serde_json::Value) -> vectorcraft_ui_egui::UiState {
|
||
vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() }
|
||
}
|
||
|
||
/// Shortcut overrides an earlier version saved still load: its spellings, `panel:` keys, an
|
||
/// override that can't fire (#487), an entry for a command this version lacks, and an old set
|
||
/// name.
|
||
#[test]
|
||
fn shortcut_overrides_from_an_earlier_version_load() {
|
||
use serde_json::json;
|
||
use vectorcraft_ui_egui::{keymap, shortcut_editor};
|
||
let mut raw = serde_json::to_value(vectorcraft_ui_egui::UiState::default()).unwrap();
|
||
raw["shortcut_overrides"] = json!({
|
||
"tool:pen": "Shift+9",
|
||
"edit.preferences": "",
|
||
"panel:layers": "Cmd+Alt+Shift+F7",
|
||
"tool:groupSelection": "Shift+ShiftLeft",
|
||
"test.unknown": "Cmd+Alt+Shift+F9",
|
||
"view.grid": "Cmd+Period",
|
||
});
|
||
raw["shortcut_set"] = json!(shortcut_editor::LEGACY_PRESETS[0].0);
|
||
let ui = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap().sanitized();
|
||
assert_eq!(ui.shortcut_set, shortcut_editor::LEGACY_PRESETS[0].1);
|
||
let km = keymap::keymap_with(&ui.shortcut_overrides);
|
||
let text = |key: &str| keymap::effective_with(&km, key);
|
||
assert_eq!(text("tool:pen").as_deref(), Some("Shift+9"));
|
||
assert_eq!(text("edit.preferences"), None);
|
||
assert_eq!(text("panel:layers").as_deref(), Some("Cmd+Alt+Shift+F7"));
|
||
assert_eq!(text("tool:groupSelection").as_deref(), shortcut_editor::default_of("tool:groupSelection"), "back on its default");
|
||
assert_eq!(text("view.grid").as_deref(), Some("Cmd+Period"));
|
||
let period = keymap::vc_chord("Cmd+.").unwrap();
|
||
assert_eq!(keymap::lookup(&km, &period).map(|c| c.id), Some("view.grid".to_string()));
|
||
assert_eq!(text("test.unknown").as_deref(), Some("Cmd+Alt+Shift+F9"), "kept");
|
||
assert!(km.problems().iter().any(|p| format!("{p:?}") == r#"UnknownCommand { key: "test.unknown" }"#), "reported");
|
||
}
|
||
|
||
/// The command line's files wait for the choice about a stopped plug-in, so they open with the
|
||
/// plug-ins it loads; with no choice up they open at once.
|
||
#[test]
|
||
fn command_line_files_wait_for_the_choice_about_a_stopped_plug_in() {
|
||
let temp = tempfile::Builder::new().prefix("vectorcraft-held-files-").tempdir().unwrap();
|
||
let file = temp.path().join("a.svg");
|
||
std::fs::write(&file, r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>"#).unwrap();
|
||
let files = vec![file.to_string_lossy().into_owned()];
|
||
// The files the app reads to open them.
|
||
let read = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
|
||
let reads = read.clone();
|
||
let services = vectorcraft_ui_egui::Services {
|
||
read: Some(Box::new(move |p: &str| {
|
||
reads.borrow_mut().push(p.to_owned());
|
||
std::fs::read(p).map_err(|e| e.to_string())
|
||
})),
|
||
..Default::default()
|
||
};
|
||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), services);
|
||
app.ui.stopped_plugin = Some(serde_json::json!({"id": "test.held", "name": "Held"}));
|
||
assert_eq!(open_or_hold(&mut app, files.clone()), files, "held while the choice is up");
|
||
assert!(read.borrow().is_empty(), "nothing opened before the choice");
|
||
app.ui.stopped_plugin = None;
|
||
assert!(open_or_hold(&mut app, files.clone()).is_empty());
|
||
assert_eq!(*read.borrow(), files, "opened with no choice up");
|
||
}
|
||
|
||
#[test]
|
||
fn opening_files_unminimizes_and_focuses_the_window() {
|
||
let ctx = egui::Context::default();
|
||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| bring_window_forward(ui.ctx()));
|
||
// Nothing paints in the test: drop the font atlas upload egui expects to be applied.
|
||
out.textures_delta.clear();
|
||
let cmds = &out.viewport_output[&egui::ViewportId::ROOT].commands;
|
||
assert!(cmds.contains(&egui::ViewportCommand::Minimized(false)));
|
||
assert!(cmds.contains(&egui::ViewportCommand::Focus));
|
||
}
|
||
|
||
#[test]
|
||
fn unreadable_saved_docking_keeps_preferences_and_every_workspace() {
|
||
use serde_json::json;
|
||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||
app.run("window.panel.float", json!({"panel":"layers"})).unwrap();
|
||
app.ui.toolbar = false;
|
||
app.ui.engine_prefs = json!({"interfaceLanguage":"fr", "uiBrightness":"light"});
|
||
app.ui.custom_workspaces =
|
||
vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future"), vectorcraft_ui_egui::workspaces::capture(&app.ui, "Valid")];
|
||
app.ui.workspace_changes = vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future")];
|
||
let original = serde_json::to_value(&app.ui).unwrap();
|
||
let valid_location = original["docking_hidden"]["layers"].clone();
|
||
assert!(valid_location.is_object());
|
||
for invalid_layout in [json!({"root":{"FutureNode":{}},"floating":[]}), json!(false)] {
|
||
for invalid_hidden in [json!({"layers":{"placement":{"FuturePlacement":{}}},"swatches":valid_location}), json!(["malformed"])] {
|
||
let mut raw = original.clone();
|
||
raw["docking"] = invalid_layout.clone();
|
||
raw["docking_hidden"] = invalid_hidden.clone();
|
||
raw["custom_workspaces"][0]["docking"] = invalid_layout.clone();
|
||
raw["custom_workspaces"][0]["dockingHidden"] = invalid_hidden.clone();
|
||
raw["workspace_changes"][0]["docking"] = invalid_layout.clone();
|
||
raw["workspace_changes"][0]["dockingHidden"] = invalid_hidden.clone();
|
||
assert!(serde_json::from_value::<vectorcraft_ui_egui::UiState>(raw.clone()).is_err(), "runtime serde stays strict");
|
||
let loaded = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap();
|
||
assert_eq!(loaded.engine_prefs, app.ui.engine_prefs);
|
||
assert!(!loaded.toolbar);
|
||
assert!(loaded.docking.is_none());
|
||
assert!(!loaded.docking_hidden.contains_key("layers"));
|
||
if invalid_hidden.is_object() {
|
||
assert_eq!(serde_json::to_value(loaded.docking_hidden.get("swatches")).unwrap(), valid_location);
|
||
} else {
|
||
assert!(loaded.docking_hidden.is_empty());
|
||
}
|
||
assert_eq!(loaded.custom_workspaces.len(), 2);
|
||
assert_eq!(loaded.custom_workspaces[0].name, "Future");
|
||
assert!(loaded.custom_workspaces[0].docking.is_none());
|
||
assert!(!loaded.custom_workspaces[0].docking_hidden.contains_key("layers"));
|
||
assert_eq!(serde_json::to_value(&loaded.custom_workspaces[1]).unwrap(), original["custom_workspaces"][1]);
|
||
assert_eq!(loaded.workspace_changes.len(), 1, "a remembered layout is recovered, not the whole file lost");
|
||
assert!(loaded.workspace_changes[0].docking.is_none());
|
||
}
|
||
}
|
||
let roundtrip = decode_saved_prefs(&serde_json::to_vec(&original).unwrap()).unwrap();
|
||
assert_eq!(serde_json::to_value(roundtrip).unwrap(), original);
|
||
assert!(
|
||
decode_saved_prefs(&serde_json::to_vec(&json!({"toolbar":"invalid"})).unwrap()).is_none(),
|
||
"unrelated fields retain normal strict parsing"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn an_unreadable_ui_json_is_kept_beside_it() {
|
||
let temp = tempfile::Builder::new().prefix("vectorcraft-ui-prefs-").tempdir().unwrap();
|
||
let dir = temp.path();
|
||
let path = dir.join("ui.json");
|
||
super::keep_prefs_copy(&path, b"{\"docking\": {\"root\": {\"FutureNode\": {}}}}", "ui.unreadable");
|
||
super::keep_prefs_copy(&path, b"not json", "ui.unreadable");
|
||
super::keep_prefs_copy(&path, b"{}", "ui.before-layout-migration");
|
||
let first = std::fs::read(dir.join("ui.unreadable.json")).unwrap();
|
||
let second = std::fs::read(dir.join("ui.unreadable-2.json")).unwrap();
|
||
let before = std::fs::read(dir.join("ui.before-layout-migration.json")).unwrap();
|
||
assert!(first.starts_with(b"{\"docking\""), "the first copy is not replaced");
|
||
assert_eq!(second, b"not json");
|
||
assert_eq!(before, b"{}");
|
||
assert!(!super::KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed), "ui.json can be saved once a copy is kept");
|
||
}
|
||
|
||
#[test]
|
||
fn the_image_trace_preview_setting_is_kept_across_a_restart() {
|
||
for preview in [false, true] {
|
||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||
app.ui.image_trace_preview = preview;
|
||
let saved = prefs_bytes(&app).unwrap();
|
||
let loaded = decode_saved_prefs(&saved).unwrap().sanitized();
|
||
assert_eq!(loaded.image_trace_preview, preview, "Preview {preview} after a restart");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_system_title_bar_preference_keeps_the_window_decorations() {
|
||
use craft_window::{Frame, Layout};
|
||
|
||
// The System Title Bar preference (Preferences › User Interface) is read the same way on
|
||
// every platform; a missing or unreadable value is `false`.
|
||
assert!(!super::opted_into_system_title_bar(None), "no preferences: the default");
|
||
let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false}));
|
||
assert!(!super::opted_into_system_title_bar(Some(&off)));
|
||
let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true}));
|
||
assert!(super::opted_into_system_title_bar(Some(&on)));
|
||
|
||
// The suite-wide mapping (Frame + preference -> whether VectorCraft draws its own title
|
||
// bar). The desktop frame (macOS, Windows, X11, KDE/Wayland, …) always keeps native
|
||
// decorations; the app frame (GNOME/Wayland, or a `VECTORCRAFT_TITLEBAR` override) draws
|
||
// our own title bar — unless the user opted into the system title bar, which forces the
|
||
// native frame back on.
|
||
let app = Frame::App(Layout::default());
|
||
let desktop = Frame::Desktop;
|
||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||
{
|
||
assert!(super::custom_titlebar_from(&app, false), "GNOME/Wayland: VectorCraft draws its own");
|
||
assert!(!super::custom_titlebar_from(&app, true), "System Title Bar preference forces native");
|
||
assert!(!super::custom_titlebar_from(&desktop, false), "KDE/X11/…: keep the compositor's frame");
|
||
assert!(!super::custom_titlebar_from(&desktop, true));
|
||
}
|
||
// macOS and Windows keep today's behavior, independent of the probed frame: the pref still
|
||
// forces the native frame back on where a custom one would otherwise be drawn.
|
||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||
{
|
||
assert_eq!(super::custom_titlebar_from(&desktop, false), super::CUSTOM_TITLEBAR);
|
||
assert_eq!(super::custom_titlebar_from(&app, false), super::CUSTOM_TITLEBAR, "the probe does not change macOS/Windows");
|
||
assert!(!super::custom_titlebar_from(&desktop, true));
|
||
}
|
||
|
||
assert_eq!(super::native_options(false).viewport.decorations, Some(true));
|
||
assert_eq!(super::native_options(true).viewport.decorations, Some(false));
|
||
}
|
||
|
||
/// Preferences › User Interface › VectorCraft Title Bar on Every Desktop gives the app frame where
|
||
/// the probe chose the desktop's (KDE, X11, a window opened through XWayland); without it the probe
|
||
/// decides, and `VECTORCRAFT_TITLEBAR` set to `app`, `system` or `desktop` takes precedence over
|
||
/// both.
|
||
#[test]
|
||
fn the_app_title_bar_preference_gives_the_app_frame_on_desktop_frames() {
|
||
assert!(!super::opted_into_app_title_bar(None), "no preferences: the default");
|
||
let off = ui_state_with_prefs(serde_json::json!({"appTitleBar": false}));
|
||
assert!(!super::opted_into_app_title_bar(Some(&off)));
|
||
let on = ui_state_with_prefs(serde_json::json!({"appTitleBar": true}));
|
||
assert!(super::opted_into_app_title_bar(Some(&on)));
|
||
|
||
// (probed_app, overridden, forced_to_x11): without the preference, then with it.
|
||
let cases = [
|
||
((false, None, false), false, true), // KDE, Sway, X11: the desktop frame
|
||
((true, None, false), true, true), // GNOME on Wayland: the app frame either way
|
||
((true, None, true), false, true), // GNOME, the window opened through XWayland
|
||
((false, Some(true), false), true, true), // VECTORCRAFT_TITLEBAR=app
|
||
((false, Some(true), true), true, true), // VECTORCRAFT_TITLEBAR=app, the window opened through XWayland
|
||
((true, Some(false), false), false, false), // VECTORCRAFT_TITLEBAR=desktop takes precedence over the preference
|
||
((false, Some(false), true), false, false),
|
||
];
|
||
for ((probed_app, overridden, forced_to_x11), without, with) in cases {
|
||
assert_eq!(super::app_draws_frame(probed_app, overridden, forced_to_x11, false), without, "{probed_app} {overridden:?} {forced_to_x11}");
|
||
assert_eq!(super::app_draws_frame(probed_app, overridden, forced_to_x11, true), with, "{probed_app} {overridden:?} {forced_to_x11}");
|
||
}
|
||
}
|
||
|
||
/// The frame a start uses: the preference turns the desktop frame the probe chose into the app frame
|
||
/// with the default window buttons, an app frame the desktop left to the app keeps its own buttons,
|
||
/// one that `VECTORCRAFT_TITLEBAR=app` forced or the preference kept through XWayland gets the
|
||
/// default buttons, and the desktop frame stays
|
||
/// where the app frame is not drawn. System Title Bar takes precedence over the preference.
|
||
#[test]
|
||
fn the_start_frame_applies_the_app_title_bar_preference() {
|
||
use craft_window::{Button, Frame, Layout};
|
||
let gnome = Layout { left: vec![Button::Close], right: vec![] };
|
||
let cases = [
|
||
// (probed, overridden, forced_to_x11, opted_into_app), expected
|
||
((Frame::Desktop, None, false, false), Frame::Desktop),
|
||
((Frame::Desktop, None, false, true), Frame::App(Layout::default())),
|
||
((Frame::Desktop, None, true, true), Frame::App(Layout::default())),
|
||
((Frame::App(gnome.clone()), None, false, false), Frame::App(gnome.clone())),
|
||
((Frame::App(gnome.clone()), None, true, false), Frame::Desktop),
|
||
((Frame::App(gnome.clone()), None, true, true), Frame::App(Layout::default())),
|
||
((Frame::Desktop, Some(false), false, true), Frame::Desktop),
|
||
((Frame::App(gnome.clone()), Some(true), true, false), Frame::App(Layout::default())),
|
||
((Frame::App(gnome.clone()), Some(true), false, false), Frame::App(Layout::default())),
|
||
];
|
||
for ((probed, overridden, forced_to_x11, opted_into_app), expected) in cases {
|
||
let label = format!("{probed:?} {overridden:?} {forced_to_x11} {opted_into_app}");
|
||
assert_eq!(super::start_frame(probed, overridden, forced_to_x11, opted_into_app), expected, "{label}");
|
||
}
|
||
// With both preferences on, System Title Bar takes precedence: the desktop draws the frame.
|
||
let both = super::start_frame(Frame::Desktop, None, false, true);
|
||
assert!(!super::custom_titlebar_from(&both, true), "System Title Bar takes precedence over the app title bar preference");
|
||
}
|
||
|
||
/// The app bar's caption buttons follow the window-button layout of the app frame on Linux and the
|
||
/// BSDs (Close alone at the right, or all three at the left with Close outermost); the desktop frame,
|
||
/// macOS and Windows keep all three at the right.
|
||
#[test]
|
||
fn the_caption_buttons_follow_the_frame_layout() {
|
||
use craft_window::{Button, Frame, Layout};
|
||
use vectorcraft_ui_egui::titlebar::{Caption, CaptionLayout};
|
||
let close_only = Layout::parse("appmenu:close");
|
||
let at_left = Layout { left: vec![Button::Close, Button::Minimize, Button::Maximize], right: vec![] };
|
||
let split = Layout { left: vec![Button::Close], right: vec![Button::Minimize, Button::Maximize] };
|
||
let probed = [
|
||
(close_only, CaptionLayout { left: vec![], right: vec![Caption::Close] }),
|
||
(at_left, CaptionLayout { left: vec![Caption::Close, Caption::Minimize, Caption::Maximize], right: vec![] }),
|
||
(split, CaptionLayout { left: vec![Caption::Close], right: vec![Caption::Minimize, Caption::Maximize] }),
|
||
];
|
||
for (layout, expected) in probed {
|
||
let shown = super::caption_layout_from(&Frame::App(layout.clone()));
|
||
let expected = if cfg!(any(target_os = "macos", target_os = "windows")) { CaptionLayout::default() } else { expected };
|
||
assert_eq!(shown, expected, "{layout:?}");
|
||
}
|
||
assert_eq!(super::caption_layout_from(&Frame::Desktop), CaptionLayout::default());
|
||
assert_eq!(CaptionLayout::default().right, vec![Caption::Minimize, Caption::Maximize, Caption::Close]);
|
||
}
|
||
|
||
/// `VECTORCRAFT_TITLEBAR` chooses the frame for the run it is set in, with System Title Bar off and
|
||
/// on. The saved preference still decides while the variable is unset.
|
||
#[test]
|
||
fn the_titlebar_variable_takes_precedence_over_system_title_bar() {
|
||
use craft_window::Frame;
|
||
assert!(super::system_title_bar_applies(true, false));
|
||
assert!(!super::system_title_bar_applies(true, true));
|
||
assert!(!super::system_title_bar_applies(false, false));
|
||
assert!(!super::system_title_bar_applies(false, true));
|
||
// VECTORCRAFT_TITLEBAR=app with System Title Bar on: VectorCraft draws its own title bar.
|
||
let frame = super::start_frame(Frame::Desktop, Some(true), false, false);
|
||
assert_eq!(super::custom_titlebar_from(&frame, super::system_title_bar_applies(true, true)), !cfg!(target_os = "macos"));
|
||
// VECTORCRAFT_TITLEBAR=desktop with System Title Bar off keeps the desktop's frame.
|
||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||
{
|
||
let frame = super::start_frame(Frame::App(craft_window::Layout::default()), Some(false), false, false);
|
||
assert!(!super::custom_titlebar_from(&frame, super::system_title_bar_applies(false, true)));
|
||
}
|
||
}
|
||
|
||
/// `VECTORCRAFT_TITLEBAR` read from the environment at startup, with System Title Bar off and on.
|
||
/// The variable is process-wide and read once, so each value runs in a separate test process.
|
||
#[test]
|
||
fn the_titlebar_variable_decides_the_frame_before_the_window_opens() {
|
||
const CHILD: &str = "VECTORCRAFT_TEST_TITLEBAR_CHILD";
|
||
if std::env::var_os(CHILD).is_none() {
|
||
for value in ["app", "system", "desktop", "unknown"] {
|
||
// Linux and the BSDs: hold `spawning` while the child starts, so it does not keep a
|
||
// copy of a GPU start test's locked start file (see `CHILD_SPAWN`).
|
||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||
let spawning = super::spawning();
|
||
let child = std::process::Command::new(std::env::current_exe().unwrap())
|
||
.args(["--exact", "tests::the_titlebar_variable_decides_the_frame_before_the_window_opens", "--nocapture"])
|
||
.env(CHILD, "1")
|
||
.env("VECTORCRAFT_TITLEBAR", value)
|
||
.stdin(std::process::Stdio::null())
|
||
.stdout(std::process::Stdio::piped())
|
||
.stderr(std::process::Stdio::piped())
|
||
.spawn();
|
||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||
drop(spawning);
|
||
let output = child.unwrap().wait_with_output().unwrap();
|
||
assert!(
|
||
output.status.success(),
|
||
"VECTORCRAFT_TITLEBAR={value}\n{}\n{}",
|
||
String::from_utf8_lossy(&output.stdout),
|
||
String::from_utf8_lossy(&output.stderr)
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
let value = std::env::var("VECTORCRAFT_TITLEBAR").unwrap();
|
||
let not_read = cfg!(any(target_os = "macos", target_os = "windows"));
|
||
assert_eq!(super::titlebar_from_env(), !not_read && value != "unknown", "VECTORCRAFT_TITLEBAR={value}");
|
||
let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false}));
|
||
let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true}));
|
||
for (saved, opted_into_system) in [(None, false), (Some(&off), false), (Some(&on), true)] {
|
||
let (custom, _) = super::custom_titlebar(saved);
|
||
let expected = match value.as_str() {
|
||
_ if not_read => !cfg!(target_os = "macos") && !opted_into_system,
|
||
"app" => true,
|
||
"system" | "desktop" => false,
|
||
// Not a value the variable takes: System Title Bar decides when it is on. Otherwise the
|
||
// `craft-window` probe decides, the same with System Title Bar off as with no saved
|
||
// preferences.
|
||
_ if opted_into_system => false,
|
||
_ => super::custom_titlebar(None).0,
|
||
};
|
||
assert_eq!(custom, expected, "{value}, preference {opted_into_system}");
|
||
assert_eq!(super::native_options(custom).viewport.decorations, Some(!custom), "{value}, preference {opted_into_system}");
|
||
}
|
||
}
|
||
|
||
/// Preferences says under System Title Bar that the desktop draws the frame with it on or off
|
||
/// exactly where the start frame without VectorCraft Title Bar on Every Desktop is the desktop's
|
||
/// with System Title Bar off as well as on and `VECTORCRAFT_TITLEBAR` is unset: Linux and the BSDs
|
||
/// only.
|
||
#[test]
|
||
fn the_desktop_keeps_the_frame_where_system_title_bar_does_not_change_it() {
|
||
use crate::titlebar::desktop_keeps_frame_from;
|
||
use craft_window::{Frame, Layout};
|
||
let linux = cfg!(not(any(target_os = "macos", target_os = "windows")));
|
||
for probed in [Frame::Desktop, Frame::App(Layout::default())] {
|
||
for forced_to_x11 in [false, true] {
|
||
let frame = super::start_frame(probed.clone(), None, forced_to_x11, false);
|
||
let either_way = !super::custom_titlebar_from(&frame, false) && !super::custom_titlebar_from(&frame, true);
|
||
assert_eq!(desktop_keeps_frame_from(&probed, None, forced_to_x11), linux && either_way, "{probed:?} {forced_to_x11}");
|
||
}
|
||
}
|
||
// KDE, Sway and X11, and GNOME through XWayland, keep the desktop's frame; GNOME on Wayland does not.
|
||
assert_eq!(desktop_keeps_frame_from(&Frame::Desktop, None, false), linux);
|
||
assert_eq!(desktop_keeps_frame_from(&Frame::App(Layout::default()), None, true), linux);
|
||
assert!(!desktop_keeps_frame_from(&Frame::App(Layout::default()), None, false));
|
||
// While `VECTORCRAFT_TITLEBAR` chooses the frame, System Title Bar does not apply and there is no note.
|
||
for (frame, overridden) in [(Frame::App(Layout::default()), Some(true)), (Frame::Desktop, Some(false))] {
|
||
for forced_to_x11 in [false, true] {
|
||
assert!(!desktop_keeps_frame_from(&frame, overridden, forced_to_x11), "{overridden:?} {forced_to_x11}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The file extensions the macOS bundle declares: its document types and its own exported type.
|
||
fn plist_extensions(plist: &str) -> Vec<&str> {
|
||
["<key>CFBundleTypeExtensions</key>", "<key>public.filename-extension</key>"]
|
||
.iter()
|
||
.flat_map(|key| plist.split(key).skip(1))
|
||
.filter_map(|rest| rest.split("</array>").next())
|
||
.flat_map(|array| array.split("<string>").skip(1))
|
||
.filter_map(|s| s.split("</string>").next())
|
||
.collect()
|
||
}
|
||
|
||
/// Finder offers the app for every file File › Open reads (#295, #354) but those with an
|
||
/// extension in `UNASSOCIATED_EXTS`, takes over no other app's files, and hands them to the app
|
||
/// rather than to AppKit's document machinery.
|
||
#[test]
|
||
fn the_macos_bundle_opens_every_readable_format() {
|
||
let plist = include_str!("../../../packaging/macos/Info.plist.in");
|
||
let declared = plist_extensions(plist);
|
||
for e in fileio::OPEN_EXTS.iter().filter(|e| !fileio::UNASSOCIATED_EXTS.contains(e)) {
|
||
assert!(declared.contains(e), "Info.plist.in doesn't declare .{e}");
|
||
}
|
||
for e in &declared {
|
||
assert!(fileio::OPEN_EXTS.contains(e), "Info.plist.in declares .{e}, which the app doesn't open");
|
||
}
|
||
assert!(!plist.contains("<key>NSDocumentClass</key>"), "not an NSDocument app: AppKit would refuse the files");
|
||
let types = plist.matches("<key>CFBundleTypeName</key>").count();
|
||
assert!(types > 1 && plist.matches("<key>LSHandlerRank</key>").count() == types, "every document type has a rank");
|
||
assert_eq!(plist.matches("<string>Owner</string>").count(), 2, "only VectorCraft documents and templates are owned");
|
||
}
|
||
|
||
/// The MIME types a Linux desktop gives each extension in `OPEN_EXTS` but `UNASSOCIATED_EXTS`
|
||
/// (one row per type): shared-mime-info's, or one that `ai.storyteller.vectorcraft.mime.xml`
|
||
/// declares.
|
||
const LINUX_TYPES: &[(&str, &str)] = &[
|
||
("vectorcraft", "application/x-vectorcraft"),
|
||
("drawcraft", "application/x-vectorcraft"),
|
||
("svg", "image/svg+xml"),
|
||
("svgz", "image/svg+xml-compressed"),
|
||
("pdf", "application/pdf"),
|
||
("ai", "application/illustrator"), // brand-ok: shared-mime-info's type
|
||
("ait", "application/x-ai-template"),
|
||
("png", "image/png"),
|
||
("jpg", "image/jpeg"),
|
||
("jpeg", "image/jpeg"),
|
||
("gif", "image/gif"),
|
||
("webp", "image/webp"),
|
||
("tif", "image/tiff"),
|
||
("tiff", "image/tiff"),
|
||
("bmp", "image/bmp"),
|
||
("vctemplate", "application/x-vectorcraft-template"),
|
||
("dxf", "image/vnd.dxf"),
|
||
("emf", "image/emf"),
|
||
("wmf", "image/wmf"),
|
||
("eps", "image/x-eps"),
|
||
("af", "application/x-affinity"),
|
||
("afdesign", "application/x-affinity"),
|
||
("afpub", "application/x-affinity"),
|
||
];
|
||
|
||
/// The types in `LINUX_TYPES` that shared-mime-info defines. The MIME package declares every
|
||
/// other one.
|
||
const LINUX_SHARED_TYPES: &[&str] = &[
|
||
"image/svg+xml",
|
||
"image/svg+xml-compressed",
|
||
"application/pdf",
|
||
"application/illustrator", // brand-ok: shared-mime-info's type
|
||
"image/png",
|
||
"image/jpeg",
|
||
"image/gif",
|
||
"image/webp",
|
||
"image/tiff",
|
||
"image/bmp",
|
||
"image/vnd.dxf",
|
||
"image/emf",
|
||
"image/wmf",
|
||
"image/x-eps",
|
||
];
|
||
|
||
/// Extensions in `LINUX_TYPES` whose type the MIME package matches by file content and not by a
|
||
/// glob, with the package's `<magic>` rule for that type (its lines joined, indentation removed).
|
||
const LINUX_BY_CONTENT: &[(&str, &str)] = &[(
|
||
"af",
|
||
// `MAGIC` at offset 0 and `DOCUMENT_KIND` at offset 8 (crates/affinity/src/container.rs).
|
||
r#"<magic><match type="string" offset="0" value="\x00\xff\x4b\x41"><match type="string" offset="8" value="nsrP"/></match></magic>"#,
|
||
)];
|
||
|
||
/// The desktop entry lists a MIME type for every extension in `OPEN_EXTS` but
|
||
/// `UNASSOCIATED_EXTS`, and the app's MIME package declares the types shared-mime-info doesn't
|
||
/// have.
|
||
#[test]
|
||
fn the_linux_desktop_entry_opens_every_readable_format() {
|
||
let desktop = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.desktop");
|
||
let mime = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.mime.xml");
|
||
let listed: Vec<&str> =
|
||
desktop.lines().find_map(|l| l.strip_prefix("MimeType=")).unwrap_or_default().split(';').filter(|t| !t.is_empty()).collect();
|
||
// Each type the MIME package declares, with the extensions of its globs and its `<magic>`
|
||
// rule (empty when it has none) in the form `LINUX_BY_CONTENT` gives.
|
||
let declared: Vec<(&str, Vec<&str>, String)> = mime
|
||
.split("<mime-type type=\"")
|
||
.skip(1)
|
||
.map(|block| {
|
||
let body = block.split("</mime-type>").next().unwrap_or_default();
|
||
let globs = body.split("<glob pattern=\"*.").skip(1).filter_map(|g| g.split('"').next()).collect();
|
||
let magic = match (body.find("<magic"), body.find("</magic>")) {
|
||
(Some(start), Some(end)) => body[start..end + "</magic>".len()].lines().map(str::trim).collect(),
|
||
_ => String::new(),
|
||
};
|
||
(block.split('"').next().unwrap_or_default(), globs, magic)
|
||
})
|
||
.collect();
|
||
let mut associated: Vec<&str> = fileio::OPEN_EXTS.iter().copied().filter(|e| !fileio::UNASSOCIATED_EXTS.contains(e)).collect();
|
||
let mut typed: Vec<&str> = LINUX_TYPES.iter().map(|(e, _)| *e).collect();
|
||
associated.sort_unstable();
|
||
typed.sort_unstable();
|
||
typed.dedup();
|
||
assert_eq!(typed, associated, "a MIME type for each extension the packaging associates with the app");
|
||
for (e, t) in LINUX_TYPES {
|
||
assert!(listed.contains(t), "the desktop entry doesn't list {t} (.{e})");
|
||
let package = declared.iter().find(|(name, ..)| name == t);
|
||
if LINUX_SHARED_TYPES.contains(t) {
|
||
assert!(package.is_none(), "{t} is shared-mime-info's, and the MIME package declares it again");
|
||
continue;
|
||
}
|
||
let Some((_, globs, magic)) = package else { panic!("the MIME package doesn't declare {t} (.{e})") };
|
||
if let Some((_, rule)) = LINUX_BY_CONTENT.iter().find(|(content, _)| content == e) {
|
||
assert!(!globs.contains(e), "{t} matches .{e} files by content only");
|
||
assert_eq!(magic, rule, "{t}'s magic rule for .{e} files");
|
||
} else {
|
||
assert!(globs.contains(e), "{t} has no *.{e} glob");
|
||
}
|
||
}
|
||
for t in &listed {
|
||
assert!(LINUX_TYPES.iter().any(|(_, known)| known == t), "the desktop entry lists {t}, which the app doesn't open");
|
||
}
|
||
for (t, globs, _) in &declared {
|
||
assert!(listed.contains(t), "the desktop entry doesn't list the declared {t}");
|
||
for g in globs {
|
||
assert!(LINUX_TYPES.contains(&(*g, *t)), "{t}'s *.{g} isn't a file the app opens as {t}");
|
||
}
|
||
}
|
||
}
|