refactor(ui-egui): move app constructors and eframe::App to app.rs, clock and hooks to their own files, screenshot and playback state to state.rs

This commit is contained in:
aiongg
2026-10-26 21:49:11 +00:00
parent da99df18b4
commit 0eea2d67ab
5 changed files with 2612 additions and 2586 deletions
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
use super::*;
/// The application's monotonic clock, in seconds.
///
/// Neither `ctx.input(|i| i.time)` nor `RawInput::time` is valid in a logic-only tick:
/// `Context::run_logic` copies only the viewport information, so both are the previous full
/// pass's values. Playback, auto-save, screenshot deadlines and the control channel measure time
/// with this clock instead. Native and web use the same [`web_time::Instant`] source; tests
/// inject [`AppClock::manual`] and advance it.
#[derive(Clone, Debug)]
pub struct AppClock {
origin: web_time::Instant,
manual: Option<f64>,
}
impl AppClock {
/// The host's monotonic clock.
pub fn real() -> Self {
Self { origin: web_time::Instant::now(), manual: None }
}
/// A clock tests advance explicitly with [`AppClock::advance`].
pub fn manual() -> Self {
Self { origin: web_time::Instant::now(), manual: Some(0.0) }
}
/// Seconds since the clock started (monotonic on native and in the browser).
pub fn now(&self) -> f64 {
match self.manual {
Some(t) => t,
None => self.origin.elapsed().as_secs_f64(),
}
}
/// Move a manual clock forward; a real clock ignores this.
pub fn advance(&mut self, seconds: f64) {
if let Some(t) = &mut self.manual {
*t = (*t + seconds.max(0.0)).max(0.0);
}
}
/// Whether the clock is test-driven.
pub fn is_manual(&self) -> bool {
self.manual.is_some()
}
}
impl Default for AppClock {
/// The real monotonic clock (`Self::real()`).
fn default() -> Self {
Self::real()
}
}
impl EffectcraftApp {
/// The app clock's value at the last tick.
pub fn now(&self) -> f64 {
self.now
}
/// The clock itself (tests advance a manual clock).
pub fn clock_mut(&mut self) -> &mut AppClock {
&mut self.clock
}
/// Use a test-driven clock from now on.
pub fn use_manual_clock(&mut self) {
self.clock = AppClock::manual();
self.now = 0.0;
}
}
+83
View File
@@ -0,0 +1,83 @@
use super::*;
/// The desktop's light or dark appearance where egui cannot see it (Wayland compositors that send
/// winit no theme). `None` = the host does not know.
pub type SystemThemeFn = Box<dyn Fn(&egui::Context) -> Option<egui::Theme>>;
/// A file dialog a host shows without blocking the frame ([`Hooks::file_dialog_async`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FileDialog {
/// Files to open or import with these extensions; several when `many`.
Open { exts: Vec<String>, many: bool },
/// An EffectCraft project to open.
OpenProject,
/// A folder.
Folder,
/// Where to save `default` (a file name or path) with extension `ext` (empty: no filter);
/// `project`: the project save dialog, given the file name.
Save { default: String, ext: String, project: bool },
}
impl FileDialog {
/// The save dialog for `default`, as [`Hooks::save_dialog`] picks it.
pub fn save(default: &str) -> Self {
let ext = std::path::Path::new(default).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
Self::Save { default: default.to_string(), project: ext.eq_ignore_ascii_case("ecproj"), ext }
}
}
/// Called with the paths a [`FileDialog`] picked (empty: cancelled), from any thread.
pub type DialogDone = Box<dyn FnOnce(Vec<String>) + Send>;
/// Host hooks provided by the native app (file pickers etc.).
#[derive(Default)]
pub struct Hooks {
/// The system appearance for Settings ▸ Appearance ▸ Sync with System (the desktop app reads
/// the Linux desktop portal); without it, what egui reports.
pub system_theme: Option<SystemThemeFn>,
pub pick_files: Option<Box<dyn Fn(&[&str]) -> Vec<String>>>,
pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
pub pick_open_project: Option<Box<dyn Fn() -> Option<String>>>,
/// Opens the audio output for preview playback (the desktop app uses cpal).
pub audio_device: Option<audio::AudioDeviceFactory>,
/// Lists audio output devices (Settings ▸ Audio).
pub audio_devices: Option<Box<dyn Fn() -> Vec<String>>>,
/// Picks a folder (Settings paths).
pub pick_folder: Option<Box<dyn Fn() -> Option<String>>>,
/// Save dialog for other file kinds: (default name or path, extension; empty: no filter). A
/// default path opens the dialog in its folder.
pub pick_save_file: Option<Box<dyn Fn(&str, &str) -> Option<String>>>,
/// The system clipboard's text (native menu Edit ▸ Paste into a text field).
pub clipboard_text: Option<Box<dyn Fn() -> Option<String>>>,
/// Application actions the OS performs (`app.hide`, `app.hideOthers`, `app.showAll` on
/// macOS). Returns false when the host doesn't handle the id.
pub app_action: Option<Box<dyn Fn(&str) -> bool>>,
/// Shows a command's file dialog without blocking the frame and calls `done` with the
/// answer (the host then asks for a repaint). Used in place of the blocking pickers above for
/// the commands' dialogs (File ▸ Import, Open, Save As…) where blocking the frame is unsafe or
/// leaves the window unresponsive: on macOS a nested modal event loop there aborted the app on
/// a drag over the window (#511), and on Linux a blocking desktop-portal dialog never pumps
/// events, so the compositor marks the window as not responding (#1275).
pub file_dialog_async: Option<Box<dyn Fn(FileDialog, DialogDone)>>,
/// Tells the user the graphics device was lost and the window is closing, once. `saved` is
/// the auto-save of unsaved changes (`None`: nothing was unsaved), its path or why it failed.
pub device_lost: Option<Box<dyn Fn(Option<Result<String, String>>)>>,
}
impl Hooks {
/// The host's save dialog for `default` (a file name or path), offering its extension: the
/// project dialog for project files, else [`Hooks::pick_save_file`]. The project dialog is
/// the fallback for hosts without that (the web, whose dialog only names the file): on macOS
/// a dialog filtered to projects appends `.ecproj` to any other name (#293). `None` when the
/// host has no save dialog; `Some(None)` when it was cancelled.
pub fn save_dialog(&self, default: &str) -> Option<Option<String>> {
let path = std::path::Path::new(default);
let ext = path.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
match (&self.pick_save_file, &self.pick_save) {
(Some(pick), _) if !ext.eq_ignore_ascii_case("ecproj") => Some(pick(default, &ext)),
(_, Some(pick)) => Some(pick(&path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default())),
(Some(pick), None) => Some(pick(default, &ext)),
(None, None) => None,
}
}
}
+8 -2586
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -229,6 +229,7 @@ impl Tool {
}
}
use super::*;
/// The viewer's Resolution: each composition keeps its own in the project
/// ([`effectcraft_engine::project::Comp::resolution`], `EffectcraftApp::viewer_res`).
pub use effectcraft_engine::project::Resolution;
@@ -746,3 +747,48 @@ impl Default for UiState {
}
}
}
/// What a `ui.screenshot` captures.
///
/// A panel or element is an identity, not a rectangle: it is resolved again when the capture is
/// issued, after the settle passes, so a panel that moved or resized while the UI settled is
/// captured where it is now (never with the rectangle from request admission).
#[derive(Clone, Debug, PartialEq)]
pub enum ShotTarget {
/// The whole root surface.
Window,
/// A fixed rectangle in logical points, relative to the root surface's top-left corner.
Rect([f32; 4]),
/// One dock panel's automation rectangle (`panel.<id>`).
Panel(PanelKind),
/// One automation element id's rectangle.
Id(String),
}
/// The comp frames a playback pass covers (`first..=last`).
#[derive(Clone, Copy, Debug)]
pub(crate) struct FrameWindow {
pub(crate) first: i64,
pub(crate) last: i64,
}
/// The inputs of one playback pass: the viewer series it renders, the preview plan and
/// position, and the frame range and clock settings its steps share. `queued` is a snapshot of
/// the in-flight render count that each step copies. Built once in `advance_playback`.
#[derive(Clone, Copy)]
pub(crate) struct PlaybackPass<'a> {
pub(crate) ctx: &'a egui::Context,
pub(crate) comp: &'a effectcraft_engine::project::Comp,
pub(crate) cid: ItemId,
pub(crate) scale: f64,
pub(crate) now: f64,
pub(crate) frame_rate: effectcraft_engine::time::FrameRate,
pub(crate) series: FrameKey,
pub(crate) plan: Option<effectcraft_engine::preview::PreviewPlan>,
pub(crate) window: FrameWindow,
pub(crate) cur: i64,
pub(crate) video: bool,
pub(crate) looping: bool,
pub(crate) ahead: i64,
pub(crate) queued: usize,
}