# Conflicts: # ROADMAP.md
This commit is contained in:
commit
d9a2cfe7f9
File diff suppressed because one or more lines are too long
|
|
@ -34,6 +34,7 @@ mod window;
|
|||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_ui_egui::graphics::GraphicsLoss;
|
||||
use vectorcraft_ui_egui::picks::PickRequest;
|
||||
use vectorcraft_ui_egui::{ClipboardProbeFactory, FilePick, Services, VectorcraftApp};
|
||||
|
||||
struct App {
|
||||
|
|
@ -185,9 +186,22 @@ fn save_prefs(app: &VectorcraftApp) {
|
|||
}
|
||||
}
|
||||
|
||||
/// A native file dialog showing `pick`'s file types, folder and suggested name.
|
||||
fn file_dialog(pick: &FilePick) -> rfd::FileDialog {
|
||||
let d = pick.filters.iter().fold(rfd::FileDialog::new(), |d, (name, exts)| d.add_filter(*name, exts));
|
||||
/// The window file dialogs belong to.
|
||||
type Parent = Option<std::sync::Arc<winit::window::Window>>;
|
||||
|
||||
/// A native file dialog for `request` over `parent` (Windows and Linux, where the portal then
|
||||
/// shows it over the window and keeps it in front; macOS shows them as before).
|
||||
fn file_dialog(request: &PickRequest, parent: &Parent) -> rfd::FileDialog {
|
||||
let d = match parent {
|
||||
Some(w) if !cfg!(target_os = "macos") => rfd::FileDialog::new().set_parent(&**w),
|
||||
_ => rfd::FileDialog::new(),
|
||||
};
|
||||
let pick = match request {
|
||||
PickRequest::Open(pick) | PickRequest::Save(pick) => pick,
|
||||
PickRequest::OpenMany => return fileio::place_filters().fold(d.set_title("Place"), |d, (name, exts)| d.add_filter(name, exts)),
|
||||
PickRequest::Folder => return d,
|
||||
};
|
||||
let d = pick.filters.iter().fold(d, |d, (name, exts)| d.add_filter(*name, exts));
|
||||
let d = match &pick.folder {
|
||||
Some(folder) => d.set_directory(folder),
|
||||
None => d,
|
||||
|
|
@ -195,6 +209,28 @@ fn file_dialog(pick: &FilePick) -> rfd::FileDialog {
|
|||
if pick.name.is_empty() { d } else { d.set_file_name(&pick.name) }
|
||||
}
|
||||
|
||||
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked.
|
||||
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec<String> {
|
||||
let paths = match request {
|
||||
PickRequest::Open(_) => dialog.pick_file().into_iter().collect(),
|
||||
PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(),
|
||||
PickRequest::Save(pick) => {
|
||||
// The Templates folder may not exist yet.
|
||||
if let Some(folder) = &pick.folder {
|
||||
let _ = std::fs::create_dir_all(folder);
|
||||
}
|
||||
dialog.save_file().into_iter().collect()
|
||||
}
|
||||
PickRequest::Folder => dialog.pick_folder().into_iter().collect(),
|
||||
};
|
||||
paths.into_iter().map(|p| p.to_string_lossy().to_string()).collect()
|
||||
}
|
||||
|
||||
/// Show the dialog for `request` over `parent` now → the paths picked.
|
||||
fn pick_now(request: PickRequest, parent: &Parent) -> Vec<String> {
|
||||
show_dialog(file_dialog(&request, parent), &request)
|
||||
}
|
||||
|
||||
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
|
||||
fn reveal(path: &str) -> Result<(), String> {
|
||||
reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}"))
|
||||
|
|
@ -230,25 +266,31 @@ fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
|
|||
fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn services() -> Services {
|
||||
/// Linux: show the dialog for `request` over `parent` on a thread of its own, answering on the
|
||||
/// receiver. Shown on the UI thread, nothing would answer the compositor meanwhile, which then
|
||||
/// offers to kill the window as not responding (#592).
|
||||
fn start_pick(request: PickRequest, parent: &Parent) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
|
||||
let dialog = file_dialog(&request, parent);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::Builder::new()
|
||||
.name("file-dialog".into())
|
||||
.spawn(move || {
|
||||
// The app gone meanwhile has no use for the answer.
|
||||
let _ = tx.send(show_dialog(dialog, &request));
|
||||
})
|
||||
.ok()?;
|
||||
Some(rx)
|
||||
}
|
||||
|
||||
fn services(parent: Parent) -> Services {
|
||||
let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent);
|
||||
Services {
|
||||
pick_open: Some(Box::new(|pick: &FilePick| file_dialog(pick).pick_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
pick_open_multi: Some(Box::new(|| {
|
||||
fileio::place_filters()
|
||||
.fold(rfd::FileDialog::new().set_title("Place"), |d, (name, exts)| d.add_filter(name, exts))
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})),
|
||||
pick_save: Some(Box::new(|pick: &FilePick| {
|
||||
// The Templates folder may not exist yet.
|
||||
if let Some(folder) = &pick.folder {
|
||||
let _ = std::fs::create_dir_all(folder);
|
||||
}
|
||||
file_dialog(pick).save_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())),
|
||||
pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))),
|
||||
pick_save: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Save(pick.clone()), &p3).into_iter().next())),
|
||||
// Windows and macOS dialogs run the window's events while they are open; Linux's don't.
|
||||
start_pick: cfg!(all(unix, not(target_os = "macos")))
|
||||
.then(|| Box::new(move |request: PickRequest| start_pick(request, &p5)) as vectorcraft_ui_egui::picks::StartPick),
|
||||
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(write_file)),
|
||||
// Background Save and Export write from a worker thread.
|
||||
|
|
@ -266,7 +308,7 @@ fn services() -> Services {
|
|||
reveal: Some(Box::new(reveal)),
|
||||
// Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders.
|
||||
open_file: Some(Box::new(open_file)),
|
||||
pick_folder: Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))),
|
||||
pick_folder: Some(Box::new(move || pick_now(PickRequest::Folder, &p4).into_iter().next())),
|
||||
// File → Print: the system's printers and print queue.
|
||||
print: Some(Box::new(printing::SystemPrint)),
|
||||
..Default::default()
|
||||
|
|
@ -397,7 +439,7 @@ fn main() -> std::process::ExitCode {
|
|||
"VectorCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = VectorcraftApp::new(Session::new(), services());
|
||||
let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned()));
|
||||
load_prefs(&mut app, saved);
|
||||
// Fit the window to its monitor, or put it back where it was (still hidden).
|
||||
if let Some(w) = cc.winit_window() {
|
||||
|
|
|
|||
|
|
@ -424,11 +424,8 @@ fn destination(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
|||
let pick = app.services.pick_folder.is_some();
|
||||
ui.horizontal(|ui| {
|
||||
form::text(ui, d, "folder", if pick { 300.0 } else { 334.0 });
|
||||
if pick
|
||||
&& widgets::icon_button(ui, "folder-open", tl!("Choose a folder"), false, 26.0).clicked()
|
||||
&& let Some(f) = app.services.pick_folder.as_mut().and_then(|pick| pick())
|
||||
{
|
||||
d.fields.insert("folder".into(), json!(f));
|
||||
if pick && widgets::icon_button(ui, "folder-open", tl!("Choose a folder"), false, 26.0).clicked() {
|
||||
crate::picks::folder_field(app, d, "folder");
|
||||
}
|
||||
});
|
||||
form::check(ui, d, "openLocation", tl!("Open Location after Export"));
|
||||
|
|
|
|||
|
|
@ -145,10 +145,17 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
|||
}
|
||||
}
|
||||
}
|
||||
if let Some(a) = act
|
||||
&& let Err(e) = run(app, d, a, &preset.name)
|
||||
{
|
||||
app.status(e);
|
||||
if let Some(a) = act {
|
||||
let r = if matches!(a, Action::Import) {
|
||||
// Its file dialog, shown off the UI thread, imports into the dialog as it is then.
|
||||
let current = preset.name.clone();
|
||||
crate::picks::in_dialog(app, d, move |app, d| run(app, d, Action::Import, ¤t))
|
||||
} else {
|
||||
run(app, d, a, &preset.name)
|
||||
};
|
||||
if let Err(e) = r {
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
|
@ -181,7 +188,7 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) ->
|
|||
return Ok(());
|
||||
}
|
||||
let pick = crate::FilePick { filters: vec![("Flattener presets", PRESET_EXTS)], ..Default::default() };
|
||||
let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?;
|
||||
let path = crate::picks::open(app, &pick).ok_or("cancelled")?;
|
||||
let r = app.run("flattener.presets.import", json!({ "path": path }))?;
|
||||
if let Some(first) = r["imported"].get(0).and_then(Value::as_str) {
|
||||
select(d, first);
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
|||
p if !p.is_empty() => p,
|
||||
_ => {
|
||||
let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::place_filters().collect(), ..Default::default() };
|
||||
app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?
|
||||
crate::picks::open(app, &pick).ok_or("cancelled")?
|
||||
}
|
||||
};
|
||||
out = app.run("links.relink", json!({ "ids": current["ids"], "path": path }))?;
|
||||
|
|
|
|||
|
|
@ -315,7 +315,8 @@ pub fn cancel(app: &mut VectorcraftApp) {
|
|||
/// Apply the open dialog (OK).
|
||||
pub fn confirm(app: &mut VectorcraftApp) -> DialogResult {
|
||||
let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };
|
||||
(spec(&d.kind).confirm)(app, &d)
|
||||
// A file dialog it shows off the UI thread confirms the dialog as it is again.
|
||||
crate::picks::as_entry(app, || crate::picks::Entry::Confirm(Box::new(d.clone())), |app| (spec(&d.kind).confirm)(app, &d))
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
|
|
|
|||
|
|
@ -70,11 +70,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
|||
if !downloads(app) {
|
||||
widgets::label_row(ui, tl!("Location:"), LABEL, |ui| {
|
||||
form::text(ui, d, "folder", 230.0);
|
||||
if let Some(pick) = app.services.pick_folder.as_mut()
|
||||
&& ui.button(tl!("Choose…")).clicked()
|
||||
&& let Some(f) = pick()
|
||||
{
|
||||
d.fields.insert("folder".into(), json!(f));
|
||||
if app.services.pick_folder.is_some() && ui.button(tl!("Choose…")).clicked() {
|
||||
crate::picks::folder_field(app, d, "folder");
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
|
|
|
|||
|
|
@ -102,10 +102,17 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
|||
details(ui, &t, preset, builtin);
|
||||
});
|
||||
});
|
||||
if let Some(a) = act
|
||||
&& let Err(e) = run(app, d, a, &preset.name)
|
||||
{
|
||||
app.status(e);
|
||||
if let Some(a) = act {
|
||||
let r = if matches!(a, Action::Import) {
|
||||
// Its file dialog, shown off the UI thread, imports into the dialog as it is then.
|
||||
let current = preset.name.clone();
|
||||
crate::picks::in_dialog(app, d, move |app, d| run(app, d, Action::Import, ¤t))
|
||||
} else {
|
||||
run(app, d, a, &preset.name)
|
||||
};
|
||||
if let Err(e) = r {
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
|
@ -169,7 +176,7 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) ->
|
|||
return Ok(());
|
||||
}
|
||||
let pick = crate::FilePick { filters: vec![("PDF presets", vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS)], ..Default::default() };
|
||||
let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?;
|
||||
let path = crate::picks::open(app, &pick).ok_or("cancelled")?;
|
||||
let r = app.run("pdf.preset.import", json!({ "path": path }))?;
|
||||
if let Some(first) = r["imported"].get(0).and_then(Value::as_str) {
|
||||
select(d, first);
|
||||
|
|
|
|||
|
|
@ -150,10 +150,17 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
|||
});
|
||||
});
|
||||
let name = preset.name.clone();
|
||||
if let Some(a) = act
|
||||
&& let Err(e) = run(app, d, a, &name)
|
||||
{
|
||||
app.status(e);
|
||||
if let Some(a) = act {
|
||||
let r = if matches!(a, Action::Import) {
|
||||
// Its file dialog, shown off the UI thread, imports into the dialog as it is then.
|
||||
let current = name.clone();
|
||||
crate::picks::in_dialog(app, d, move |app, d| run(app, d, Action::Import, ¤t))
|
||||
} else {
|
||||
run(app, d, a, &name)
|
||||
};
|
||||
if let Err(e) = r {
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
|
@ -187,7 +194,7 @@ pub(super) fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current
|
|||
return Ok(());
|
||||
}
|
||||
let pick = crate::FilePick { filters: vec![("Perspective grid presets", PRESET_EXTS)], ..Default::default() };
|
||||
let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?;
|
||||
let path = crate::picks::open(app, &pick).ok_or("cancelled")?;
|
||||
let r = app.run("perspective.presets.import", json!({ "path": path }))?;
|
||||
if let Some(first) = r["imported"].get(0).and_then(Value::as_str) {
|
||||
select(d, first);
|
||||
|
|
|
|||
|
|
@ -111,10 +111,17 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
|||
}
|
||||
});
|
||||
});
|
||||
if let Some(a) = act
|
||||
&& let Err(e) = run(app, d, a, &name)
|
||||
{
|
||||
app.status(e);
|
||||
if let Some(a) = act {
|
||||
let r = if matches!(a, Action::Import) {
|
||||
// Its file dialog, shown off the UI thread, imports into the dialog as it is then.
|
||||
let current = name.clone();
|
||||
crate::picks::in_dialog(app, d, move |app, d| run(app, d, Action::Import, ¤t))
|
||||
} else {
|
||||
run(app, d, a, &name)
|
||||
};
|
||||
if let Err(e) = r {
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
|
@ -149,7 +156,7 @@ pub(super) fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current
|
|||
return Ok(());
|
||||
}
|
||||
let pick = crate::FilePick { filters: vec![("Print presets", PRESET_EXTS)], ..Default::default() };
|
||||
let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?;
|
||||
let path = crate::picks::open(app, &pick).ok_or("cancelled")?;
|
||||
let r = app.run("print.presets.import", json!({ "path": path }))?;
|
||||
if let Some(first) = r["imported"].get(0).and_then(Value::as_str) {
|
||||
select(d, first);
|
||||
|
|
|
|||
|
|
@ -634,3 +634,4 @@
|
|||
Clipboard Handling Práce se schránkou
|
||||
Appearance of Black Vzhled černé
|
||||
Devices Zařízení
|
||||
A file dialog is open: choose there first Je otevřené dialogové okno souboru: nejprve vyberte v něm
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3598,3 +3598,4 @@ axis Both Ambos
|
|||
Off: an editable copy of its art, embedded in this document. Desactivado: una copia editable de su ilustración, incrustada en este documento.
|
||||
Images: link to the file instead of only embedding it. Imágenes: vincular al archivo en lugar de solo incrustarlo.
|
||||
VectorCraft documents: one locked object, updated when the file changes. Documentos de VectorCraft: un objeto bloqueado que se actualiza cuando cambia el archivo.
|
||||
A file dialog is open: choose there first Hay un cuadro de diálogo de archivos abierto: elija primero en él
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3598,3 +3598,4 @@ axis Both Entrambi
|
|||
Off: an editable copy of its art, embedded in this document. Disattivato: una copia modificabile della sua grafica, incorporata in questo documento.
|
||||
Images: link to the file instead of only embedding it. Immagini: collega il file invece di incorporarlo soltanto.
|
||||
VectorCraft documents: one locked object, updated when the file changes. Documenti VectorCraft: un unico oggetto bloccato, aggiornato quando il file cambia.
|
||||
A file dialog is open: choose there first È aperta una finestra di dialogo dei file: scegli prima lì
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3060,3 +3060,4 @@ theme Appearance 外観
|
|||
Off: an editable copy of its art, embedded in this document. オフ:アートの編集可能なコピーをこのドキュメントに埋め込みます。
|
||||
Images: link to the file instead of only embedding it. 画像:埋め込むだけでなく、ファイルにリンクします。
|
||||
VectorCraft documents: one locked object, updated when the file changes. VectorCraft ドキュメント:ロックされた 1 つのオブジェクト。ファイルの変更時に更新されます。
|
||||
A file dialog is open: choose there first ファイルダイアログが開いています。先にそちらで選択してください
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -2648,3 +2648,4 @@
|
|||
Off: an editable copy of its art, embedded in this document. Desativado: uma cópia editável da arte, incorporada neste documento.
|
||||
Images: link to the file instead of only embedding it. Imagens: vincular ao arquivo em vez de apenas incorporá-lo.
|
||||
VectorCraft documents: one locked object, updated when the file changes. Documentos do VectorCraft: um objeto bloqueado, atualizado quando o arquivo muda.
|
||||
A file dialog is open: choose there first Há uma caixa de diálogo de arquivos aberta: escolha nela primeiro
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -3596,3 +3596,4 @@ axis Both Оба
|
|||
Off: an editable copy of its art, embedded in this document. Выкл.: редактируемая копия его рисунка, внедрённая в этот документ.
|
||||
Images: link to the file instead of only embedding it. Изображения: связывать с файлом вместо простого внедрения.
|
||||
VectorCraft documents: one locked object, updated when the file changes. Документы VectorCraft: один заблокированный объект, обновляемый при изменении файла.
|
||||
A file dialog is open: choose there first Открыто окно выбора файла: сначала сделайте выбор в нём
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3315,3 +3315,4 @@ axis Both 两者
|
|||
Off: an editable copy of its art, embedded in this document. 关闭:将其图稿的可编辑副本嵌入此文档。
|
||||
Images: link to the file instead of only embedding it. 图像:链接到文件,而不只是嵌入。
|
||||
VectorCraft documents: one locked object, updated when the file changes. VectorCraft 文档:一个锁定对象,文件更改时随之更新。
|
||||
A file dialog is open: choose there first 文件对话框已打开:请先在其中选择
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3090,3 +3090,4 @@ axis Both 兩者
|
|||
Off: an editable copy of its art, embedded in this document. 關閉:將其圖稿的可編輯副本內嵌在此文件中。
|
||||
Images: link to the file instead of only embedding it. 影像:連結至檔案,而不只是嵌入。
|
||||
VectorCraft documents: one locked object, updated when the file changes. VectorCraft 文件:單一鎖定物件,檔案變更時隨之更新。
|
||||
A file dialog is open: choose there first 檔案對話方塊已開啟:請先在其中選擇
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -72,7 +72,7 @@ pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: O
|
|||
|
||||
/// A path from the open dialog, or "cancelled".
|
||||
fn pick_open(app: &mut VectorcraftApp, pick: &FilePick) -> Result<String, String> {
|
||||
app.services.pick_open.as_mut().and_then(|f| f(pick)).ok_or_else(|| "cancelled".into())
|
||||
crate::picks::open(app, pick).ok_or_else(|| "cancelled".into())
|
||||
}
|
||||
|
||||
/// Object › Plug-ins › Install Plug-in…: installs the `.wasm` at `path`, else a picked one (the
|
||||
|
|
@ -164,7 +164,7 @@ fn pick_path(app: &mut VectorcraftApp, pick: &FilePick) -> Result<String, String
|
|||
if is_web(app) {
|
||||
return Ok(pick.name.clone());
|
||||
}
|
||||
let picked = app.services.pick_save.as_mut().and_then(|f| f(pick)).ok_or("cancelled")?;
|
||||
let picked = crate::picks::save(app, pick).ok_or("cancelled")?;
|
||||
Ok(with_extension(&picked, &fileio::extension(&pick.name), |_| true))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ pub mod menus;
|
|||
pub mod native_menu;
|
||||
pub mod palette;
|
||||
pub mod panels;
|
||||
pub mod picks;
|
||||
pub mod place;
|
||||
pub mod prefs_dialog;
|
||||
pub mod print;
|
||||
|
|
@ -95,6 +96,8 @@ mod tests_pathtype;
|
|||
#[cfg(test)]
|
||||
mod tests_pdfoutput;
|
||||
#[cfg(test)]
|
||||
mod tests_picks;
|
||||
#[cfg(test)]
|
||||
mod tests_place;
|
||||
#[cfg(test)]
|
||||
mod tests_plugins;
|
||||
|
|
@ -233,6 +236,10 @@ pub struct Services {
|
|||
pub print: Option<Box<dyn print::PrintService>>,
|
||||
/// The macOS menu bar, when the desktop app installed one: the in-window menus are hidden then.
|
||||
pub native_menu: Option<native_menu::NativeMenu>,
|
||||
/// Show file dialogs off the UI thread (desktop Linux, where a dialog in line holds the window
|
||||
/// and the compositor finds it not answering): what asked runs again with the answer
|
||||
/// ([`picks`]). Without it they are shown in line.
|
||||
pub start_pick: Option<picks::StartPick>,
|
||||
}
|
||||
|
||||
/// Cached canvas raster.
|
||||
|
|
@ -347,6 +354,8 @@ pub struct VectorcraftApp {
|
|||
/// The thread that checks the system clipboard for `system_paste` instead
|
||||
/// ([`Services::clipboard_probe`]).
|
||||
clipboard_probe: Option<clipboard_probe::Probe>,
|
||||
/// File dialogs shown off the UI thread and what runs again with their answers.
|
||||
pub(crate) picks: picks::Picks,
|
||||
/// The look for fonts installed or removed while the app was in the background, running
|
||||
/// ([`Self::refresh_installed_fonts`]): whether they were.
|
||||
font_check: Option<std::sync::mpsc::Receiver<bool>>,
|
||||
|
|
@ -426,6 +435,7 @@ impl VectorcraftApp {
|
|||
system_paste: false,
|
||||
system_paste_at: f64::NEG_INFINITY,
|
||||
clipboard_probe: None,
|
||||
picks: picks::Picks::default(),
|
||||
font_check: None,
|
||||
paste_chord: Default::default(),
|
||||
background: Default::default(),
|
||||
|
|
@ -489,6 +499,16 @@ impl VectorcraftApp {
|
|||
|
||||
/// Run a UI or engine command by id. The single entry point for every frontend path.
|
||||
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
// A file dialog it shows off the UI thread runs it again with the path picked.
|
||||
if self.picks.is_entry_free() {
|
||||
let entry = picks::Entry::Command(id.to_string(), params.clone());
|
||||
return picks::as_entry(self, move || entry, |app| app.run_now(id, params));
|
||||
}
|
||||
self.run_now(id, params)
|
||||
}
|
||||
|
||||
/// [`Self::run`] it, inside what asks for file dialogs.
|
||||
fn run_now(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
self.run_count = self.run_count.wrapping_add(1);
|
||||
if let Some(r) = menus::run_ui_command(self, id, ¶ms) {
|
||||
return r;
|
||||
|
|
@ -790,6 +810,7 @@ impl VectorcraftApp {
|
|||
self.system_paste = wanted && self.system_clipboard_pasteable();
|
||||
}
|
||||
self.poll_font_check(ctx);
|
||||
picks::poll(self, ctx);
|
||||
background::poll(self);
|
||||
if !self.background.jobs.is_empty() {
|
||||
// Keep the status bar's progress moving and pick the result up when it arrives.
|
||||
|
|
|
|||
|
|
@ -185,17 +185,23 @@ fn collect(app: &mut VectorcraftApp, ctx: &egui::Context, ids: Option<Vec<NodeId
|
|||
/// folder picker: the last folder Export for Screens used, else the Desktop); the web downloads
|
||||
/// them, several as one zip.
|
||||
fn export(app: &mut VectorcraftApp, ids: Vec<u64>, files_each: usize) {
|
||||
// The folder picked off the UI thread exports them then.
|
||||
crate::picks::button(app, move |app| export_now(app, ids.clone(), files_each));
|
||||
}
|
||||
|
||||
fn export_now(app: &mut VectorcraftApp, ids: Vec<u64>, files_each: usize) {
|
||||
let mut p = json!({ "assets": ids });
|
||||
if io::is_web(app) {
|
||||
p["zip"] = json!(ids.len() * files_each > 1);
|
||||
} else {
|
||||
let saved = app.session.active().map(|st| st.doc.export_settings.clone()).unwrap_or_default();
|
||||
let folder = match app.services.pick_folder.as_mut() {
|
||||
Some(pick) => match pick() {
|
||||
let folder = if crate::picks::can(app, &crate::picks::PickRequest::Folder) {
|
||||
match crate::picks::folder(app) {
|
||||
Some(f) => f,
|
||||
None => return,
|
||||
},
|
||||
None => match saved
|
||||
}
|
||||
} else {
|
||||
match saved
|
||||
.get("folder")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|f| !f.is_empty())
|
||||
|
|
@ -204,7 +210,7 @@ fn export(app: &mut VectorcraftApp, ids: Vec<u64>, files_each: usize) {
|
|||
{
|
||||
Some(f) => f,
|
||||
None => return app.status("choose a folder to export to".to_string()),
|
||||
},
|
||||
}
|
||||
};
|
||||
p["folder"] = json!(folder);
|
||||
p["openLocation"] = json!(saved.get("openLocation").and_then(Value::as_bool).unwrap_or(true));
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ pub(crate) fn pick_library_file(app: &mut VectorcraftApp, path: Option<String>)
|
|||
}
|
||||
path.or_else(|| {
|
||||
let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::open_filters().collect(), ..Default::default() };
|
||||
app.services.pick_open.as_mut().and_then(|f| f(&pick))
|
||||
crate::picks::open(app, &pick)
|
||||
})
|
||||
.map(Some)
|
||||
.ok_or_else(|| "cancelled".into())
|
||||
|
|
|
|||
|
|
@ -116,15 +116,23 @@ fn report(app: &mut VectorcraftApp, r: Result<Value, String>, done: impl FnOnce(
|
|||
|
||||
/// Relink images `ids` to a picked file.
|
||||
pub(crate) fn relink(app: &mut VectorcraftApp, ids: Vec<u64>) {
|
||||
crate::picks::button(app, move |app| relink_picked(app, ids.clone()));
|
||||
}
|
||||
|
||||
fn relink_picked(app: &mut VectorcraftApp, ids: Vec<u64>) {
|
||||
let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::place_filters().collect(), ..Default::default() };
|
||||
let Some(path) = app.services.pick_open.as_mut().and_then(|f| f(&pick)) else { return };
|
||||
let Some(path) = crate::picks::open(app, &pick) else { return };
|
||||
let r = app.run("links.relink", json!({ "ids": ids, "path": path }));
|
||||
report(app, r, |v| format!("Relinked {} image(s)", v["relinked"].as_array().map_or(0, Vec::len)));
|
||||
}
|
||||
|
||||
/// Relink images `ids` (none: every missing one) to the files of their names in a picked folder.
|
||||
fn relink_to_folder(app: &mut VectorcraftApp, ids: Vec<u64>) {
|
||||
let Some(folder) = app.services.pick_folder.as_mut().and_then(|f| f()) else { return };
|
||||
crate::picks::button(app, move |app| relink_to_picked_folder(app, ids.clone()));
|
||||
}
|
||||
|
||||
fn relink_to_picked_folder(app: &mut VectorcraftApp, ids: Vec<u64>) {
|
||||
let Some(folder) = crate::picks::folder(app) else { return };
|
||||
let ids = Some(ids).filter(|i| !i.is_empty());
|
||||
let r = app.run("links.relink", json!({ "ids": ids, "folder": folder }));
|
||||
report(app, r, |v| {
|
||||
|
|
@ -154,9 +162,12 @@ pub(crate) fn unembed(app: &mut VectorcraftApp, id: u64, name: &str) {
|
|||
}
|
||||
return;
|
||||
}
|
||||
let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&crate::FilePick::named(name))) else { return };
|
||||
let r = app.run("links.unembed", json!({ "id": id, "path": path }));
|
||||
report(app, r, |v| format!("Unembedded to {}", v["path"].as_str().unwrap_or_default()));
|
||||
let name = name.to_string();
|
||||
crate::picks::button(app, move |app| {
|
||||
let Some(path) = crate::picks::save(app, &crate::FilePick::named(&name)) else { return };
|
||||
let r = app.run("links.unembed", json!({ "id": id, "path": path }));
|
||||
report(app, r, |v| format!("Unembedded to {}", v["path"].as_str().unwrap_or_default()));
|
||||
});
|
||||
}
|
||||
|
||||
/// The UI commands `links.editOriginal` (open the linked file in its app) and `links.reveal`
|
||||
|
|
|
|||
|
|
@ -0,0 +1,237 @@
|
|||
//! File dialogs that leave the window running (#592).
|
||||
//!
|
||||
//! The app asks for a path where it needs one (Open, Save As, Export, Place, Relink, presets…)
|
||||
//! and goes on with it at once, so a dialog shown on the UI thread holds the window until it
|
||||
//! closes. On Windows and macOS the dialog runs the window's events meanwhile; on Linux nothing
|
||||
//! does, so a Wayland compositor finds the window not answering and offers to kill it. There the
|
||||
//! host shows dialogs on another thread ([`Services::start_pick`]): asking returns no path for now,
|
||||
//! and when the dialog answers, what asked (the command, the dialog's OK, a panel's button: the
|
||||
//! *entry*) runs again and gets the picked path at once.
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{Receiver, TryRecvError};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::state::Dialog;
|
||||
use crate::{FilePick, VectorcraftApp};
|
||||
|
||||
/// A file dialog to show.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PickRequest {
|
||||
/// An open dialog for one file.
|
||||
Open(FilePick),
|
||||
/// An open dialog for files to place.
|
||||
OpenMany,
|
||||
/// A save dialog.
|
||||
Save(FilePick),
|
||||
/// A folder picker.
|
||||
Folder,
|
||||
}
|
||||
|
||||
/// The paths a dialog shown off the UI thread picked (none: cancelled).
|
||||
pub type PickAnswer = Vec<String>;
|
||||
|
||||
/// Shows a dialog off the UI thread; its answer arrives on the receiver. `None` when it can't, and
|
||||
/// the dialog is then shown in line.
|
||||
pub type StartPick = Box<dyn FnMut(PickRequest) -> Option<Receiver<PickAnswer>>>;
|
||||
|
||||
/// What runs again with the picked path.
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum Entry {
|
||||
/// A command (`app.run`).
|
||||
Command(String, Value),
|
||||
/// A dialog's OK (`dialogs::confirm`), on the dialog as it was.
|
||||
Confirm(Box<Dialog>),
|
||||
/// A panel's or a dialog's button.
|
||||
Call(Rc<dyn Fn(&mut VectorcraftApp)>),
|
||||
}
|
||||
|
||||
/// A dialog shown off the UI thread, waiting for its answer.
|
||||
struct Waiting {
|
||||
request: PickRequest,
|
||||
answer: Receiver<PickAnswer>,
|
||||
entry: Entry,
|
||||
}
|
||||
|
||||
/// The app's file dialogs ([`VectorcraftApp`]'s `picks`).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Picks {
|
||||
/// What is running now and asks for a path, the outermost one.
|
||||
entry: Option<Entry>,
|
||||
waiting: Option<Waiting>,
|
||||
/// A picked answer for the entry running again: the dialog it asks for gets it.
|
||||
answer: Option<(PickRequest, PickAnswer)>,
|
||||
}
|
||||
|
||||
impl Picks {
|
||||
/// Nothing asking for file dialogs is running.
|
||||
pub(crate) fn is_entry_free(&self) -> bool {
|
||||
self.entry.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` as `entry`: a dialog it asks for off the UI thread runs `entry` again when it
|
||||
/// answers. Inside another entry, that one runs again instead.
|
||||
pub(crate) fn as_entry<R>(app: &mut VectorcraftApp, entry: impl FnOnce() -> Entry, f: impl FnOnce(&mut VectorcraftApp) -> R) -> R {
|
||||
if app.picks.entry.is_some() {
|
||||
return f(app);
|
||||
}
|
||||
app.picks.entry = Some(entry());
|
||||
let r = f(app);
|
||||
app.picks.entry = None;
|
||||
r
|
||||
}
|
||||
|
||||
/// [`as_entry`] for a panel's or a dialog's button that `f` handles.
|
||||
pub(crate) fn button(app: &mut VectorcraftApp, f: impl Fn(&mut VectorcraftApp) + 'static) {
|
||||
let f = Rc::new(f);
|
||||
let again = f.clone();
|
||||
as_entry(app, move || Entry::Call(again), |app| f(app));
|
||||
}
|
||||
|
||||
/// A dialog body's action `f` on `d` (the open dialog's copy) that asks for a file dialog: shown
|
||||
/// off the UI thread, its answer runs `f` again on the open dialog, when it is still of that kind.
|
||||
pub(crate) fn in_dialog(
|
||||
app: &mut VectorcraftApp,
|
||||
d: &mut Dialog,
|
||||
f: impl Fn(&mut VectorcraftApp, &mut Dialog) -> Result<(), String> + 'static,
|
||||
) -> Result<(), String> {
|
||||
let f = Rc::new(f);
|
||||
let (again, kind) = (f.clone(), d.kind.clone());
|
||||
let entry = move || {
|
||||
Entry::Call(Rc::new(move |app: &mut VectorcraftApp| {
|
||||
let Some(mut d) = app.ui.dialog.take() else { return };
|
||||
let r = if d.kind == kind { again(app, &mut d) } else { Ok(()) };
|
||||
app.ui.dialog = Some(d);
|
||||
if let Err(e) = r {
|
||||
app.status(e);
|
||||
}
|
||||
}))
|
||||
};
|
||||
as_entry(app, entry, |app| f(app, d))
|
||||
}
|
||||
|
||||
/// A dialog body's folder button: the folder picked into field `key` of `d` ([`in_dialog`]).
|
||||
pub(crate) fn folder_field(app: &mut VectorcraftApp, d: &mut Dialog, key: &'static str) {
|
||||
let r = in_dialog(app, d, move |app, d| {
|
||||
if let Some(f) = folder(app) {
|
||||
d.fields.insert(key.into(), serde_json::json!(f));
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
// Picking a folder doesn't fail.
|
||||
let _ = r;
|
||||
}
|
||||
|
||||
/// A path from an open dialog for `pick` (none: cancelled, or shown off the UI thread for now).
|
||||
pub(crate) fn open(app: &mut VectorcraftApp, pick: &FilePick) -> Option<String> {
|
||||
ask(app, PickRequest::Open(pick.clone()))?.into_iter().next()
|
||||
}
|
||||
|
||||
/// A path from a save dialog for `pick`.
|
||||
pub(crate) fn save(app: &mut VectorcraftApp, pick: &FilePick) -> Option<String> {
|
||||
ask(app, PickRequest::Save(pick.clone()))?.into_iter().next()
|
||||
}
|
||||
|
||||
/// A folder from the folder picker.
|
||||
pub(crate) fn folder(app: &mut VectorcraftApp) -> Option<String> {
|
||||
ask(app, PickRequest::Folder)?.into_iter().next()
|
||||
}
|
||||
|
||||
/// Paths from the open dialog for files to place (none: cancelled).
|
||||
pub(crate) fn open_many(app: &mut VectorcraftApp) -> Vec<String> {
|
||||
ask(app, PickRequest::OpenMany).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether the host shows `request`'s kind of dialog at all.
|
||||
pub(crate) fn can(app: &VectorcraftApp, request: &PickRequest) -> bool {
|
||||
let s = &app.services;
|
||||
match request {
|
||||
PickRequest::Open(_) => s.pick_open.is_some(),
|
||||
PickRequest::OpenMany => s.pick_open_multi.is_some() || s.pick_open.is_some(),
|
||||
PickRequest::Save(_) => s.pick_save.is_some(),
|
||||
PickRequest::Folder => s.pick_folder.is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The paths `request`'s dialog picked: the answer kept for it, else shown off the UI thread (no
|
||||
/// paths for now), else shown in line.
|
||||
fn ask(app: &mut VectorcraftApp, request: PickRequest) -> Option<PickAnswer> {
|
||||
// One for another dialog is dropped: the entry took another way this time.
|
||||
if let Some((asked, answer)) = app.picks.answer.take()
|
||||
&& asked == request
|
||||
{
|
||||
return Some(answer);
|
||||
}
|
||||
if !can(app, &request) {
|
||||
return None;
|
||||
}
|
||||
if let (Some(entry), Some(start)) = (app.picks.entry.clone(), app.services.start_pick.as_mut()) {
|
||||
if app.picks.waiting.is_some() {
|
||||
app.status(tl!("A file dialog is open: choose there first"));
|
||||
return None;
|
||||
}
|
||||
if let Some(answer) = start(request.clone()) {
|
||||
app.picks.waiting = Some(Waiting { request, answer, entry });
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let s = &mut app.services;
|
||||
let one = |p: Option<String>| Some(p.into_iter().collect());
|
||||
match &request {
|
||||
PickRequest::Open(pick) => one(s.pick_open.as_mut().and_then(|f| f(pick))),
|
||||
PickRequest::Save(pick) => one(s.pick_save.as_mut().and_then(|f| f(pick))),
|
||||
PickRequest::Folder => one(s.pick_folder.as_mut().and_then(|f| f())),
|
||||
PickRequest::OpenMany => match (s.pick_open_multi.as_mut(), s.pick_open.as_mut()) {
|
||||
(Some(many), _) => Some(many()),
|
||||
(None, Some(f)) => one(f(&FilePick::default())),
|
||||
(None, None) => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Each frame: when the dialog shown off the UI thread answers with paths, run its entry again
|
||||
/// with them. While it is open, look again shortly.
|
||||
pub(crate) fn poll(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let Some(w) = &app.picks.waiting else { return };
|
||||
let answer = match w.answer.try_recv() {
|
||||
Ok(answer) => answer,
|
||||
Err(TryRecvError::Empty) => {
|
||||
// What asked gave up for now: no "cancelled" while the dialog is open.
|
||||
if app.ui.status == "cancelled" {
|
||||
app.ui.status.clear();
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
return;
|
||||
}
|
||||
// The dialog's thread is gone: as cancelled.
|
||||
Err(TryRecvError::Disconnected) => vec![],
|
||||
};
|
||||
let Some(Waiting { request, entry, .. }) = app.picks.waiting.take() else { return };
|
||||
if answer.is_empty() {
|
||||
return;
|
||||
}
|
||||
app.picks.answer = Some((request, answer));
|
||||
rerun(app, entry);
|
||||
// An entry that took another way this time leaves the answer unused.
|
||||
app.picks.answer = None;
|
||||
}
|
||||
|
||||
/// Run `entry` again.
|
||||
fn rerun(app: &mut VectorcraftApp, entry: Entry) {
|
||||
let r = match entry {
|
||||
Entry::Command(id, params) => app.run(&id, params).map(|_| ()),
|
||||
Entry::Confirm(d) => {
|
||||
app.ui.dialog = Some(*d);
|
||||
crate::dialogs::confirm(app).map(|_| ())
|
||||
}
|
||||
Entry::Call(f) => {
|
||||
f(app);
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
if let Err(e) = r {
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
|
|
@ -135,10 +135,10 @@ fn pick(app: &mut VectorcraftApp) -> Result<(), String> {
|
|||
f();
|
||||
return Ok(());
|
||||
}
|
||||
let paths = match (app.services.pick_open_multi.as_mut(), app.services.pick_open.as_mut()) {
|
||||
(Some(f), _) => f(),
|
||||
(None, Some(f)) => f(&crate::FilePick { filters: fileio::place_filters().collect(), ..Default::default() }).into_iter().collect(),
|
||||
(None, None) => vec![],
|
||||
let paths = if app.services.pick_open_multi.is_some() {
|
||||
crate::picks::open_many(app)
|
||||
} else {
|
||||
crate::picks::open(app, &crate::FilePick { filters: fileio::place_filters().collect(), ..Default::default() }).into_iter().collect()
|
||||
};
|
||||
if paths.is_empty() {
|
||||
return Err("cancelled".into());
|
||||
|
|
|
|||
|
|
@ -458,7 +458,7 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Resu
|
|||
"shortcuts.export" => {
|
||||
let v = export_json(&app.ui.shortcut_set, &app.ui.shortcut_overrides);
|
||||
let bytes = serde_json::to_vec_pretty(&v).unwrap_or_default();
|
||||
let path = s("path").or_else(|| app.services.pick_save.as_mut().and_then(|f| f(&crate::FilePick::named("VectorCraft Shortcuts.json"))));
|
||||
let path = s("path").or_else(|| crate::picks::save(app, &crate::FilePick::named("VectorCraft Shortcuts.json")));
|
||||
match path {
|
||||
Some(path) => match app.services.write.as_mut() {
|
||||
Some(w) => w(&path, &bytes).map(|_| json!({"path": path})),
|
||||
|
|
@ -477,7 +477,7 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Resu
|
|||
let data = if let Some(v) = p.get("data") {
|
||||
Ok(v.clone())
|
||||
} else {
|
||||
let path = s("path").or_else(|| app.services.pick_open.as_mut().and_then(|f| f(&crate::FilePick::default())));
|
||||
let path = s("path").or_else(|| crate::picks::open(app, &crate::FilePick::default()));
|
||||
match (path, app.services.read.as_ref()) {
|
||||
(Some(path), Some(r)) => r(&path).and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string())),
|
||||
(None, _) => Err("cancelled".into()),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
//! File dialogs shown off the UI thread (Linux, #592): what asked runs again with the answer.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{Sender, channel};
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use crate::picks::{self, PickRequest};
|
||||
use crate::state::Dialog;
|
||||
use crate::{FilePick, Services, VectorcraftApp};
|
||||
|
||||
const SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30"><rect width="10" height="10"/></svg>"#;
|
||||
|
||||
/// A dialog asked for, and where its answer goes.
|
||||
type Asked = (PickRequest, Sender<Vec<String>>);
|
||||
|
||||
/// The dialogs showing.
|
||||
#[derive(Clone, Default)]
|
||||
struct Shown(Rc<RefCell<Vec<Asked>>>);
|
||||
|
||||
impl Shown {
|
||||
fn requests(&self) -> Vec<PickRequest> {
|
||||
self.0.borrow().iter().map(|(r, _)| r.clone()).collect()
|
||||
}
|
||||
/// The last dialog shown answers `paths`.
|
||||
fn answer(&self, paths: &[&str]) {
|
||||
let (_, tx) = self.0.borrow_mut().pop().expect("a dialog is showing");
|
||||
tx.send(paths.iter().map(|p| p.to_string()).collect()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A desktop app whose dialogs show off the UI thread (none in line), reading [`SVG`] from any path
|
||||
/// and writing files to `written`.
|
||||
fn app() -> (VectorcraftApp, Shown, Rc<RefCell<Vec<String>>>) {
|
||||
let (shown, written) = (Shown::default(), Rc::new(RefCell::new(vec![])));
|
||||
let (s, w) = (shown.clone(), written.clone());
|
||||
let in_line = |_: &FilePick| -> Option<String> { panic!("no dialog on the UI thread") };
|
||||
let services = Services {
|
||||
pick_open: Some(Box::new(in_line)),
|
||||
pick_save: Some(Box::new(in_line)),
|
||||
pick_folder: Some(Box::new(|| panic!("no dialog on the UI thread"))),
|
||||
read: Some(Box::new(|_: &str| Ok(SVG.to_vec()))),
|
||||
write: Some(Box::new(move |path: &str, _: &[u8]| {
|
||||
w.borrow_mut().push(path.to_string());
|
||||
Ok(())
|
||||
})),
|
||||
start_pick: Some(Box::new(move |request: PickRequest| {
|
||||
let (tx, rx) = channel();
|
||||
s.0.borrow_mut().push((request, tx));
|
||||
Some(rx)
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
(VectorcraftApp::new(Session::new(), services), shown, written)
|
||||
}
|
||||
|
||||
fn poll(app: &mut VectorcraftApp) {
|
||||
picks::poll(app, &egui::Context::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_open_opens_what_its_dialog_picks_when_it_answers() {
|
||||
let (mut app, shown, _) = app();
|
||||
// The dialog shows; File › Open gives up for now, without a "cancelled".
|
||||
assert!(app.run("file.open", json!({})).is_err());
|
||||
assert!(matches!(shown.requests().as_slice(), [PickRequest::Open(_)]));
|
||||
poll(&mut app);
|
||||
assert!(app.session.documents().is_empty() && app.ui.status.is_empty(), "{}", app.ui.status);
|
||||
// Another dialog waits for this one.
|
||||
assert!(app.run("file.open", json!({})).is_err());
|
||||
assert_eq!(shown.requests().len(), 1);
|
||||
assert!(app.ui.status.contains("file dialog is open"), "{}", app.ui.status);
|
||||
shown.answer(&["/art/a.svg"]);
|
||||
poll(&mut app);
|
||||
assert_eq!(app.session.documents().len(), 1);
|
||||
assert_eq!(app.session.active().and_then(|d| d.path.clone()).as_deref(), Some("/art/a.svg"));
|
||||
// Cancelled: nothing happens, and dialogs show again.
|
||||
assert!(app.run("file.open", json!({})).is_err());
|
||||
shown.answer(&[]);
|
||||
poll(&mut app);
|
||||
assert_eq!(app.session.documents().len(), 1);
|
||||
assert!(app.run("file.open", json!({})).is_err());
|
||||
assert_eq!(shown.requests().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_goes_on_with_the_path_picked() {
|
||||
let (mut app, shown, written) = app();
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
|
||||
assert!(app.run("file.saveAs", json!({})).is_err());
|
||||
assert!(matches!(shown.requests().as_slice(), [PickRequest::Save(_)]));
|
||||
shown.answer(&["/art/poster.svg"]);
|
||||
poll(&mut app);
|
||||
// SVG Options asks next, and its OK writes the file there.
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("svgOptions"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(*written.borrow(), ["/art/poster.svg"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dialogs_folder_button_fills_its_field_when_the_picker_answers() {
|
||||
let (mut app, shown, _) = app();
|
||||
let mut d = Dialog::new("exportForScreens", json!({"folder": ""}));
|
||||
app.ui.dialog = Some(d.clone());
|
||||
picks::folder_field(&mut app, &mut d, "folder");
|
||||
assert_eq!(shown.requests(), [PickRequest::Folder]);
|
||||
shown.answer(&["/out"]);
|
||||
poll(&mut app);
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("folder")).as_deref(), Some("/out"));
|
||||
}
|
||||
Loading…
Reference in New Issue