Wayland: explain native file drag-and-drop limitation (#386) (#430)

* Wayland: add persistent file drop guidance (#386)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* Fix Wayland file-drop guidance review

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

* fix(app): pass no dismissal preference for portable notice

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

* Pass no dismissal preference for the prefs save-failure notice

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* notices::post takes the dismiss preference: pass None for the GPU retry notice (#434)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
This commit is contained in:
Alex Gorevski 2026-10-07 04:12:25 -07:00 committed by GitHub
parent 50d52a0848
commit 047aeb747a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 161 additions and 18 deletions

View File

@ -184,6 +184,12 @@ fn main() -> eframe::Result {
let automation = control.as_ref().map(|(_, _, workspace)| workspace.clone());
let mut services = services::native(automation);
services.preset_store = presets;
#[cfg(target_os = "linux")]
let display = tablet::DisplayKind::of(cc);
#[cfg(target_os = "linux")]
{
services.is_wayland = display == Some(tablet::DisplayKind::Wayland);
}
let mut app = PhotocraftApp::new(Session::new(), services);
app.integrated_titlebar = cfg!(target_os = "macos");
// Long commands and file opens run as background jobs with progress and Cancel (#210).
@ -264,12 +270,12 @@ fn main() -> eframe::Result {
// and the X11 reader write into this feed.
app.stylus.feed = stylus_feed;
#[cfg(target_os = "linux")]
tablet::spawn_x11(&app.stylus.feed, tablet::DisplayKind::of(cc));
tablet::spawn_x11(&app.stylus.feed, display);
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
app.open_paths(&files);
// Portable marker found but its data folder isn't writable (#228): say where settings went.
if let Some(w) = &app_dirs::current().warning {
photocraft_ui_egui::notices::post(&mut app, "Portable mode is off", vec![w.clone()], false);
photocraft_ui_egui::notices::post(&mut app, "Portable mode is off", vec![w.clone()], false, None);
}
Ok(Box::new(app))
}),

View File

@ -191,6 +191,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
os_events: None,
// Set by main, which starts loading the store before the window opens.
preset_store: None,
is_wayland: false,
..recovery_services(recovery_dir())
}
}

View File

@ -7,6 +7,7 @@
//! ```
//!
//! `--safe-gpu` draws the canvas on the CPU path, like the app's `--safe-gpu` launch.
//! `--wayland-notice` previews the native file drag-and-drop guidance shown in Wayland sessions.
//!
//! `--monitor 1366x768 --window-top 31` simulates the display the window is on (in points) and
//! where its content starts on it, e.g. a window running under a Windows taskbar.
@ -29,6 +30,7 @@ fn main() {
.and_then(|s| s.split_once('x').and_then(|(a, b)| Some((a.parse::<f32>().ok()?, b.parse::<f32>().ok()?))))
.unwrap_or((1440.0, 900.0));
let scale: f32 = arg(&args, "--scale").and_then(|s| s.parse().ok()).unwrap_or(2.0);
let wayland_notice = args.iter().any(|a| a == "--wayland-notice");
let script: Vec<(String, Value)> =
arg(&args, "--script").map(|s| serde_json::from_str::<Vec<(String, Value)>>(&s).expect("--script must be [[method, params], …]")).unwrap_or_default();
@ -53,6 +55,8 @@ fn main() {
let mut harness =
egui_kittest::Harness::builder().with_size(egui::vec2(w, h)).with_pixels_per_point(scale).with_max_steps(64).wgpu().build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut services = services;
services.is_wayland = wayland_notice;
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
app.background_jobs = background_jobs;
// `--safe-gpu`: the CPU canvas, as the desktop app's `--safe-gpu` launch.

View File

@ -1181,7 +1181,7 @@ fn start_screen(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
});
ui.add_space(22.0);
ui.horizontal(|ui| {
let msg = tl!("Drop an image or PSD anywhere to open it.");
let msg = start_screen_drop_hint(app.services.is_wayland);
let g = ui.painter().layout_no_wrap(msg.into(), egui::FontId::proportional(12.5), t.text_faint);
ui.add_space(((card.width() - g.size().x - 24.0) / 2.0).max(0.0));
let (r, _) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), Sense::hover());
@ -1200,6 +1200,10 @@ fn start_screen(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
});
}
fn start_screen_drop_hint(is_wayland: bool) -> &'static str {
if is_wayland { tl!("Use File › Open to open an image.") } else { tl!("Drop an image or PSD anywhere to open it.") }
}
/// Recent files listed on the Home screen.
const HOME_RECENT: usize = 6;
/// Height of one Home-screen recent-file row.
@ -2496,6 +2500,11 @@ fn hex(c: [f32; 4]) -> String {
mod tests {
use super::*;
#[test]
fn wayland_start_screen_hint_does_not_claim_file_drop_works() {
assert_ne!(start_screen_drop_hint(true), start_screen_drop_hint(false));
}
#[test]
fn pointer_moves_are_bounded_by_the_press_and_release() {
use egui::{Event, PointerButton, pos2};

View File

@ -224,7 +224,7 @@ fn dropped_files_open_with_path_and_recent() {
fn notices_are_capped_and_dismissable_state_round_trips() {
let (mut app, _) = app_with(None, None);
for i in 0..10 {
notices::post(&mut app, format!("n{i}"), vec![], false);
notices::post(&mut app, format!("n{i}"), vec![], false, None);
}
assert_eq!(app.ui.notices.len(), notices::MAX_NOTICES);
assert_eq!(app.ui.notices.last().map(|n| n.title.as_str()), Some("n9"));
@ -238,7 +238,7 @@ fn notices_are_capped_and_dismissable_state_round_trips() {
#[test]
fn notices_render_without_panicking() {
let (mut app, _) = app_with(None, None);
notices::post(&mut app, "Opened a.psd with 9 warnings", (0..9).map(|i| format!("warning {i}")).collect(), false);
notices::post(&mut app, "Opened a.psd with 9 warnings", (0..9).map(|i| format!("warning {i}")).collect(), false, None);
notices::error(&mut app, "Couldn't open b.psd: not an image".into());
let ctx = egui::Context::default();
let mut out = ctx.run_ui(Default::default(), |ui| notices::show(&mut app, ui.ctx()));

View File

@ -100,6 +100,7 @@ fn choose_recovery(app: &mut PhotocraftApp, retry: bool) -> Result<(), String> {
"GPU retry scheduled",
vec!["Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session.".into()],
false,
None,
);
}
Ok(())

View File

@ -976,9 +976,12 @@
Drag to move the active shape's path or the Work Path Tažením přesunete cestu aktivního tvaru nebo pracovní cestu
Draw with the Pen tool (P) or make a work path from a selection. Kreslete nástrojem pero (P) nebo vytvořte pracovní cestu z výběru.
Drop an image or PSD anywhere to open it. Přetáhněte sem obrázek nebo PSD a otevře se.
Native file drag-and-drop is unavailable Nativní přetahování souborů ve Waylandu není dostupné
Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Nativní přetahování souborů ve Waylandu zatím není podporováno. Použijte Soubor › Otevřít nebo spusťte PhotoCraft v XWayland s `WAYLAND_DISPLAY= photocraft`.
Duplicate Layers… Duplikovat vrstvy…
Duplicate style Duplikovat styl
Edit Toolbar… Upravit panel nástrojů…
Use File › Open to open an image. Obrázek otevřete přes Soubor › Otevřít.
Edit in Quick Mask Mode (Q) Upravit v režimu rychlé masky (Q)
Edit in Standard Mode (Q) Upravit ve standardním režimu (Q)
Effects Efekty

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -961,9 +961,12 @@
Drag to move the active shape's path or the Work Path Arrastre para mover el trazado de la forma activa o el Trazado de trabajo
Draw with the Pen tool (P) or make a work path from a selection. Dibuja con la herramienta Pluma (P) o crea un trazado de trabajo a partir de una selección.
Drop an image or PSD anywhere to open it. Suelte una imagen o un PSD en cualquier lugar para abrirlo.
Native file drag-and-drop is unavailable El arrastrar y soltar archivos de forma nativa no está disponible en Wayland
Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. El arrastrar y soltar archivos de forma nativa aún no está disponible en Wayland. Usa Archivo › Abrir o ejecuta PhotoCraft bajo XWayland con `WAYLAND_DISPLAY= photocraft`.
Duplicate Layers… Duplicar capas…
Duplicate style Duplicar estilo
Edit Toolbar… Editar barra de herramientas…
Use File › Open to open an image. Usa Archivo › Abrir para abrir una imagen.
Edit in Quick Mask Mode (Q) Editar en modo de máscara rápida (Q)
Edit in Standard Mode (Q) Editar en modo estándar (Q)
Effects Efectos

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -966,9 +966,12 @@
Drag to move the active shape's path or the Work Path ドラッグして、アクティブなシェイプのパスまたは作業用パスを移動
Draw with the Pen tool (P) or make a work path from a selection. ペンツール(P)で描くか、選択範囲から作業用パスを作成します。
Drop an image or PSD anywhere to open it. 画像や PSD をここにドロップすると開きます。
Native file drag-and-drop is unavailable Wayland ではネイティブのファイルドラッグ&ドロップを利用できません
Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Wayland ではネイティブのファイルドラッグ&ドロップはまだ利用できません。[ファイル › 開く] を使うか、`WAYLAND_DISPLAY= photocraft` を指定して PhotoCraft を XWayland 上で起動してください。
Duplicate Layers… レイヤーを複製…
Duplicate style スタイルを複製
Edit Toolbar… ツールバーを編集…
Use File › Open to open an image. 画像を開くには [ファイル › 開く] を使ってください。
Edit in Quick Mask Mode (Q) クイックマスクモードで編集(Q)
Edit in Standard Mode (Q) 標準モードで編集(Q)
Effects 効果

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1368,6 +1368,9 @@
BLANK DOCUMENT PRESETS ({n}) НАБОРЫ ПУСТЫХ ДОКУМЕНТОВ ({n})
Create a new document or open an existing file. Создайте новый документ или откройте существующий файл.
Drop an image or PSD anywhere to open it. Перетащите изображение или PSD в любое место окна, чтобы открыть.
Use File › Open to open an image. Чтобы открыть изображение, выберите Файл › Открыть.
Native file drag-and-drop is unavailable Нативное перетаскивание файлов в Wayland недоступно
Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Нативное перетаскивание файлов в Wayland пока не поддерживается. Откройте файл через Файл › Открыть или запустите PhotoCraft в XWayland с `WAYLAND_DISPLAY= photocraft`.
Photo Фото
Print Печать
Art & Illustration Искусство и иллюстрация

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -965,9 +965,12 @@
Drag to move the active shape's path or the Work Path 拖动以移动当前形状的路径或工作路径
Draw with the Pen tool (P) or make a work path from a selection. 使用钢笔工具(P)绘制,或从选区创建工作路径。
Drop an image or PSD anywhere to open it. 将图像或 PSD 拖放到任意位置即可打开。
Native file drag-and-drop is unavailable Wayland 上不支持原生文件拖放
Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Wayland 尚不支持原生文件拖放。请使用“文件 › 打开”,或通过 `WAYLAND_DISPLAY= photocraft` 在 XWayland 下运行 PhotoCraft。
Duplicate Layers… 复制图层…
Duplicate style 复制样式
Edit Toolbar… 编辑工具栏…
Use File › Open to open an image. 使用“文件 › 打开”打开图像。
Edit in Quick Mask Mode (Q) 在快速蒙版模式下编辑 (Q)
Edit in Standard Mode (Q) 在标准模式下编辑 (Q)
Effects 效果

Can't render this file because it has a wrong number of fields in line 13.

View File

@ -967,9 +967,12 @@
Drag to move the active shape's path or the Work Path 拖曳以移動作用中形狀的路徑或工作路徑
Draw with the Pen tool (P) or make a work path from a selection. 使用筆型工具 (P) 繪製,或從選取範圍建立工作路徑。
Drop an image or PSD anywhere to open it. 將影像或 PSD 拖放到任何位置即可開啟。
Native file drag-and-drop is unavailable Wayland 不支援原生檔案拖放
Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Wayland 尚未支援原生檔案拖放。請使用「檔案 › 開啟」,或透過 `WAYLAND_DISPLAY= photocraft` 在 XWayland 下執行 PhotoCraft。
Duplicate Layers… 複製圖層…
Duplicate style 複製樣式
Edit Toolbar… 編輯工具列…
Use File › Open to open an image. 使用「檔案 › 開啟」開啟影像。
Edit in Quick Mask Mode (Q) 在快速遮色片模式下編輯 (Q)
Edit in Standard Mode (Q) 在標準模式下編輯 (Q)
Effects 效果

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -223,6 +223,8 @@ pub struct Services {
/// the web (see `prefs_ui`).
pub load_prefs: Option<LoadTextFn>,
pub save_prefs: Option<SaveTextFn>,
/// The native window is connected directly to a Wayland compositor.
pub is_wayland: bool,
/// Crash-recovery autosave (Preferences › File Handling) and recovery at launch.
pub autosave: Option<AutosaveFn>,
pub discard_autosave: Option<DiscardAutosaveFn>,
@ -451,6 +453,7 @@ impl PhotocraftApp {
};
// Saved preferences (and recovered documents) are in place before the first frame.
prefs_ui::load(&mut app);
notices::wayland_file_drop_guidance(&mut app);
// File › Scripts › Script Events Manager: "Start Application".
photocraft_engine::automate_cmds::fire_event(&mut app.session, "startApplication");
app

View File

@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
use crate::PhotocraftApp;
const WAYLAND_FILE_DROP_DISMISSED: &str = "ui.waylandFileDropGuidanceDismissed";
/// At most this many notices are kept; older ones drop off.
pub const MAX_NOTICES: usize = 3;
/// Lines shown per notice before "…and N more".
@ -21,19 +22,55 @@ pub struct Notice {
/// An error (warning colour) rather than an informational notice.
#[serde(default)]
pub error: bool,
/// Preference key to set when this notice is dismissed.
#[serde(default)]
pub dismiss_pref: Option<String>,
}
/// Show a notice (newest last); returns its id.
pub fn post(app: &mut PhotocraftApp, title: impl Into<String>, lines: Vec<String>, error: bool) -> u64 {
pub fn post(app: &mut PhotocraftApp, title: impl Into<String>, lines: Vec<String>, error: bool, dismiss_pref: Option<&str>) -> u64 {
let id = app.ui.alloc_id();
app.ui.notices.push(Notice { id, title: title.into(), lines, error });
let n = app.ui.notices.len();
if n > MAX_NOTICES {
app.ui.notices.drain(..n - MAX_NOTICES);
}
app.ui.notices.push(Notice { id, title: title.into(), lines, error, dismiss_pref: dismiss_pref.map(str::to_owned) });
cap_notices(app);
id
}
fn cap_notices(app: &mut PhotocraftApp) {
while app.ui.notices.len() > MAX_NOTICES {
let oldest_temporary = app.ui.notices.iter().position(|notice| notice.dismiss_pref.is_none()).unwrap_or(0);
app.ui.notices.remove(oldest_temporary);
}
}
/// Show the Wayland-specific fallback guidance unless the user dismissed it in preferences.
pub fn wayland_file_drop_guidance(app: &mut PhotocraftApp) {
if !app.services.is_wayland
|| app.session.prefs().dialogs.get(WAYLAND_FILE_DROP_DISMISSED).and_then(serde_json::Value::as_bool) == Some(true)
|| app.ui.notices.iter().any(|notice| notice.dismiss_pref.as_deref() == Some(WAYLAND_FILE_DROP_DISMISSED))
{
return;
}
post(
app,
tl!("Native file drag-and-drop is unavailable"),
vec![
tl!("Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`.").into(),
],
false,
Some(WAYLAND_FILE_DROP_DISMISSED),
);
}
fn dismiss(app: &mut PhotocraftApp, id: u64) {
let dismiss_pref = app.ui.notices.iter().find(|notice| notice.id == id).and_then(|notice| notice.dismiss_pref.clone());
app.ui.notices.retain(|notice| notice.id != id);
if let Some(key) = dismiss_pref {
app.session.prefs.edit(|prefs| {
prefs.dialogs.insert(key, serde_json::Value::Bool(true));
});
}
}
/// Report import/export `warnings` for the file operation `what` (e.g. "Opened a.psd"): the status
/// bar says how many there were, and a notice lists them. Nothing happens when there are none.
pub fn io_warnings(app: &mut PhotocraftApp, what: &str, warnings: &[String]) {
@ -41,7 +78,7 @@ pub fn io_warnings(app: &mut PhotocraftApp, what: &str, warnings: &[String]) {
let n = warnings.len();
app.ui.status = if n == 1 { format!("{what}: {first}") } else { format!("{what} with {n} warnings: {first} …") };
app.ui.status_error = true;
post(app, format!("{what} with {n} warning{}", if n == 1 { "" } else { "s" }), warnings.to_vec(), false);
post(app, format!("{what} with {n} warning{}", if n == 1 { "" } else { "s" }), warnings.to_vec(), false, None);
}
/// Report a failed file operation: the status bar shows it as an error and a notice keeps it on
@ -49,7 +86,7 @@ pub fn io_warnings(app: &mut PhotocraftApp, what: &str, warnings: &[String]) {
pub fn error(app: &mut PhotocraftApp, message: String) {
app.ui.status = message.clone();
app.ui.status_error = true;
post(app, message, Vec::new(), true);
post(app, message, Vec::new(), true, None);
}
/// Draw the notices; each has a close button.
@ -58,7 +95,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
return;
}
let t = crate::theme::Tokens::get(ctx);
let mut dismiss = None;
let mut dismiss_id = None;
// Clear the status bar (~24 px) and leave the dock's edge some air.
egui::Area::new(egui::Id::new("photocraft-notices"))
.order(egui::Order::Foreground)
@ -80,7 +117,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
ui.add(egui::Label::new(title).wrap());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
if ui.add(egui::Button::new(egui::RichText::new("×").color(t.text_dim)).frame(false)).on_hover_text(tl!("Dismiss")).clicked() {
dismiss = Some(n.id);
dismiss_id = Some(n.id);
}
});
});
@ -94,7 +131,71 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
ui.add_space(6.0);
}
});
if let Some(id) = dismiss {
app.ui.notices.retain(|n| n.id != id);
if let Some(id) = dismiss_id {
dismiss(app, id);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{PhotocraftApp, Services};
use photocraft_engine::Session;
use std::sync::{Arc, Mutex};
#[test]
fn wayland_guidance_is_only_shown_in_wayland_sessions() {
let mut app = PhotocraftApp::new(Session::new(), Services { is_wayland: true, ..Default::default() });
assert_eq!(app.ui.notices.len(), 1);
assert_eq!(app.ui.notices[0].title, "Native file drag-and-drop is unavailable");
let guidance = app.ui.notices[0].lines.join(" ");
assert!(guidance.contains("not supported on Wayland yet"));
assert!(guidance.contains("File › Open"));
assert!(!guidance.contains("Ctrl+V"));
assert!(guidance.contains("XWayland"));
assert!(guidance.contains("WAYLAND_DISPLAY= photocraft"));
assert_eq!(app.ui.notices[0].dismiss_pref.as_deref(), Some(WAYLAND_FILE_DROP_DISMISSED));
for i in 0..MAX_NOTICES {
post(&mut app, format!("Transient {i}"), Vec::new(), false, None);
}
assert_eq!(app.ui.notices.len(), MAX_NOTICES);
assert!(app.ui.notices.iter().any(|notice| notice.dismiss_pref.is_some()));
let app = PhotocraftApp::new(Session::new(), Services::default());
assert!(app.ui.notices.is_empty());
}
#[test]
fn dismissing_wayland_guidance_persists_across_launches() {
let saved = Arc::new(Mutex::new(None::<String>));
let load_store = saved.clone();
let save_store = saved.clone();
let services = Services {
is_wayland: true,
load_prefs: Some(Box::new(move || load_store.lock().unwrap_or_else(|e| e.into_inner()).clone())),
save_prefs: Some(Box::new(move |text| {
*save_store.lock().unwrap_or_else(|e| e.into_inner()) = Some(text.to_owned());
Ok(())
})),
..Default::default()
};
let mut app = PhotocraftApp::new(Session::new(), services);
let id = app.ui.notices[0].id;
dismiss(&mut app, id);
crate::prefs_ui::tick(&mut app, &egui::Context::default());
let services =
Services { is_wayland: true, load_prefs: Some(Box::new(move || saved.lock().unwrap_or_else(|e| e.into_inner()).clone())), ..Default::default() };
let app = PhotocraftApp::new(Session::new(), services);
assert!(app.ui.notices.is_empty());
}
#[test]
fn dismissing_a_notice_writes_its_own_preference_key() {
let mut app = PhotocraftApp::new(Session::new(), Services::default());
post(&mut app, "Dismissible", Vec::new(), false, Some("ui.testNoticeDismissed"));
let id = app.ui.notices[0].id;
dismiss(&mut app, id);
assert_eq!(app.session.prefs().dialogs.get("ui.testNoticeDismissed").and_then(serde_json::Value::as_bool), Some(true));
}
}

View File

@ -292,7 +292,7 @@ fn persist(app: &mut PhotocraftApp, now: f64) -> Option<f64> {
app.ui.status_error = true;
} else if failures == SAVE_NOTICE_AFTER {
let lines = vec![e, "PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes.".into()];
let id = crate::notices::post(app, "Preferences can't be saved", lines, true);
let id = crate::notices::post(app, "Preferences can't be saved", lines, true, None);
app.prefs_rt.save_retry.notice = Some(id);
}
Some(delay)