mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
UI.9: retain shared docking across upstream workspace changes
This commit is contained in:
commit
83ae6218ef
183 files changed
+9574
-1030
No files matched your search
@@ -117,6 +117,9 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
|
||||
(app.project, comps, layers, properties…); prints writeLn
|
||||
output and the result; errors exit 1 with file:line:col
|
||||
mcp [--autosave | --bridge PORT] MCP server (JSON-RPC over stdio); --autosave preserves unsaved headless work
|
||||
[--automation-read-root DIR] [--automation-write-root DIR]
|
||||
confine the agent's file access: reads under a read or write root,
|
||||
writes under a write root (each repeatable); scripts are refused
|
||||
options: --project F.ecproj | --demo | --empty --save | --save-as F --bridge PORT --json --gpu
|
||||
<comp>: id or name, '-' = active comp; <layer>: id, '#n' or name; <value>: JSON or bare string";
|
||||
|
||||
@@ -163,6 +166,9 @@ const VALUED: &[&str] = &[
|
||||
"--webm-codec",
|
||||
"--audio-bitrate",
|
||||
"--opus-app",
|
||||
// mcp
|
||||
"--automation-read-root",
|
||||
"--automation-write-root",
|
||||
];
|
||||
|
||||
/// Options without a value. Any other `--option` is a usage error.
|
||||
@@ -232,6 +238,10 @@ impl Args {
|
||||
fn flag(&self, k: &str) -> bool {
|
||||
self.opts.iter().any(|(o, _)| o == k)
|
||||
}
|
||||
/// Every value of a repeatable option, in order.
|
||||
fn opts(&self, k: &str) -> Vec<&str> {
|
||||
self.opts.iter().filter(|(o, _)| o == k).filter_map(|(_, v)| v.as_deref()).collect()
|
||||
}
|
||||
fn opt(&self, k: &str) -> Option<&str> {
|
||||
self.opts.iter().rev().find(|(o, _)| o == k).and_then(|(_, v)| v.as_deref())
|
||||
}
|
||||
@@ -595,7 +605,12 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
if args.flag("--autosave") && args.opt("--bridge").is_some() {
|
||||
return usage_err("--autosave is for headless MCP; the bridged app owns its auto-saves");
|
||||
}
|
||||
let b = backend(args, false)?;
|
||||
let mut b = backend(args, false)?;
|
||||
let (read, write) = (args.opts("--automation-read-root"), args.opts("--automation-write-root"));
|
||||
if !read.is_empty() || !write.is_empty() {
|
||||
let roots = |r: Vec<&str>| r.into_iter().map(std::path::PathBuf::from).collect::<Vec<_>>();
|
||||
b.confine(effectcraft_automation::confine::Confinement::new(&roots(read), &roots(write))?);
|
||||
}
|
||||
let server = McpServer::new(b);
|
||||
let mut server = if args.flag("--autosave") {
|
||||
let root =
|
||||
|
||||
@@ -18,6 +18,7 @@ egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }
|
||||
serde_json = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
pollster = { workspace = true }
|
||||
cpal = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
|
||||
@@ -10,11 +10,32 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Duration;
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::{FromSample, SizedSample};
|
||||
use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed, AudioOutput};
|
||||
|
||||
/// Device callback period: about 10 ms, clamped so Pulse/PipeWire/ALSA does not open a
|
||||
/// multi-hundred-millisecond default buffer that plays as silence at the start of preview (#554).
|
||||
fn preferred_buffer_frames(rate: u32) -> u32 {
|
||||
(rate / 100).clamp(256, 2048)
|
||||
}
|
||||
|
||||
/// Wait until the feeder has mixed a block, or until preview is stopped. Starting the stream
|
||||
/// before that fills the device with silence, so the first real samples are heard late (#554).
|
||||
fn wait_to_play(feed: &AudioFeed, stop: &mpsc::Receiver<()>) -> bool {
|
||||
loop {
|
||||
if feed.queued_frames() > 0 {
|
||||
return true;
|
||||
}
|
||||
match stop.recv_timeout(Duration::from_millis(2)) {
|
||||
Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return false,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CpalOut {
|
||||
out: AudioOutput,
|
||||
rate: u32,
|
||||
@@ -131,21 +152,44 @@ impl AudioDevice for CpalOut {
|
||||
};
|
||||
let mut cfg: cpal::StreamConfig = sup.config();
|
||||
cfg.sample_rate = cpal::SampleRate(rate);
|
||||
let stream = match sup.sample_format() {
|
||||
cpal::SampleFormat::F32 => run::<f32>(&dev, &cfg, feed, latency, map),
|
||||
cpal::SampleFormat::I16 => run::<i16>(&dev, &cfg, feed, latency, map),
|
||||
cpal::SampleFormat::U16 => run::<u16>(&dev, &cfg, feed, latency, map),
|
||||
cpal::SampleFormat::I32 => run::<i32>(&dev, &cfg, feed, latency, map),
|
||||
// A short period only where the default one is long (ALSA/PulseAudio on Linux, #554),
|
||||
// and only within what the device reports it takes.
|
||||
if cfg!(target_os = "linux") {
|
||||
let want = preferred_buffer_frames(rate);
|
||||
cfg.buffer_size = match sup.buffer_size() {
|
||||
cpal::SupportedBufferSize::Range { min, max } if *min <= *max => cpal::BufferSize::Fixed(want.clamp(*min, *max)),
|
||||
_ => cpal::BufferSize::Default,
|
||||
};
|
||||
}
|
||||
let build = |cfg: &cpal::StreamConfig| match sup.sample_format() {
|
||||
cpal::SampleFormat::F32 => run::<f32>(&dev, cfg, feed.clone(), latency.clone(), map),
|
||||
cpal::SampleFormat::I16 => run::<i16>(&dev, cfg, feed.clone(), latency.clone(), map),
|
||||
cpal::SampleFormat::U16 => run::<u16>(&dev, cfg, feed.clone(), latency.clone(), map),
|
||||
cpal::SampleFormat::I32 => run::<i32>(&dev, cfg, feed.clone(), latency.clone(), map),
|
||||
f => Err(format!("unsupported sample format {f:?}")),
|
||||
};
|
||||
let stream = match stream.and_then(|s| s.play().map(|_| s).map_err(|e| e.to_string())) {
|
||||
let stream = match build(&cfg) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = ready_tx.send(Err(e));
|
||||
return;
|
||||
Err(_) => {
|
||||
// Some hosts reject a fixed period; a large default still plays, just later.
|
||||
cfg.buffer_size = cpal::BufferSize::Default;
|
||||
match build(&cfg) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = ready_tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let _ = ready_tx.send(Ok(()));
|
||||
if !wait_to_play(&feed, &stop_rx) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = stream.play() {
|
||||
eprintln!("effectcraft: audio output: {e}");
|
||||
return;
|
||||
}
|
||||
let _ = stop_rx.recv();
|
||||
drop(stream);
|
||||
})
|
||||
@@ -174,3 +218,37 @@ impl Drop for CpalOut {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn preferred_buffer_is_about_ten_milliseconds() {
|
||||
assert_eq!(preferred_buffer_frames(48_000), 480);
|
||||
assert_eq!(preferred_buffer_frames(8_000), 256);
|
||||
assert_eq!(preferred_buffer_frames(192_000), 1920);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_to_play_returns_once_the_feeder_has_samples() {
|
||||
let feed = Arc::new(AudioFeed::default());
|
||||
let (_tx, rx) = mpsc::channel();
|
||||
let f = feed.clone();
|
||||
let h = std::thread::spawn(move || wait_to_play(&f, &rx));
|
||||
assert_eq!(feed.queued_frames(), 0);
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
feed.push(&[0.1, 0.2]);
|
||||
assert!(h.join().unwrap());
|
||||
assert_eq!(feed.queued_frames(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_to_play_stops_without_samples() {
|
||||
let feed = AudioFeed::default();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let h = std::thread::spawn(move || wait_to_play(&feed, &rx));
|
||||
tx.send(()).unwrap();
|
||||
assert!(!h.join().unwrap());
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,52 @@
|
||||
//! Settings ▸ Startup & Repair ▸ Window Graphics, and the launch guard behind it.
|
||||
//!
|
||||
//! Some graphics drivers crash EffectCraft as its window's device is created (an access
|
||||
//! violation in an older Intel UHD driver with DirectX 12 / Vulkan, #243). A crash in a driver
|
||||
//! can't be caught, so each launch leaves a marker in the settings folder until its window has
|
||||
//! drawn a frame. A launch that finds the previous one's marker switches Window Graphics to
|
||||
//! OpenGL, which those drivers run, and says so once the window is up. `WGPU_BACKEND` (wgpu's
|
||||
//! own override) still wins over both. The marker is locked while its launch runs, so a second
|
||||
//! copy opened meanwhile isn't taken for a crash; and a window OpenGL can't open either puts
|
||||
//! Window Graphics back to Automatic ([`opengl_failed`]) rather than failing every launch.
|
||||
//! violation in an older Intel UHD driver with DirectX 12 / Vulkan, #243), or a moment after
|
||||
//! its first frame, as the window is shown and its surface re-created (AMD's Vulkan driver for
|
||||
//! a Radeon RX 6800M, Adrenalin 26.9.2: an access violation in amdvlk64.dll on every launch).
|
||||
//! A crash in a driver can't be caught, so each launch leaves a marker in the settings folder
|
||||
//! until its window has kept drawing for [`SETTLE`] (or the app quits normally before then). A
|
||||
//! launch that finds the previous one's marker switches Window Graphics to OpenGL, which those
|
||||
//! drivers run, and says so once the window is up. `WGPU_BACKEND` (wgpu's own override) still
|
||||
//! wins over both. The marker is locked while its launch runs, so a second copy opened meanwhile
|
||||
//! isn't taken for a crash; and a window OpenGL can't open either puts Window Graphics back to
|
||||
//! Automatic ([`opengl_failed`]) rather than failing every launch.
|
||||
|
||||
use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use effectcraft_engine::prefs::{PREFS_FILE, Prefs};
|
||||
use eframe::wgpu::Backends;
|
||||
|
||||
/// Left in the settings folder from a launch until its window draws.
|
||||
/// Left in the settings folder from a launch until its window has settled.
|
||||
const MARKER: &str = "launch-pending";
|
||||
|
||||
/// How long the window keeps drawing after its first frame before the launch counts as made.
|
||||
/// A driver that fails as the window is shown and its surface re-created does so within the
|
||||
/// first second; a user quitting sooner than this is told the next launch uses OpenGL and can
|
||||
/// switch back in Settings.
|
||||
pub const SETTLE: Duration = Duration::from_secs(3);
|
||||
|
||||
/// This launch's window graphics.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Launch {
|
||||
/// The graphics APIs the window may use (`None`: eframe's default).
|
||||
pub backends: Option<Backends>,
|
||||
/// Switched to OpenGL because the previous launch never drew: the message to show.
|
||||
/// Switched to OpenGL because the previous launch stopped as its window opened: the message
|
||||
/// to show.
|
||||
pub notice: Option<String>,
|
||||
marker: Option<PathBuf>,
|
||||
/// Holds the marker's lock until the window draws (or the process ends).
|
||||
/// Holds the marker's lock until the launch settles (or the process ends).
|
||||
lock: Option<File>,
|
||||
/// When the window drew its first frame, until the launch settles.
|
||||
first_frame: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Launch {
|
||||
/// Read Window Graphics in the settings folder `dir`, switch it to OpenGL when the previous
|
||||
/// launch never drew (where OpenGL exists: not macOS), and leave this launch's marker.
|
||||
/// `env_override`: `WGPU_BACKEND` is set.
|
||||
/// launch stopped as its window opened (where OpenGL exists: not macOS), and leave this
|
||||
/// launch's marker. `env_override`: `WGPU_BACKEND` is set.
|
||||
pub fn begin(dir: Option<&Path>, env_override: bool) -> Launch {
|
||||
let Some(dir) = dir.filter(|_| !env_override) else { return Launch::default() };
|
||||
let prefs_path = dir.join(PREFS_FILE);
|
||||
@@ -45,11 +58,11 @@ impl Launch {
|
||||
if marker.exists() && !held && prefs.startup.window_graphics == "auto" && cfg!(not(target_os = "macos")) {
|
||||
prefs.startup.window_graphics = "gl".into();
|
||||
match std::fs::create_dir_all(dir).and_then(|()| std::fs::write(&prefs_path, prefs.to_json())) {
|
||||
Ok(()) => log::warn!("the last launch never drew its window: switching Window Graphics to OpenGL"),
|
||||
Err(e) => log::warn!("the last launch never drew its window; saving Window Graphics failed: {e}"),
|
||||
Ok(()) => log::warn!("the last launch stopped as its window opened: switching Window Graphics to OpenGL"),
|
||||
Err(e) => log::warn!("the last launch stopped as its window opened; saving Window Graphics failed: {e}"),
|
||||
}
|
||||
notice = Some(
|
||||
"EffectCraft's last launch stopped before its window opened, so it now draws with OpenGL \
|
||||
"EffectCraft's last launch stopped as its window opened, so it now draws with OpenGL \
|
||||
(Settings ▸ Startup & Repair ▸ Window Graphics). Updating the graphics driver may let it use \
|
||||
Automatic again."
|
||||
.to_string(),
|
||||
@@ -64,11 +77,33 @@ impl Launch {
|
||||
(None, None)
|
||||
}
|
||||
};
|
||||
Launch { backends, notice, marker, lock }
|
||||
Launch { backends, notice, marker, lock, first_frame: None }
|
||||
}
|
||||
|
||||
/// The window drew its first frame: the launch made it.
|
||||
pub fn drawn(&mut self) {
|
||||
/// The window drew a frame. Until the launch has settled, returns how long that still takes
|
||||
/// (ask for a repaint then, so that an idle window settles too).
|
||||
pub fn frame_drawn(&mut self) -> Option<Duration> {
|
||||
self.frame_drawn_at(Instant::now())
|
||||
}
|
||||
|
||||
fn frame_drawn_at(&mut self, now: Instant) -> Option<Duration> {
|
||||
self.marker.as_ref()?;
|
||||
let first = *self.first_frame.get_or_insert(now);
|
||||
let left = SETTLE.saturating_sub(now.saturating_duration_since(first));
|
||||
if left.is_zero() {
|
||||
self.settled();
|
||||
return None;
|
||||
}
|
||||
Some(left)
|
||||
}
|
||||
|
||||
/// The app quits normally: however soon after opening, that's no crash.
|
||||
pub fn exited(&mut self) {
|
||||
self.settled();
|
||||
}
|
||||
|
||||
/// The launch made it: release and remove its marker.
|
||||
fn settled(&mut self) {
|
||||
self.lock = None;
|
||||
if let Some(m) = self.marker.take()
|
||||
&& let Err(e) = std::fs::remove_file(&m)
|
||||
@@ -103,12 +138,18 @@ mod tests {
|
||||
fn a_launch_that_never_drew_switches_the_next_to_opengl() {
|
||||
let dir = std::env::temp_dir().join(format!("ec-launch-guard-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
// A first launch that draws: nothing changes.
|
||||
// A first launch that draws: nothing changes. Its first frames don't settle it (a driver
|
||||
// may still crash as the window is shown); frames for `SETTLE` do.
|
||||
let mut l = Launch::begin(Some(&dir), false);
|
||||
assert_eq!((l.backends, l.notice.is_some()), (None, false));
|
||||
assert!(dir.join(MARKER).exists());
|
||||
l.drawn();
|
||||
let t0 = Instant::now();
|
||||
assert_eq!(l.frame_drawn_at(t0), Some(SETTLE));
|
||||
assert!(l.frame_drawn_at(t0 + SETTLE / 2).is_some_and(|left| left <= SETTLE / 2));
|
||||
assert!(dir.join(MARKER).exists());
|
||||
assert_eq!(l.frame_drawn_at(t0 + SETTLE), None);
|
||||
assert!(!dir.join(MARKER).exists());
|
||||
assert_eq!(l.frame_drawn_at(t0 + SETTLE), None, "settled stays settled");
|
||||
// A launch that crashes before drawing (it never calls `drawn`)…
|
||||
drop(Launch::begin(Some(&dir), false));
|
||||
// …makes the next one use OpenGL and say so, once.
|
||||
@@ -119,7 +160,7 @@ mod tests {
|
||||
assert_eq!(l.backends, Some(Backends::GL));
|
||||
assert!(l.notice.is_some());
|
||||
assert_eq!(window_graphics(&dir), "gl", "saved in Settings");
|
||||
l.drawn();
|
||||
l.exited();
|
||||
let l = Launch::begin(Some(&dir), false);
|
||||
assert_eq!((l.backends, l.notice.is_some()), (Some(Backends::GL), false), "it stays, without the notice");
|
||||
}
|
||||
@@ -130,6 +171,34 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A launch that stops moments after its first frame (AMD's Vulkan driver crashing as the
|
||||
/// window is shown and its surface re-created) switches the next one to OpenGL too; a
|
||||
/// normal quit in that time doesn't.
|
||||
#[test]
|
||||
fn a_launch_that_stops_after_its_first_frame_switches_the_next_to_opengl() {
|
||||
let dir = std::env::temp_dir().join(format!("ec-launch-guard-3-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut l = Launch::begin(Some(&dir), false);
|
||||
assert!(l.frame_drawn().is_some());
|
||||
drop(l);
|
||||
let l = Launch::begin(Some(&dir), false);
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(l.backends, None, "no OpenGL on macOS");
|
||||
} else {
|
||||
assert_eq!(l.backends, Some(Backends::GL));
|
||||
assert!(l.notice.is_some());
|
||||
}
|
||||
drop(l);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
// Quitting right after the window opened leaves no marker.
|
||||
let mut l = Launch::begin(Some(&dir), false);
|
||||
assert!(l.frame_drawn().is_some());
|
||||
l.exited();
|
||||
assert!(!dir.join(MARKER).exists());
|
||||
assert_eq!(Launch::begin(Some(&dir), false).backends, None);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A second copy opened while the first is still opening isn't a crash; OpenGL that can't
|
||||
/// open a window either goes back to Automatic instead of failing every launch.
|
||||
#[test]
|
||||
|
||||
@@ -21,6 +21,9 @@ mod control_server;
|
||||
mod launch_guard;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
// Built everywhere so it stays checked; only the macOS app uses it.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
mod sheet_dialogs;
|
||||
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use serde_json::json;
|
||||
@@ -97,8 +100,8 @@ fn main() -> eframe::Result {
|
||||
// Wayland shows the window's icon from its desktop entry: an AppImage brings its own.
|
||||
#[cfg(target_os = "linux")]
|
||||
appimage::integrate_from_env(ICON_PNG);
|
||||
// Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch's
|
||||
// window never drew (a crashing graphics driver, #243).
|
||||
// Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch
|
||||
// stopped as its window opened (a crashing graphics driver, #243).
|
||||
let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), std::env::var_os("WGPU_BACKEND").is_some());
|
||||
let on_gl = launch.backends == Some(eframe::wgpu::Backends::GL);
|
||||
let notice = launch.notice.take();
|
||||
@@ -207,6 +210,22 @@ fn main() -> eframe::Result {
|
||||
app.hooks.pick_save = Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj"]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
// The window closes after a lost graphics device (#591); say why, unless an agent drives
|
||||
// the app through the control channel.
|
||||
if control_port.is_none() {
|
||||
app.hooks.device_lost = Some(Box::new(|saved: Option<Result<String, String>>| {
|
||||
let saved = match saved {
|
||||
Some(Ok(path)) => format!("\n\nYour unsaved changes were saved to:\n{path}\n\nReopen EffectCraft to recover them."),
|
||||
Some(Err(e)) => format!("\n\nYour unsaved changes could not be saved ({e})."),
|
||||
None => String::new(),
|
||||
};
|
||||
rfd::MessageDialog::new()
|
||||
.set_level(rfd::MessageLevel::Error)
|
||||
.set_title("EffectCraft: graphics device lost")
|
||||
.set_description(format!("The graphics driver stopped responding, so EffectCraft has to close.{saved}"))
|
||||
.show();
|
||||
}));
|
||||
}
|
||||
app.hooks.pick_open_project = Some(Box::new(|| {
|
||||
rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
@@ -227,6 +246,9 @@ fn main() -> eframe::Result {
|
||||
}));
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// The commands' file dialogs open as sheets instead of nested modal loops (#511).
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone())));
|
||||
app.hooks.app_action = Some(Box::new(native_menu::app_action));
|
||||
app.hooks.clipboard_text = Some(Box::new(native_menu::clipboard_text));
|
||||
}
|
||||
@@ -327,12 +349,17 @@ impl eframe::App for Desktop {
|
||||
self.app.ui(ui, frame);
|
||||
if !self.drawn {
|
||||
self.drawn = true;
|
||||
self.launch.drawn();
|
||||
log::info!("first frame drawn");
|
||||
}
|
||||
// The launch guard's marker goes once the window has kept drawing for a few seconds: a
|
||||
// driver may still crash as the window is shown and its surface re-created.
|
||||
if let Some(left) = self.launch.frame_drawn() {
|
||||
ui.ctx().request_repaint_after(left);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_exit(&mut self) {
|
||||
self.launch.exited();
|
||||
self.app.on_exit();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! File dialogs that don't block the frame (macOS), for `Hooks::file_dialog_async`.
|
||||
//!
|
||||
//! A blocking `rfd::FileDialog` runs a nested modal event loop inside eframe's frame, that is
|
||||
//! inside winit's event handler. On macOS a file dragged over the window while that loop ran
|
||||
//! re-entered winit's handler, which panics across the Objective-C boundary and aborts the app
|
||||
//! (#511). `rfd::AsyncFileDialog` shows the dialog as a sheet on the window and returns at once;
|
||||
//! the answer arrives when the sheet closes, outside the frame, and the app runs the command then.
|
||||
|
||||
use effectcraft_ui_egui::{DialogDone, FileDialog};
|
||||
use rfd::{AsyncFileDialog, FileHandle};
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
type Answer = Pin<Box<dyn Future<Output = Vec<String>> + Send>>;
|
||||
|
||||
/// Show `dialog` (on the main thread: the sheet opens here) and hand its paths to `done` once it
|
||||
/// closes, then wake the UI.
|
||||
pub fn show(dialog: FileDialog, done: DialogDone, ctx: egui::Context) {
|
||||
let answer = open(dialog);
|
||||
let spawned = std::thread::Builder::new().name("ec-file-dialog".into()).spawn(move || {
|
||||
done(pollster::block_on(answer));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::error!("file dialog: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn paths(files: Option<Vec<FileHandle>>) -> Vec<String> {
|
||||
files.unwrap_or_default().into_iter().map(|f| f.path().to_string_lossy().to_string()).collect()
|
||||
}
|
||||
|
||||
fn path(file: Option<FileHandle>) -> Vec<String> {
|
||||
paths(file.map(|f| vec![f]))
|
||||
}
|
||||
|
||||
/// The dialog, as the blocking hooks in `main.rs` show it.
|
||||
fn open(dialog: FileDialog) -> Answer {
|
||||
match dialog {
|
||||
FileDialog::Open { exts, many } => {
|
||||
let d = AsyncFileDialog::new().add_filter("Media", &exts);
|
||||
if many {
|
||||
let f = d.pick_files();
|
||||
Box::pin(async move { paths(f.await) })
|
||||
} else {
|
||||
let f = d.pick_file();
|
||||
Box::pin(async move { path(f.await) })
|
||||
}
|
||||
}
|
||||
FileDialog::OpenProject => {
|
||||
let f = AsyncFileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file();
|
||||
Box::pin(async move { path(f.await) })
|
||||
}
|
||||
FileDialog::Folder => {
|
||||
let f = AsyncFileDialog::new().pick_folder();
|
||||
Box::pin(async move { path(f.await) })
|
||||
}
|
||||
FileDialog::Save { default, ext, project } => {
|
||||
let at = std::path::Path::new(&default);
|
||||
let name = at.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let mut d = if project {
|
||||
AsyncFileDialog::new().add_filter("EffectCraft Project", &["ecproj"])
|
||||
} else if ext.is_empty() {
|
||||
AsyncFileDialog::new()
|
||||
} else {
|
||||
AsyncFileDialog::new().add_filter(&ext, &[ext.as_str()])
|
||||
};
|
||||
// A default with a folder that exists opens the dialog there.
|
||||
if let Some(dir) = at.parent().filter(|d| d.is_dir()) {
|
||||
d = d.set_directory(dir);
|
||||
}
|
||||
let f = d.set_file_name(name).save_file();
|
||||
Box::pin(async move { path(f.await) })
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user