mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 03:28:22 -06:00
2036 lines
92 KiB
Rust
2036 lines
92 KiB
Rust
//! The EffectCraft egui frontend (swappable): docking panels, the Composition viewer, the
|
||
//! Timeline with property twirl-downs and keyframes, Effect Controls, Effects & Presets, Preview,
|
||
//! Info, Character/Paragraph/Align, menus, dialogs and the control channel.
|
||
//!
|
||
//! Everything goes through `effectcraft_engine::Session::execute` (or `menus::invoke` for
|
||
//! frontend-only commands) so every gesture is also available to agents.
|
||
|
||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||
|
||
pub mod audio;
|
||
pub mod automation;
|
||
pub mod bench;
|
||
pub mod color_picker;
|
||
pub mod control;
|
||
pub mod credits;
|
||
pub mod dock;
|
||
mod dock_shared;
|
||
pub mod dock_ui;
|
||
pub mod frames;
|
||
pub mod gpu_failure;
|
||
pub mod header;
|
||
pub mod i18n;
|
||
pub mod icons;
|
||
pub mod menu_keys;
|
||
pub mod menus;
|
||
pub mod native_menu;
|
||
pub mod panels;
|
||
mod pointer_wrap;
|
||
pub mod prefs_live;
|
||
pub mod state;
|
||
pub mod theme;
|
||
pub mod widgets;
|
||
mod workspace_state;
|
||
|
||
use std::sync::Arc;
|
||
use std::sync::mpsc::{Receiver, Sender};
|
||
|
||
pub use control::ControlRequest;
|
||
use dock::PanelKind;
|
||
use effectcraft_engine::Session;
|
||
use effectcraft_engine::project::ItemId;
|
||
use effectcraft_engine::render::RenderOpts;
|
||
use effectcraft_engine::time::Tick;
|
||
use frames::{FrameKey, Frames, RenderSource};
|
||
use serde_json::json;
|
||
use state::UiState;
|
||
use theme::Tokens;
|
||
|
||
const SCREENSHOT_TIMEOUT_S: f64 = 4.0;
|
||
/// Seconds without a project change before paused prefetch resumes (see
|
||
/// [`EffectcraftApp::editing`]).
|
||
const EDIT_QUIET: f64 = 0.3;
|
||
|
||
/// Modal dialogs.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum Dialog {
|
||
About,
|
||
NewComp,
|
||
CompSettings,
|
||
SolidSettings,
|
||
CommandPalette,
|
||
/// Settings (Preferences) on `dialog_state.settings_page`.
|
||
Settings,
|
||
/// Edit ▸ Keyboard Shortcuts.
|
||
Shortcuts,
|
||
/// A generic parameter form for a command (`dialog_state.form`).
|
||
Form,
|
||
/// A message (`dialog_state.info`).
|
||
Info,
|
||
/// View ▸ View Options.
|
||
ViewOptions,
|
||
CameraSettings,
|
||
LightSettings,
|
||
KeyVelocity,
|
||
KeyInterpolation,
|
||
TimeStretch,
|
||
/// Tracker ▸ Options…
|
||
TrackOptions,
|
||
/// Tracker ▸ Edit Target…
|
||
TrackTarget,
|
||
/// Tracker ▸ Apply (Transform / Stabilize): Apply Dimensions.
|
||
TrackApply,
|
||
/// Crash recovery: offer the latest auto-save (`EffectcraftApp::recovery`).
|
||
Recovery,
|
||
/// Composition/Layer Marker (double-click a marker).
|
||
Marker,
|
||
/// Layer ▸ Pre-compose.
|
||
Precompose,
|
||
/// Layer ▸ Layer Styles ▸ Layer Style Options…
|
||
LayerStyles,
|
||
/// Edit ▸ Templates ▸ Render Settings… / Output Module….
|
||
RenderTemplates,
|
||
/// Save changes before closing the project? (`panels::unsaved`).
|
||
UnsavedChanges,
|
||
/// Delete Project items that compositions use? (`panels::delete_items`).
|
||
DeleteItems,
|
||
}
|
||
|
||
/// 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>;
|
||
|
||
/// A non-blocking file dialog's answer for the command that asked (`menus::dialog_answered`).
|
||
pub(crate) struct DialogAnswer {
|
||
pub command: String,
|
||
pub key: &'static str,
|
||
pub params: serde_json::Value,
|
||
pub paths: Vec<String>,
|
||
}
|
||
|
||
/// 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 a modal dialog's nested event
|
||
/// loop inside a frame is unsafe: on macOS a drag over the window then aborted the app (#511).
|
||
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,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Default)]
|
||
pub struct Playback {
|
||
pub playing: bool,
|
||
/// Wall-clock start and the comp time it corresponds to.
|
||
pub start_wall: f64,
|
||
pub start_time: Tick,
|
||
pub start_frame: i64,
|
||
/// Frames shown / dropped (for the Info/Preview readout).
|
||
pub shown: u64,
|
||
pub waiting: bool,
|
||
pub fps: f64,
|
||
/// The Preview panel shortcut that started this preview and what it plays.
|
||
pub shortcut: effectcraft_engine::preview::PreviewShortcut,
|
||
pub plan: Option<effectcraft_engine::preview::PreviewPlan>,
|
||
/// Cache Before Playback: rendering the range before the clock starts.
|
||
pub caching: bool,
|
||
/// Resolution override while playing (pixel step), `None` = the viewer's.
|
||
pub res: Option<u32>,
|
||
/// Include Overlays / Include Layer Controls.
|
||
pub overlays: bool,
|
||
pub layer_controls: bool,
|
||
/// Full Screen: the maximized panel to restore when the preview stops.
|
||
pub restore_max: Option<Option<PanelKind>>,
|
||
/// Audio-only previews: the frame under the audible sample (the current time stays put).
|
||
pub audio_frame: Option<i64>,
|
||
/// The preview's sound waits for frames: while the frames ahead aren't cached, every frame
|
||
/// shows as it renders, silently; the sound starts once the rest of the preview (or what
|
||
/// fits in the RAM preview) is cached, so it never plays over skipped frames (#103), or
|
||
/// sooner once the silent preview keeps real time with the frames ahead cached (#208).
|
||
pub audio_held: bool,
|
||
/// When the last frames were shown (seconds, the last second's worth): the achieved rate.
|
||
pub shown_at: std::collections::VecDeque<f64>,
|
||
}
|
||
|
||
impl Playback {
|
||
/// A new frame went on screen at `now`.
|
||
pub fn frame_shown(&mut self, now: f64) {
|
||
self.shown += 1;
|
||
self.shown_at.push_back(now);
|
||
while self.shown_at.front().is_some_and(|t| now - t > 1.0) {
|
||
self.shown_at.pop_front();
|
||
}
|
||
}
|
||
|
||
/// Frames per second shown over the last second while the preview plays (`None` before two
|
||
/// frames were shown, while caching, or stopped).
|
||
pub fn achieved_fps(&self) -> Option<f64> {
|
||
let (a, b) = (self.shown_at.front()?, self.shown_at.back()?);
|
||
(self.playing && !self.caching && self.shown_at.len() > 1 && b > a).then(|| (self.shown_at.len() - 1) as f64 / (b - a))
|
||
}
|
||
|
||
/// The preview keeps up with its frame rate (within 5 %; previews that skip frames count
|
||
/// the frames they mean to show).
|
||
pub fn real_time(&self) -> Option<bool> {
|
||
let step = self.plan.map_or(1, |p| p.step).max(1) as f64;
|
||
self.achieved_fps().map(|f| f * step >= self.fps * 0.95)
|
||
}
|
||
|
||
/// The preview has kept real time for the last quarter second (at least two frames) up to
|
||
/// `now`: rendering keeps up, so sound can start without waiting for the whole preview.
|
||
pub fn keeps_up(&self, now: f64) -> bool {
|
||
let step = self.plan.map_or(1, |p| p.step).max(1) as f64;
|
||
let frame = step / self.fps.max(1.0);
|
||
let (Some(a), Some(b)) = (self.shown_at.front(), self.shown_at.back()) else { return false };
|
||
self.shown_at.len() >= 3 && b - a >= (2.0 * frame).max(0.25) && now - b <= 2.0 * frame && self.real_time() == Some(true)
|
||
}
|
||
}
|
||
|
||
pub struct EffectcraftApp {
|
||
pub session: Session,
|
||
pub ui: UiState,
|
||
workspace_store: workspace_state::WorkspaceStore,
|
||
pub tokens: Tokens,
|
||
pub auto: automation::Registry,
|
||
pub frames: Frames,
|
||
pub playback: Playback,
|
||
pub dialog: Option<Dialog>,
|
||
pub hooks: Hooks,
|
||
pub fps: f32,
|
||
last_time: f64,
|
||
styled: bool,
|
||
fonts_ready: bool,
|
||
/// The interface language the fonts were installed for; `""` until the first frame.
|
||
styled_language: &'static str,
|
||
pub(crate) control_rx: Option<Receiver<ControlRequest>>,
|
||
pub(crate) deferred: Vec<(ControlRequest, f64)>,
|
||
pub(crate) synthetic: Vec<egui::Event>,
|
||
pub(crate) input_waiters: Vec<Sender<serde_json::Value>>,
|
||
/// Control requests waiting for an offloaded job (`wait: true` in the browser).
|
||
pub(crate) job_waiters: Vec<(control::JobWaiter, Sender<serde_json::Value>)>,
|
||
queued_screenshots: Vec<(u64, f64, u32)>,
|
||
pending_screenshots: Vec<(u64, Option<String>, Option<[f32; 4]>, Sender<serde_json::Value>, f64)>,
|
||
next_token: u64,
|
||
pub(crate) last_ui_time: f64,
|
||
/// When the user last did something (input or an edit): Cache Frames When Idle waits for
|
||
/// a second of quiet. (time, project revision then)
|
||
last_activity: (f64, u64),
|
||
/// When the project last changed, and its revision then (see [`Self::editing`]).
|
||
last_edit: (f64, u64),
|
||
pub(crate) toast: Option<(String, f64)>,
|
||
/// Texture of the last CPU frame shown in the viewer, the key it came from and the factor it
|
||
/// was averaged down by for the magnification (`panels::viewer::minify_factor`).
|
||
pub(crate) viewer_tex: Option<(egui::TextureHandle, FrameKey, usize)>,
|
||
/// The frames the other (passive) Composition viewers show, by viewer id.
|
||
pub(crate) passive_tex: std::collections::HashMap<u32, (FrameKey, panels::viewers::PassiveTexture)>,
|
||
/// The last GPU frame shown: its egui texture id (registered with egui-wgpu), key and texture.
|
||
pub(crate) viewer_native: Option<(egui::TextureId, FrameKey, Arc<effectcraft_gpu::DisplayFrame>)>,
|
||
/// The texture the viewer draws and its frame key (CPU or GPU frame).
|
||
pub(crate) viewer_shown: Option<(egui::TextureId, FrameKey)>,
|
||
/// Last displayed image (Info panel colour, eyedroppers, histograms). GPU frames fill it on
|
||
/// demand ([`EffectcraftApp::viewer_pixels`]).
|
||
pub(crate) viewer_image: Option<Arc<egui::ColorImage>>,
|
||
/// wasm32: an asynchronous readback of a GPU viewer frame (key asked for, result).
|
||
viewer_readback: (Option<FrameKey>, Arc<std::sync::Mutex<Option<(FrameKey, Arc<egui::ColorImage>)>>>),
|
||
/// egui-wgpu's device, once the first frame arrives (desktop and WebGPU).
|
||
pub(crate) wgpu: Option<eframe::egui_wgpu::RenderState>,
|
||
/// The GPU compositor on that device (None: no usable adapter → CPU only).
|
||
pub(crate) gpu: Option<effectcraft_gpu::Gpu>,
|
||
gpu_checked: bool,
|
||
gpu_failures: Option<gpu_failure::GpuFailureBridge>,
|
||
gpu_failure_applied: Option<bool>,
|
||
/// The graphics device was lost and the window is closing (see `close_after_device_loss`);
|
||
/// `true` keeps crash recovery for the next launch.
|
||
device_lost_close: Option<bool>,
|
||
/// Answers of file dialogs shown without blocking ([`Hooks::file_dialog_async`]).
|
||
pub(crate) dialog_answers: (Sender<DialogAnswer>, Receiver<DialogAnswer>),
|
||
/// Commands from the native menu bar.
|
||
pub command_inbox: Option<Receiver<String>>,
|
||
/// Pointer position in comp pixels (Info panel).
|
||
pub(crate) pointer_comp: Option<[f32; 2]>,
|
||
pub(crate) dialog_state: panels::dialogs::DialogState,
|
||
pub integrated_titlebar: bool,
|
||
/// Audio preview while playing (audio clock drives playback).
|
||
pub audio: Option<audio::AudioPlayback>,
|
||
/// Audio scrubbing's open output (Ctrl/Cmd-drag the current time, `playback.scrubAudio`).
|
||
pub scrub: Option<audio::AudioScrub>,
|
||
/// Audio panel VU meters.
|
||
pub meter: audio::Meter,
|
||
/// Waveform peak summaries per footage item (see `panels::waveform`).
|
||
pub(crate) waveforms: panels::waveform::Cache,
|
||
/// Last reveal shortcut and when (double-press shortcuts such as LL).
|
||
pub(crate) last_reveal: Option<(String, f64)>,
|
||
/// Settings revision applied to the theme, tooltips and caches.
|
||
/// The settings revision and system appearance (light?) last applied.
|
||
applied_prefs: Option<(u64, Option<bool>)>,
|
||
/// A previous run that didn't exit cleanly (shown by `Dialog::Recovery`).
|
||
pub recovery: Option<effectcraft_engine::autosave::Recovery>,
|
||
/// Docked groups laid out last frame: (active panel, group rect) — `~` maximizes the one
|
||
/// under the pointer.
|
||
pub(crate) dock_rects: Vec<(PanelKind, egui::Rect)>,
|
||
/// Home screen: recent-project thumbnail textures by path (None = no thumbnail).
|
||
pub(crate) home_thumbs: std::collections::HashMap<String, Option<egui::TextureHandle>>,
|
||
/// The (project path, saved revision) whose thumbnail was stored last.
|
||
pub(crate) home_thumb_saved: Option<(String, u64)>,
|
||
/// The window title last sent (project name, `*` while modified).
|
||
window_title: String,
|
||
/// Home ▸ Templates: thumbnail textures by template id (None = not renderable).
|
||
pub(crate) template_thumbs: std::collections::HashMap<String, Option<egui::TextureHandle>>,
|
||
/// Home ▸ Templates: the gallery, and the user template files it was listed from.
|
||
pub(crate) template_list: Option<(Vec<String>, Vec<effectcraft_engine::templates::TemplateInfo>)>,
|
||
}
|
||
|
||
/// The saved workspaces in the settings store ([`effectcraft_engine::config::ConfigStore`]).
|
||
pub const WORKSPACES_FILE: &str = "workspaces.json";
|
||
|
||
#[derive(serde::Serialize, serde::Deserialize)]
|
||
struct SavedWorkspaces {
|
||
#[serde(default)]
|
||
workspaces: std::collections::BTreeMap<String, dock::DockNode>,
|
||
#[serde(default)]
|
||
floating: std::collections::BTreeMap<String, Vec<dock::Floating>>,
|
||
}
|
||
|
||
impl EffectcraftApp {
|
||
pub fn new(session: Session) -> Self {
|
||
let mut ui = UiState::default();
|
||
let workspace_store = workspace_state::WorkspaceStore::load(session.config.as_deref(), &mut ui);
|
||
EffectcraftApp {
|
||
session,
|
||
ui,
|
||
workspace_store,
|
||
tokens: Tokens::for_kind(Default::default()),
|
||
auto: Default::default(),
|
||
frames: Frames::default(),
|
||
playback: Playback::default(),
|
||
dialog: None,
|
||
hooks: Hooks::default(),
|
||
fps: 60.0,
|
||
last_time: 0.0,
|
||
styled: false,
|
||
fonts_ready: false,
|
||
styled_language: "",
|
||
control_rx: None,
|
||
deferred: vec![],
|
||
synthetic: vec![],
|
||
input_waiters: vec![],
|
||
job_waiters: vec![],
|
||
queued_screenshots: vec![],
|
||
pending_screenshots: vec![],
|
||
next_token: 1,
|
||
last_ui_time: 0.0,
|
||
last_activity: (0.0, 0),
|
||
last_edit: (0.0, 0),
|
||
toast: None,
|
||
viewer_tex: None,
|
||
passive_tex: std::collections::HashMap::new(),
|
||
viewer_native: None,
|
||
viewer_shown: None,
|
||
viewer_image: None,
|
||
viewer_readback: Default::default(),
|
||
wgpu: None,
|
||
gpu: None,
|
||
gpu_checked: false,
|
||
gpu_failures: None,
|
||
gpu_failure_applied: None,
|
||
device_lost_close: None,
|
||
dialog_answers: std::sync::mpsc::channel(),
|
||
command_inbox: None,
|
||
pointer_comp: None,
|
||
dialog_state: Default::default(),
|
||
integrated_titlebar: false,
|
||
audio: None,
|
||
scrub: None,
|
||
meter: Default::default(),
|
||
waveforms: Default::default(),
|
||
last_reveal: None,
|
||
applied_prefs: None,
|
||
recovery: None,
|
||
dock_rects: vec![],
|
||
home_thumbs: Default::default(),
|
||
home_thumb_saved: None,
|
||
window_title: String::new(),
|
||
template_thumbs: Default::default(),
|
||
template_list: None,
|
||
}
|
||
.with_ui_commands()
|
||
}
|
||
|
||
/// Keep the saved workspaces for the next session (see [`EffectcraftApp::new`]): After
|
||
/// Effects' saved workspaces last until they are deleted (#320).
|
||
pub(crate) fn store_saved_workspaces(&mut self) {
|
||
self.save_workspace_state();
|
||
}
|
||
|
||
/// Offer the frontend's commands (tools, timeline navigation…) for keyboard shortcuts.
|
||
fn with_ui_commands(mut self) -> Self {
|
||
let cmds = menus::UI_COMMANDS
|
||
.iter()
|
||
.map(|c| effectcraft_engine::shortcuts::UiCommand { id: c.id.into(), label: c.label.into(), shortcut: c.shortcut.map(str::to_string) })
|
||
.collect();
|
||
self.session.set_ui_commands(cmds);
|
||
self
|
||
}
|
||
|
||
/// Show the crash-recovery dialog for a previous run that didn't exit cleanly.
|
||
pub fn offer_recovery(&mut self, r: effectcraft_engine::autosave::Recovery) {
|
||
if r.autosave.is_some() {
|
||
self.recovery = Some(r);
|
||
self.dialog = Some(Dialog::Recovery);
|
||
}
|
||
}
|
||
|
||
/// Apply changed settings: theme and brightness, label colours, tool tips, preview caches.
|
||
pub fn apply_prefs(&mut self, ctx: &egui::Context) {
|
||
// Sync with System re-applies when the system appearance changes (the host wakes the UI;
|
||
// nothing polls).
|
||
let system = if self.session.prefs.appearance.appearance_mode == "auto" { self.system_light(ctx) } else { None };
|
||
if self.applied_prefs == Some((self.session.prefs_revision, system)) {
|
||
return;
|
||
}
|
||
self.applied_prefs = Some((self.session.prefs_revision, system));
|
||
let p = &self.session.prefs;
|
||
self.ui.theme = theme::ThemeKind::from_name(p.appearance.resolved_theme(system)).unwrap_or_default();
|
||
self.tokens = Tokens::from_prefs(p, self.ui.theme);
|
||
theme::apply_visuals(ctx, &self.tokens);
|
||
ctx.set_theme(theme::theme_preference(&p.appearance.appearance_mode, self.ui.theme.is_light()));
|
||
let tips = p.general.show_tool_tips;
|
||
ctx.all_styles_mut(|s| s.interaction.tooltip_delay = if tips { 0.5 } else { f32::INFINITY });
|
||
// Settings ▸ Appearance ▸ UI Scale (#284). egui's own Ctrl+= / Ctrl+- / Ctrl+0 zoom stays
|
||
// off: Ctrl+0 is Window ▸ Project, and the scale is a setting.
|
||
ctx.options_mut(|o| o.zoom_with_keyboard = false);
|
||
ctx.set_zoom_factor(p.appearance.ui_scale as f32 / 100.0);
|
||
self.ui.cache_when_idle = p.previews.cache_frames_when_idle;
|
||
self.ui.viewer.fast_preview = p.previews.fast_previews;
|
||
self.frames.set_budget(p.cache_budgets(self.session.sys_memory).preview);
|
||
}
|
||
|
||
/// Change settings from the UI (applied next frame and saved).
|
||
pub fn set_pref(&mut self, key: &str, value: serde_json::Value) -> Result<(), String> {
|
||
self.session.prefs.set(key, value)?;
|
||
self.session.prefs_changed();
|
||
self.session.save_prefs();
|
||
Ok(())
|
||
}
|
||
|
||
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
|
||
self.control_rx = Some(rx);
|
||
self
|
||
}
|
||
|
||
/// Show theme `k`: the appearance mode becomes its family's (Dark or Light), and `k` that
|
||
/// family's theme.
|
||
pub fn set_theme(&mut self, ctx: &egui::Context, k: theme::ThemeKind) {
|
||
if let Err(e) = self.set_pref("appearance.theme", json!(k.name())) {
|
||
self.ui.status = e;
|
||
}
|
||
self.apply_prefs(ctx);
|
||
}
|
||
|
||
/// The system appearance: light (`Some(true)`), dark, or unknown. The host's answer (the Linux
|
||
/// desktop portal) wins over egui's, which Wayland compositors often leave empty.
|
||
pub fn system_light(&self, ctx: &egui::Context) -> Option<bool> {
|
||
self.hooks.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme()).map(|t| t == egui::Theme::Light)
|
||
}
|
||
|
||
/// The Tools bar's appearance button (`view.theme.toggle`): Sync with System, then Light, then
|
||
/// Dark, then Sync with System again. The light and dark theme choices are kept.
|
||
pub fn cycle_appearance(&mut self, ctx: &egui::Context) {
|
||
let next = match self.session.prefs.appearance.appearance_mode.as_str() {
|
||
"auto" => "light",
|
||
"light" => "dark",
|
||
_ => "auto",
|
||
};
|
||
if let Err(e) = self.set_pref("appearance.appearanceMode", json!(next)) {
|
||
self.ui.status = e;
|
||
}
|
||
self.apply_prefs(ctx);
|
||
}
|
||
|
||
pub fn set_workspace(&mut self, name: &str) {
|
||
// Learn: the Home screen's Learn tab (tutorials) in the Composition panel. Leaving it for
|
||
// another workspace closes the Home screen again: it stayed over every workspace until a
|
||
// project was created (#272).
|
||
if name == "Learn" {
|
||
self.ui.start_screen = true;
|
||
self.ui.home_learn = true;
|
||
self.ui.home_templates = false;
|
||
} else if self.ui.workspace == "Learn" {
|
||
self.ui.start_screen = false;
|
||
self.ui.home_learn = false;
|
||
}
|
||
self.ui.workspace = name.to_string();
|
||
self.ui.dock = self.ui.saved_workspaces.get(name).cloned().unwrap_or_else(|| dock::workspace(name));
|
||
self.ui.floating = self.ui.saved_floating.get(name).cloned().unwrap_or_else(|| dock::workspace_floating(name));
|
||
self.ui.maximized = None;
|
||
}
|
||
|
||
/// Built-in workspaces followed by the saved ones (Window ▸ Workspace ▸ Save as New Workspace).
|
||
pub fn workspace_names(&self) -> Vec<String> {
|
||
let mut v: Vec<String> = dock::WORKSPACES.iter().map(|s| s.to_string()).collect();
|
||
v.extend(self.saved_workspace_names());
|
||
v
|
||
}
|
||
|
||
/// The workspaces the user saved under a new name (built-ins whose changes were saved are
|
||
/// not repeated), in name order.
|
||
pub fn saved_workspace_names(&self) -> Vec<String> {
|
||
self.ui.saved_workspaces.keys().filter(|k| !dock::WORKSPACES.contains(&k.as_str())).cloned().collect()
|
||
}
|
||
|
||
/// Show panel `p` (opening it in its usual place if it is closed), bring it to the front and
|
||
/// give it the focus: Window ▸ <panel>.
|
||
pub fn show_panel(&mut self, p: PanelKind) {
|
||
if !self.ui.floating.iter().any(|f| f.panels.contains(&p)) && self.ui.maximized.is_some_and(|m| m != p) {
|
||
self.ui.maximized = None;
|
||
}
|
||
self.raise_panel(p);
|
||
self.ui.focused = p;
|
||
}
|
||
|
||
/// Bring panel `p` to the front of its group, opening it in its usual place if it is closed,
|
||
/// without moving the focus (a comp opening brings up its viewer, an effect applied brings up
|
||
/// Effect Controls).
|
||
pub fn raise_panel(&mut self, p: PanelKind) {
|
||
if self.ui.floating.iter().any(|group| group.panels.contains(&p)) {
|
||
self.edit_layout(|layout| layout.apply(craft_ui::docking::Action::Activate { panel: p }).is_ok());
|
||
return;
|
||
}
|
||
if !self.ui.dock.contains(p) {
|
||
// The Composition panel lives in the centre, with the Layer panel and other viewers;
|
||
// with none of those left, above the Timeline.
|
||
let centre = |d: &dock::DockNode| {
|
||
let mut v = Vec::new();
|
||
d.panels(&mut v);
|
||
v.into_iter().find(|q| matches!(q, PanelKind::Layer | PanelKind::Viewer(_) | PanelKind::Flowchart | PanelKind::Footage))
|
||
};
|
||
let near = match p {
|
||
PanelKind::Composition => match centre(&self.ui.dock) {
|
||
Some(q) => q,
|
||
None if self.ui.dock.dock_panel(p, PanelKind::Timeline, dock::Zone::Top) => {
|
||
self.ui.dock.activate(p);
|
||
return;
|
||
}
|
||
None => PanelKind::EffectsPresets,
|
||
},
|
||
PanelKind::Layer | PanelKind::Flowchart | PanelKind::Viewer(_) => PanelKind::Composition,
|
||
PanelKind::RenderQueue => PanelKind::Timeline,
|
||
PanelKind::EffectControls | PanelKind::History => PanelKind::Project,
|
||
_ => PanelKind::EffectsPresets,
|
||
};
|
||
self.ui.dock.open_near(p, near);
|
||
}
|
||
self.ui.dock.activate(p);
|
||
}
|
||
|
||
pub fn render_source(&self) -> RenderSource {
|
||
RenderSource {
|
||
project: self.session.project.clone(),
|
||
footage: self.session.footage.clone(),
|
||
expr: self.session.expr.clone(),
|
||
layer_cache: self.session.layer_cache.clone(),
|
||
gpu: self.gpu.clone(),
|
||
gpu_display: self.gpu_display(),
|
||
disk: self.session.disk_cache.clone(),
|
||
}
|
||
}
|
||
|
||
/// Keep GPU-composited viewer frames on the GPU (drawn from their texture). The browser can't
|
||
/// read frames back (its thread can't wait for the GPU), so there the viewer shows GPU frames
|
||
/// directly whenever it draws them plainly (RGB, no exposure, no region of interest); the
|
||
/// desktop reads them back (see [`frames::RenderSource::gpu_display`]).
|
||
fn gpu_display(&self) -> bool {
|
||
let v = &self.session.state.viewer;
|
||
cfg!(target_arch = "wasm32")
|
||
&& v.channel == effectcraft_engine::viewer::Channel::Rgb
|
||
&& v.exposure == 0.0
|
||
&& self.session.state.region_of_interest.is_none()
|
||
&& self.ui.viewer.extended.is_none()
|
||
}
|
||
|
||
/// The UI draws its panels and handles input such as dropped files. Until the fonts are
|
||
/// installed (the first frames) it draws nothing, and that input goes unhandled.
|
||
pub fn is_ready(&self) -> bool {
|
||
self.fonts_ready
|
||
}
|
||
|
||
/// Provide the executable/embedding host's device notifications. The host installs the
|
||
/// handlers once; this frontend never replaces handlers on a borrowed device.
|
||
pub fn set_gpu_failure_bridge(&mut self, bridge: gpu_failure::GpuFailureBridge) {
|
||
if let Some(gpu) = &self.gpu {
|
||
bridge.forward_to(gpu.context().device_failure_notifier());
|
||
}
|
||
self.gpu_failures = Some(bridge);
|
||
}
|
||
|
||
fn apply_gpu_failure(&mut self, ctx: &egui::Context) {
|
||
let Some(bridge) = &self.gpu_failures else { return };
|
||
// Readback deadlines can retire the compositor without a host device notification.
|
||
if let Some(gpu) = &self.gpu
|
||
&& let Err(reason) = gpu.context().check_health()
|
||
{
|
||
bridge.report(&reason, false);
|
||
}
|
||
let Some(failure) = bridge.failure() else { return };
|
||
if self.gpu_failure_applied == Some(failure.presentation_lost) {
|
||
return;
|
||
}
|
||
self.gpu_failure_applied = Some(failure.presentation_lost);
|
||
self.gpu_checked = true;
|
||
self.frames.retire_gpu();
|
||
self.session.accel = None;
|
||
self.session.accel_note = Some(format!("GPU preview retired: {}", failure.reason));
|
||
self.session.layer_cache.clear();
|
||
self.gpu = None;
|
||
if let Some(rs) = &self.wgpu {
|
||
let mut renderer = rs.renderer.write();
|
||
if let Some((id, _, _)) = &self.viewer_native {
|
||
renderer.free_texture(id);
|
||
}
|
||
for (_, texture) in self.passive_tex.values() {
|
||
if let panels::viewers::PassiveTexture::Gpu(id, _) = texture {
|
||
renderer.free_texture(id);
|
||
}
|
||
}
|
||
}
|
||
self.viewer_native = None;
|
||
self.passive_tex.retain(|_, (_, texture)| matches!(texture, panels::viewers::PassiveTexture::Cpu(_)));
|
||
self.viewer_shown = self.viewer_tex.as_ref().map(|(texture, key, _)| (texture.id(), *key));
|
||
self.viewer_image = None;
|
||
// Old completion closures retain only their detached slot, not the replacement.
|
||
self.viewer_readback = Default::default();
|
||
self.wgpu = None;
|
||
self.ui.status = if failure.presentation_lost {
|
||
"Graphics device lost. The project is still open; presentation requires restarting the application.".into()
|
||
} else {
|
||
"Shared GPU preview disabled; CPU compositing remains available.".into()
|
||
};
|
||
log::warn!("GPU preview retired: {}", failure.reason);
|
||
ctx.request_repaint();
|
||
}
|
||
|
||
/// After the graphics device is lost, eframe's renderer cannot draw again: any mesh makes
|
||
/// egui-wgpu panic on the main thread when it cannot stage its index buffer (#591). Draw
|
||
/// nothing from then on, auto-save a project with unsaved changes (keeping the crash
|
||
/// recovery marker, so the next launch offers it) and close the window.
|
||
fn close_after_device_loss(&mut self, ctx: &egui::Context) {
|
||
if self.device_lost_close.is_some() {
|
||
return;
|
||
}
|
||
let keep_recovery = self.session.is_dirty();
|
||
let saved = keep_recovery.then(|| match self.session.autosave_now().and_then(|path| self.session.autosave_wait().map(|()| path)) {
|
||
Ok(path) => {
|
||
log::warn!("graphics device lost: auto-saved {path}; the next launch offers to recover it");
|
||
Ok(path)
|
||
}
|
||
Err(e) => {
|
||
log::error!("graphics device lost: {e}");
|
||
Err(e.to_string())
|
||
}
|
||
});
|
||
self.device_lost_close = Some(keep_recovery);
|
||
log::warn!("graphics device lost: closing the window");
|
||
if let Some(tell) = &self.hooks.device_lost {
|
||
tell(saved);
|
||
}
|
||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||
}
|
||
|
||
/// Run the commands whose non-blocking file dialogs have been answered.
|
||
pub(crate) fn run_dialog_answers(&mut self) {
|
||
while let Ok(answer) = self.dialog_answers.1.try_recv() {
|
||
menus::dialog_answered(self, answer);
|
||
}
|
||
}
|
||
|
||
/// Set up the GPU compositor on egui-wgpu's device (once). Without an adapter that runs
|
||
/// compute shaders (WebGL2, software GL) everything stays on the CPU.
|
||
pub(crate) fn init_gpu(&mut self, frame: &eframe::Frame) {
|
||
if self.gpu_checked {
|
||
return;
|
||
}
|
||
self.gpu_checked = true;
|
||
if self.gpu_failures.as_ref().is_some_and(|bridge| bridge.failure().is_some()) {
|
||
return;
|
||
}
|
||
let Some(rs) = frame.wgpu_render_state() else { return };
|
||
// Help ▸ System Compatibility Report names the adapter and backend the window got.
|
||
let info = rs.adapter.get_info();
|
||
self.session.window_adapter = Some(format!(
|
||
"{} ({:?}, {:?}{})",
|
||
info.name,
|
||
info.backend,
|
||
info.device_type,
|
||
if info.driver.is_empty() { String::new() } else { format!(", {} {}", info.driver, info.driver_info) }
|
||
));
|
||
// Backend validation can panic on shader compilation even when device creation
|
||
// succeeded. Keep the document and the egui renderer, and use CPU compositing.
|
||
let gpu = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| effectcraft_gpu::Gpu::new(&rs.adapter, rs.device.clone(), rs.queue.clone())));
|
||
match gpu {
|
||
Ok(Ok(g)) => {
|
||
if let Some(bridge) = &self.gpu_failures {
|
||
bridge.forward_to(g.context().device_failure_notifier());
|
||
if bridge.failure().is_some() {
|
||
return;
|
||
}
|
||
}
|
||
log::info!("GPU compositor: {}", effectcraft_engine::render::Accelerator::name(&g));
|
||
self.session.accel = Some(Arc::new(g.clone()));
|
||
self.gpu = Some(g);
|
||
self.wgpu = Some(rs.clone());
|
||
}
|
||
Ok(Err(e)) => {
|
||
log::info!("GPU compositor unavailable: {e}");
|
||
self.session.accel_note = Some(format!("GPU compositor unavailable: {e}"));
|
||
}
|
||
Err(e) => {
|
||
let reason = e.downcast_ref::<String>().map(String::as_str).or_else(|| e.downcast_ref::<&str>().copied()).unwrap_or("backend panicked");
|
||
log::warn!("GPU compositor setup failed; using CPU compositing: {reason}");
|
||
self.session.accel_note = Some(format!("GPU compositor setup failed: {reason}"));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The viewer shows a frame composited on the GPU (drawn from its wgpu texture).
|
||
pub fn viewer_on_gpu(&self) -> bool {
|
||
matches!((&self.viewer_shown, &self.viewer_native), (Some(s), Some(n)) if s.1 == n.1)
|
||
}
|
||
|
||
/// The GPU compositor's adapter, when the viewer has one.
|
||
pub fn gpu_adapter(&self) -> Option<String> {
|
||
self.gpu.as_ref().map(effectcraft_engine::render::Accelerator::name)
|
||
}
|
||
|
||
/// The size of the viewer frame's texture (CPU frames; fitted to the GPU's texture limit).
|
||
pub fn viewer_texture_size(&self) -> Option<[usize; 2]> {
|
||
self.viewer_tex.as_ref().map(|(t, _, _)| t.size())
|
||
}
|
||
|
||
/// The viewer's current pixels as 8-bit premultiplied RGBA, reading a GPU frame back the
|
||
/// first time something asks for it.
|
||
///
|
||
/// The browser can't wait for the GPU: there the first call starts an asynchronous readback
|
||
/// and returns `None`; a later call (the next frame) has the pixels.
|
||
pub fn viewer_pixels(&mut self) -> Option<Arc<egui::ColorImage>> {
|
||
let to_image = |bytes: Vec<u8>, w: u32, h: u32| {
|
||
let px = bytes.as_chunks::<4>().0.iter().map(|c| egui::Color32::from_rgba_premultiplied(c[0], c[1], c[2], c[3])).collect();
|
||
Arc::new(egui::ColorImage::new([w as usize, h as usize], px))
|
||
};
|
||
if self.viewer_image.is_none()
|
||
&& let (Some((_, key, f)), Some(g)) = (&self.viewer_native, &self.gpu)
|
||
&& self.viewer_shown.as_ref().map(|s| s.1) == Some(*key)
|
||
{
|
||
if cfg!(target_arch = "wasm32") {
|
||
let ready = self.viewer_readback.1.lock().ok().and_then(|mut r| r.take_if(|r| r.0 == *key));
|
||
if let Some((_, img)) = ready {
|
||
self.viewer_image = Some(img);
|
||
} else if self.viewer_readback.0 != Some(*key) {
|
||
self.viewer_readback.0 = Some(*key);
|
||
let (slot, k, (w, h)) = (self.viewer_readback.1.clone(), *key, (f.width, f.height));
|
||
let ctx = self.frames.context();
|
||
g.read_display_async(f, move |bytes| {
|
||
if let (Some(b), Ok(mut s)) = (bytes, slot.lock()) {
|
||
*s = Some((k, to_image(b, w, h)));
|
||
}
|
||
if let Some(c) = ctx {
|
||
c.request_repaint();
|
||
}
|
||
});
|
||
}
|
||
} else if let Some(bytes) = g.read_display(f) {
|
||
self.viewer_image = Some(to_image(bytes, f.width, f.height));
|
||
}
|
||
}
|
||
self.viewer_image.clone()
|
||
}
|
||
|
||
/// The render scale used by the viewer right now.
|
||
pub fn viewer_scale(&self, zoom: f32, ppp: f32) -> f64 {
|
||
// The preview's Resolution (when not Auto) while it plays.
|
||
if self.playback.playing
|
||
&& let Some(f) = self.playback.res
|
||
{
|
||
let r = match f {
|
||
1 => state::Resolution::Full,
|
||
2 => state::Resolution::Half,
|
||
3 => state::Resolution::Third,
|
||
4 => state::Resolution::Quarter,
|
||
n => state::Resolution::Custom(n.min(40) as u8),
|
||
};
|
||
return r.scale(zoom, ppp);
|
||
}
|
||
// Auto follows the zoom alike while playing and paused, so frames cached while scrubbing
|
||
// play back, and frames cached by a preview stay on screen when it stops.
|
||
let full = self.ui.viewer.res.scale(zoom, ppp);
|
||
// Fast Previews ▸ Adaptive Resolution / Fast Draft: lower resolution while dragging.
|
||
let (_, k) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting || self.ui.viewer.property_interacting);
|
||
if k < 1.0 && self.ui.viewer.res == state::Resolution::Auto {
|
||
return full.min((full * 0.5).max(self.session.prefs.adaptive_limit()));
|
||
}
|
||
full
|
||
}
|
||
|
||
/// The cache key of viewer frame `frame` of `comp` at `scale` (see [`Self::frame_series`]).
|
||
pub fn frame_key(&self, comp: ItemId, frame: i64, scale: f64) -> FrameKey {
|
||
FrameKey { frame, ..self.frame_series(comp, scale) }
|
||
}
|
||
|
||
/// The key of frame 0 of `comp`'s viewer frames at `scale` (the content, view and render
|
||
/// options are the same for every frame: loops compute this once).
|
||
pub fn frame_series(&self, comp: ItemId, scale: f64) -> FrameKey {
|
||
FrameKey {
|
||
revision: self.session.revision,
|
||
content: self.frames.content_of(&self.session.project, self.session.revision, comp),
|
||
comp: comp.0,
|
||
frame: 0,
|
||
scale: (scale * 1000.0).round() as u32,
|
||
view: self.view_hash(comp),
|
||
opts: frames::opts_hash(&self.frame_opts(comp, scale)),
|
||
}
|
||
}
|
||
|
||
/// The frame series the viewer of `comp` shows now (its scale, view and options, for the
|
||
/// comp's current content): what the cache bars count.
|
||
pub fn shown_series(&self, comp: ItemId) -> FrameKey {
|
||
let scale = self.viewer_shown.as_ref().filter(|(_, k)| k.comp == comp.0).map_or(1000, |(_, k)| k.scale);
|
||
self.frame_series(comp, scale as f64 / 1000.0)
|
||
}
|
||
|
||
/// Hash of the comp viewer's 3D view camera (0 for the active camera view).
|
||
pub fn view_hash(&self, comp: ItemId) -> u64 {
|
||
use std::hash::{Hash, Hasher};
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
// The region of interest changes what is rendered too.
|
||
let roi = self.viewer_region(comp);
|
||
if let Some(r) = roi {
|
||
r.map(f64::to_bits).hash(&mut h);
|
||
}
|
||
let Some(cam) = self.session.view_camera(comp) else { return if roi.is_some() { h.finish() | 1 } else { 0 } };
|
||
for row in cam.view.0 {
|
||
for v in row {
|
||
v.to_bits().hash(&mut h);
|
||
}
|
||
}
|
||
cam.zoom.to_bits().hash(&mut h);
|
||
cam.ortho.hash(&mut h);
|
||
h.finish() | 1
|
||
}
|
||
|
||
/// The comp-space region viewer frames of `comp` cover: the region of interest, else the
|
||
/// Extended Viewer's area (frame plus pasteboard), else `None` (the whole frame).
|
||
pub fn viewer_region(&self, comp: ItemId) -> Option<[f64; 4]> {
|
||
if self.session.active_comp_id() != Some(comp) {
|
||
return None;
|
||
}
|
||
self.session.state.region_of_interest.or(self.ui.viewer.extended)
|
||
}
|
||
|
||
/// Request a prefetch frame render (no-op if cached/in flight).
|
||
pub fn request_frame(&self, comp: ItemId, frame: i64, scale: f64) {
|
||
self.request_frame_with(comp, frame, scale, false);
|
||
}
|
||
|
||
/// Request the frame on screen: rendered before any queued prefetch.
|
||
pub fn request_frame_urgent(&self, comp: ItemId, frame: i64, scale: f64) {
|
||
self.request_frame_with(comp, frame, scale, true);
|
||
}
|
||
|
||
/// Render options of viewer frames of `comp` at `scale`.
|
||
pub fn frame_opts(&self, comp: ItemId, scale: f64) -> RenderOpts {
|
||
let (draft, _) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting || self.ui.viewer.property_interacting);
|
||
let roi = self.viewer_region(comp);
|
||
RenderOpts {
|
||
scale,
|
||
motion_blur: true,
|
||
guides: true,
|
||
draft: self.ui.viewer.fast_preview || draft,
|
||
view: self.session.view_camera(comp),
|
||
roi,
|
||
backend: effectcraft_engine::render::Backend::Auto,
|
||
nested_switches: self.session.prefs.general.switches_affect_nested_comps,
|
||
draft_shadows: self.session.prefs.three_d.realtime_shadows,
|
||
proxy: Default::default(),
|
||
}
|
||
}
|
||
|
||
fn request_frame_with(&self, comp: ItemId, frame: i64, scale: f64, urgent: bool) {
|
||
let Some(c) = self.session.project.comp(comp) else { return };
|
||
let key = self.frame_key(comp, frame, scale);
|
||
let t = c.frame_rate.tick_of(frame);
|
||
let opts = self.frame_opts(comp, scale);
|
||
if urgent {
|
||
self.frames.request_urgent(&self.render_source(), key, comp, t, opts);
|
||
} else {
|
||
self.frames.request(&self.render_source(), key, comp, t, opts);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- playback
|
||
|
||
pub fn play(&mut self, now: f64) {
|
||
let sc = self.session.prefs.preview.current;
|
||
self.play_with(now, sc);
|
||
}
|
||
|
||
/// Start the preview of a Preview panel shortcut (its range, play-from, rate, skip,
|
||
/// resolution, loop, caching, full screen and include options).
|
||
pub fn play_with(&mut self, now: f64, sc: effectcraft_engine::preview::PreviewShortcut) {
|
||
let Some(c) = self.session.active_comp_arc() else { return };
|
||
let preset = self.session.prefs.preview.get(sc).clone();
|
||
let plan = effectcraft_engine::preview::plan(&preset, &c, self.session.time());
|
||
let fr = c.frame_rate;
|
||
if plan.video {
|
||
self.session.set_time(fr.tick_of(plan.first));
|
||
}
|
||
let restore_max = preset.full_screen.then_some(self.ui.maximized);
|
||
let viewer = panels::viewers::active_panel(self);
|
||
if preset.full_screen && self.ui.dock.contains(viewer) {
|
||
self.ui.maximized = Some(viewer);
|
||
}
|
||
self.playback = Playback {
|
||
playing: true,
|
||
start_wall: now,
|
||
start_time: fr.tick_of(plan.first),
|
||
start_frame: plan.first,
|
||
shown: 0,
|
||
waiting: false,
|
||
fps: plan.fps,
|
||
shortcut: sc,
|
||
plan: Some(plan),
|
||
caching: plan.cache_first && plan.video,
|
||
res: preset.resolution.factor(preset.custom_resolution),
|
||
overlays: preset.include_overlays,
|
||
layer_controls: preset.include_layer_controls,
|
||
restore_max,
|
||
audio_frame: (!plan.video).then_some(plan.first),
|
||
audio_held: false,
|
||
shown_at: Default::default(),
|
||
};
|
||
if !self.playback.caching {
|
||
if plan.video {
|
||
self.playback.audio_held = self.audio_plays();
|
||
} else {
|
||
self.start_audio(fr.tick_of(plan.first));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The preview includes audio at the comp's frame rate and the comp has something audible.
|
||
fn audio_plays(&self) -> bool {
|
||
let (Some(pl), Some(cid)) = (self.playback.plan, self.session.active_comp_id()) else { return false };
|
||
let Some(c) = self.session.project.comp(cid) else { return false };
|
||
// A preview at another frame rate plays slower or faster than real time: silent.
|
||
pl.audio && (pl.fps - c.frame_rate.as_f64()).abs() <= 0.01 && effectcraft_engine::render::audio::comp_has_audio(&self.session.project, cid)
|
||
}
|
||
|
||
/// Start audio preview from comp time `t` when [`Self::audio_plays`] and an output device
|
||
/// opens.
|
||
fn start_audio(&mut self, t: Tick) {
|
||
self.audio = None;
|
||
self.meter.clipped = [false; 2];
|
||
let (Some(pl), Some(cid)) = (self.playback.plan, self.session.active_comp_id()) else { return };
|
||
let Some(c) = self.session.project.comp_arc(cid).filter(|_| self.audio_plays()) else { return };
|
||
let out = audio::AudioOutput::from_prefs(&self.session.prefs);
|
||
let Some(dev) = self.hooks.audio_device.as_ref().and_then(|f| f(&out)) else { return };
|
||
let mix = self.session.prefs.audio.preview_sample_rate;
|
||
let fr = c.frame_rate;
|
||
match audio::AudioPlayback::start(dev, self.render_source(), cid, t, fr.tick_of(pl.start), fr.tick_of(pl.end + 1), pl.looping, mix) {
|
||
Ok(a) => self.audio = Some(a),
|
||
Err(e) => log::warn!("audio preview: {e}"),
|
||
}
|
||
}
|
||
|
||
/// Audio scrubbing: play the frame of `comp` at `t` (see [`audio::AudioScrub`]), opening the
|
||
/// output on first use. Silent comps and missing devices do nothing.
|
||
pub fn scrub_audio(&mut self, comp: ItemId, t: Tick, now: f64) {
|
||
if self.playback.playing || !effectcraft_engine::render::audio::comp_has_audio(&self.session.project, comp) {
|
||
return;
|
||
}
|
||
if self.scrub.is_none() {
|
||
let out = audio::AudioOutput::from_prefs(&self.session.prefs);
|
||
let Some(dev) = self.hooks.audio_device.as_ref().and_then(|f| f(&out)) else { return };
|
||
match audio::AudioScrub::open(dev, now) {
|
||
Ok(s) => self.scrub = Some(s),
|
||
Err(e) => {
|
||
log::warn!("audio scrubbing: {e}");
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
let src = self.render_source();
|
||
let mix = self.session.prefs.audio.preview_sample_rate;
|
||
if let Some(s) = &mut self.scrub {
|
||
s.play(&src, comp, t, mix, now);
|
||
}
|
||
}
|
||
|
||
pub fn stop(&mut self) {
|
||
let was = std::mem::replace(&mut self.playback.playing, false);
|
||
self.audio = None;
|
||
self.playback.caching = false;
|
||
if !was {
|
||
return;
|
||
}
|
||
if let Some(m) = self.playback.restore_max.take() {
|
||
self.ui.maximized = m;
|
||
}
|
||
let Some(pl) = self.playback.plan else { return };
|
||
let move_time = self.session.prefs.preview.get(self.playback.shortcut).move_time_to_preview_time;
|
||
if !move_time {
|
||
self.session.set_time(pl.origin);
|
||
} else if let Some(f) = self.playback.audio_frame
|
||
&& let Some(c) = self.session.active_comp()
|
||
{
|
||
let t = c.frame_rate.tick_of(f);
|
||
self.session.set_time(t);
|
||
}
|
||
}
|
||
|
||
pub fn toggle_play(&mut self, now: f64) {
|
||
let sc = self.session.prefs.preview.current;
|
||
self.toggle_play_with(now, sc);
|
||
}
|
||
|
||
/// A preview shortcut pressed: start its preview, or stop the running one. While Cache
|
||
/// Before Playback is still rendering, "If caching, play cached frames" plays what is
|
||
/// cached so far instead of stopping.
|
||
pub fn toggle_play_with(&mut self, now: f64, sc: effectcraft_engine::preview::PreviewShortcut) {
|
||
if !self.playback.playing {
|
||
self.play_with(now, sc);
|
||
return;
|
||
}
|
||
if self.playback.caching && self.session.prefs.preview.get(self.playback.shortcut).play_cached_frames && self.play_cached(now) {
|
||
return;
|
||
}
|
||
self.stop();
|
||
}
|
||
|
||
/// Cut the caching preview down to the frames cached from its first frame on and play them.
|
||
fn play_cached(&mut self, now: f64) -> bool {
|
||
let (Some(mut pl), Some(cid)) = (self.playback.plan, self.session.active_comp_id()) else { return false };
|
||
let scale = self.viewer_shown.as_ref().map(|(_, k)| k.scale as f64 / 1000.0).unwrap_or(1.0);
|
||
let series = self.frame_series(cid, scale);
|
||
let mut last = None;
|
||
let mut f = pl.first;
|
||
while f <= pl.end && self.frames.is_cached(&FrameKey { frame: f, ..series }) {
|
||
last = Some(f);
|
||
f += pl.step;
|
||
}
|
||
let Some(last) = last else { return false };
|
||
pl.end = last;
|
||
pl.start = pl.first;
|
||
self.playback.plan = Some(pl);
|
||
self.playback.caching = false;
|
||
self.playback.start_wall = now;
|
||
self.playback.start_frame = pl.first;
|
||
let t = self.session.active_comp().map(|c| c.frame_rate.tick_of(pl.first)).unwrap_or_default();
|
||
self.start_audio(t);
|
||
true
|
||
}
|
||
|
||
/// The viewer shows grids, guides and safe margins (hidden while a preview without
|
||
/// Include Overlays plays).
|
||
pub fn overlays_visible(&self) -> bool {
|
||
!self.playback.playing || self.playback.overlays
|
||
}
|
||
|
||
/// The viewer shows layer handles and paths (View ▸ Show Layer Controls, and Include Layer
|
||
/// Controls while a preview plays).
|
||
pub fn layer_controls_visible(&self) -> bool {
|
||
self.ui.viewer.show_layer_controls && (!self.playback.playing || self.playback.layer_controls)
|
||
}
|
||
|
||
/// RAM preview: advance at the preview's frame rate when frames are cached; otherwise hold
|
||
/// and keep rendering ahead (the green cache bar fills, then playback runs in real time).
|
||
fn advance_playback(&mut self, ctx: &egui::Context, scale: f64) {
|
||
let Some(cid) = self.session.active_comp_id() else { return };
|
||
let Some(c) = self.session.project.comp_arc(cid) else { return };
|
||
let now = ctx.input(|i| i.time);
|
||
let fr = c.frame_rate;
|
||
let plan = self.playback.plan.filter(|_| self.playback.playing);
|
||
let (wa, wb) = match plan {
|
||
Some(p) => (p.start, p.end),
|
||
None => (fr.frame_at(c.work_area.0), fr.frame_at(c.work_area.1 - c.frame_duration())),
|
||
};
|
||
let looping = plan.is_none_or(|p| p.looping);
|
||
let video = plan.is_none_or(|p| p.video);
|
||
// Prefetch ahead.
|
||
let cur = fr.frame_at(self.session.time());
|
||
// While paused (scrubbing, editing) the viewer's frame comes first: prefetch only a few
|
||
// frames, only once it is done, and not while edits keep coming (a layer dragged in the
|
||
// viewer): those frames would be stale at the next step and would hold the CPU and GPU
|
||
// the next viewer frame needs.
|
||
let ahead = if self.playback.playing {
|
||
(self.frames_parallelism() * 2).max(4) as i64
|
||
} else if self.frames.urgent_pending() {
|
||
0
|
||
} else if let Some(wait) = self.editing(now) {
|
||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
|
||
0
|
||
} else {
|
||
// Keep workers available for a newly edited viewer frame. Running obsolete
|
||
// background renders cannot be preempted by the priority queue.
|
||
self.frames_parallelism().saturating_sub(1).min(2) as i64
|
||
};
|
||
let mut queued = self.frames.inflight();
|
||
let series = self.frame_series(cid, scale);
|
||
if self.playback.caching
|
||
&& let Some(p) = plan
|
||
{
|
||
// Cache Before Playback: the range in play order, then start the clock. A range
|
||
// larger than the RAM preview budget is cut to the frames that fit (caching more
|
||
// would evict the first ones and never finish).
|
||
let mut all = true;
|
||
let n = (p.end - p.start) / p.step + 1;
|
||
let fit = self.frames_that_fit(&c, scale);
|
||
if n > fit {
|
||
let mut cut = p;
|
||
(cut.start, cut.end) = (p.first, (p.first + (fit - 1) * p.step).min(p.end));
|
||
self.playback.plan = Some(cut);
|
||
self.toast = Some((format!("Preview cut to {fit} of {n} frames: no more fit in the RAM preview (Settings ▸ Memory & CPU)"), now));
|
||
ctx.request_repaint();
|
||
return;
|
||
}
|
||
let order: Vec<i64> = (0..n).filter_map(|k| p.frame_after(p.first, k)).collect();
|
||
for f in order {
|
||
if self.frames.is_cached(&FrameKey { frame: f, ..series }) {
|
||
continue;
|
||
}
|
||
all = false;
|
||
if queued < ahead as usize {
|
||
self.request_frame(cid, f, scale);
|
||
queued += 1;
|
||
}
|
||
}
|
||
self.playback.waiting = !all;
|
||
if all {
|
||
self.playback.caching = false;
|
||
self.playback.start_wall = now;
|
||
self.playback.start_frame = p.first;
|
||
self.session.set_time(fr.tick_of(p.first));
|
||
self.start_audio(fr.tick_of(p.first));
|
||
}
|
||
ctx.request_repaint();
|
||
return;
|
||
}
|
||
let step = plan.map_or(1, |p| p.step);
|
||
for k in 0..ahead * 3 {
|
||
if queued >= ahead as usize || !video {
|
||
break;
|
||
}
|
||
let f = match plan {
|
||
Some(p) => match p.frame_after(cur, k) {
|
||
Some(f) => f,
|
||
None => break,
|
||
},
|
||
None => {
|
||
let mut f = cur + k;
|
||
if f > wb {
|
||
f = wa + (f - wb - 1).rem_euclid((wb - wa + 1).max(1));
|
||
}
|
||
f
|
||
}
|
||
};
|
||
if !self.frames.is_cached(&FrameKey { frame: f, ..series }) {
|
||
self.request_frame(cid, f, scale);
|
||
queued += 1;
|
||
}
|
||
}
|
||
if let Some(a) = &mut self.audio {
|
||
a.pump();
|
||
self.meter.update(a.feed.take_peaks(), now);
|
||
} else if let Some(s) = &mut self.scrub {
|
||
// Scrubbing: the meters follow the snippets; an idle second closes the output.
|
||
s.pump();
|
||
self.meter.update(s.feed.take_peaks(), now);
|
||
if now - s.last_used > 1.0 {
|
||
self.scrub = None;
|
||
}
|
||
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||
} else if self.meter.active() {
|
||
self.meter.update([0.0; 2], now);
|
||
ctx.request_repaint();
|
||
}
|
||
if !self.playback.playing {
|
||
self.cache_when_idle(ctx, &c, &series, now, (wa, wb));
|
||
return;
|
||
}
|
||
let snap = |f: i64| wa + (f - wa).div_euclid(step) * step;
|
||
// Held sound starts once the frames ahead are cached (see `Playback::audio_held`): the
|
||
// prefetch window when the silent preview keeps real time, else the rest of the preview.
|
||
let fit = self.frames_that_fit(&c, scale);
|
||
if self.playback.audio_held
|
||
&& let Some(p) = plan
|
||
&& ((self.playback.keeps_up(now) && self.cached_ahead(&p, &series, cur, ahead.min(fit))) || self.cached_ahead(&p, &series, cur, fit))
|
||
{
|
||
self.playback.audio_held = false;
|
||
self.playback.start_wall = now;
|
||
self.playback.start_frame = cur;
|
||
self.start_audio(fr.tick_of(cur));
|
||
}
|
||
// Audio clock drives playback: show the frame under the audible sample.
|
||
if let Some(a) = &self.audio {
|
||
if a.finished() {
|
||
if video {
|
||
self.session.set_time(fr.tick_of(wb));
|
||
} else {
|
||
self.playback.audio_frame = Some(wb);
|
||
}
|
||
self.stop();
|
||
return;
|
||
}
|
||
let target = snap(audio::frame_of_sample(a.clock(), a.rate, fr).clamp(wa, wb));
|
||
if !video {
|
||
self.playback.audio_frame = Some(target);
|
||
} else if self.frames.is_cached(&FrameKey { frame: target, ..series }) {
|
||
self.playback.waiting = false;
|
||
if target != cur {
|
||
self.session.set_time(fr.tick_of(target));
|
||
self.playback.frame_shown(now);
|
||
}
|
||
} else {
|
||
// Rendering can't keep up: rather than skip frames, the sound stops and every
|
||
// frame shows as it renders until the frames ahead are cached again.
|
||
self.audio = None;
|
||
self.playback.audio_held = true;
|
||
self.playback.waiting = true;
|
||
// The rate measured before the stall no longer says it keeps up.
|
||
self.playback.shown_at.clear();
|
||
self.playback.start_wall = now;
|
||
self.playback.start_frame = cur;
|
||
}
|
||
ctx.request_repaint();
|
||
return;
|
||
}
|
||
let elapsed = now - self.playback.start_wall;
|
||
let n = ((elapsed * self.playback.fps) / step as f64).floor() as i64;
|
||
let target = match plan {
|
||
Some(p) => p.frame_after(self.playback.start_frame, n),
|
||
None => {
|
||
let t = self.playback.start_frame + n;
|
||
if t <= wb {
|
||
Some(t)
|
||
} else if looping {
|
||
Some(wa + (t - wa).rem_euclid((wb - wa + 1).max(1)))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
};
|
||
let Some(target) = target else {
|
||
if video {
|
||
self.session.set_time(fr.tick_of(wb));
|
||
} else {
|
||
self.playback.audio_frame = Some(wb);
|
||
}
|
||
self.stop();
|
||
return;
|
||
};
|
||
if !video {
|
||
// Audio-only preview without an audio device: the clock runs, the time stays.
|
||
self.playback.audio_frame = Some(target);
|
||
ctx.request_repaint();
|
||
return;
|
||
}
|
||
if self.frames.is_cached(&FrameKey { frame: target, ..series }) {
|
||
self.playback.waiting = false;
|
||
if target != cur {
|
||
self.session.set_time(fr.tick_of(target));
|
||
self.playback.frame_shown(now);
|
||
}
|
||
} else {
|
||
// Not cached yet: hold the clock at the current frame (cache first, then play).
|
||
self.playback.waiting = true;
|
||
let next = plan.and_then(|p| p.frame_after(cur, 1)).unwrap_or(if cur + 1 > wb { wa } else { cur + 1 });
|
||
if self.frames.is_cached(&FrameKey { frame: next, ..series }) {
|
||
self.session.set_time(fr.tick_of(next));
|
||
}
|
||
self.playback.start_wall = now;
|
||
self.playback.start_frame = fr.frame_at(self.session.time());
|
||
}
|
||
ctx.request_repaint();
|
||
}
|
||
|
||
/// While the project keeps changing (an edit within [`EDIT_QUIET`] seconds), how long until
|
||
/// it counts as settled.
|
||
fn editing(&mut self, now: f64) -> Option<f64> {
|
||
if self.last_edit.1 != self.session.revision {
|
||
self.last_edit = (now, self.session.revision);
|
||
}
|
||
let left = EDIT_QUIET - (now - self.last_edit.0);
|
||
(left > 0.0).then_some(left)
|
||
}
|
||
|
||
/// Composition ▸ Preview ▸ Cache Frames When Idle: after a second without input or edits,
|
||
/// render the work area's frames (from the current time on, then from its start) in the
|
||
/// background, a few at a time, until it is cached or the RAM preview budget is full.
|
||
fn cache_when_idle(&mut self, ctx: &egui::Context, comp: &effectcraft_engine::project::Comp, series: &FrameKey, now: f64, (wa, wb): (i64, i64)) {
|
||
let active = ctx.input(|i| !i.events.is_empty() || i.pointer.any_down() || i.pointer.is_moving());
|
||
if active || self.last_activity.1 != self.session.revision {
|
||
self.last_activity = (now, self.session.revision);
|
||
}
|
||
if !self.ui.cache_when_idle || self.frames.urgent_pending() {
|
||
return;
|
||
}
|
||
let quiet = now - self.last_activity.0;
|
||
if quiet < 1.0 {
|
||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(1.0 - quiet));
|
||
return;
|
||
}
|
||
// An empty work area has nothing to cache.
|
||
let n = wb - wa + 1;
|
||
if n < 1 {
|
||
return;
|
||
}
|
||
let cur = comp.frame_rate.frame_at(self.session.time()).clamp(wa, wb);
|
||
let fit = self.frames_that_fit(comp, series.scale as f64 / 1000.0).min(n);
|
||
let slots = (self.frames_parallelism() / 2).max(1);
|
||
let mut queued = self.frames.inflight();
|
||
let scale = series.scale as f64 / 1000.0;
|
||
let mut pending = false;
|
||
for k in 0..fit {
|
||
let f = wa + (cur - wa + k).rem_euclid(n);
|
||
if self.frames.is_cached(&FrameKey { frame: f, ..*series }) {
|
||
continue;
|
||
}
|
||
pending = true;
|
||
if queued >= slots {
|
||
break;
|
||
}
|
||
self.request_frame(ItemId(series.comp), f, scale);
|
||
queued += 1;
|
||
}
|
||
if pending {
|
||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||
}
|
||
}
|
||
|
||
/// How many viewer frames of `comp` at `scale` the RAM preview budget holds (90 % of it,
|
||
/// at least one).
|
||
/// The frames of `plan` from `cur` to its end, or as many as fit in the RAM preview, are
|
||
/// cached: sound can play from `cur` in real time.
|
||
fn cached_ahead(&self, plan: &effectcraft_engine::preview::PreviewPlan, series: &FrameKey, cur: i64, fit: i64) -> bool {
|
||
let cached = self.frames.cached_frames(series);
|
||
(0..fit).map_while(|k| plan.frame_after(cur, k)).take_while(|f| *f >= cur).all(|f| cached.binary_search(&f).is_ok())
|
||
}
|
||
|
||
fn frames_that_fit(&self, comp: &effectcraft_engine::project::Comp, scale: f64) -> i64 {
|
||
let side = |n: u32| (n as f64 * scale).ceil().max(1.0);
|
||
let frame = side(comp.width) * side(comp.height) * 4.0;
|
||
((self.frames.budget() as f64 * 0.9 / frame) as i64).max(1)
|
||
}
|
||
|
||
fn frames_parallelism(&self) -> usize {
|
||
if cfg!(target_arch = "wasm32") {
|
||
// frame workers render one frame each; without them frames render one at a time
|
||
// on the UI thread (`Frames::pump`)
|
||
return self.frames.remote_slots().max(1);
|
||
}
|
||
self.frames.parallelism()
|
||
}
|
||
|
||
// ---------------------------------------------------------------- control channel
|
||
|
||
pub(crate) fn raise_for_control(&mut self, ctx: &egui::Context) {
|
||
ctx.request_repaint();
|
||
}
|
||
|
||
fn drain_control(&mut self, ctx: &egui::Context) {
|
||
let Some(rx) = self.control_rx.take() else { return };
|
||
let now = ctx.input(|i| i.time);
|
||
let mut reqs: Vec<(ControlRequest, f64)> = std::mem::take(&mut self.deferred);
|
||
while let Ok(req) = rx.try_recv() {
|
||
reqs.push((req, now + 3.0));
|
||
}
|
||
for (req, deadline) in reqs {
|
||
let reply = req.reply.clone();
|
||
match control::handle(self, ctx, &req) {
|
||
control::Outcome::Done(v) => {
|
||
let _ = reply.send(v);
|
||
}
|
||
control::Outcome::Retry(msg) => {
|
||
if now < deadline {
|
||
self.deferred.push((req, deadline));
|
||
ctx.request_repaint();
|
||
} else {
|
||
let _ = reply.send(json!({"ok": false, "error": msg}));
|
||
}
|
||
}
|
||
control::Outcome::AfterInput => self.input_waiters.push(reply),
|
||
control::Outcome::AwaitJobs(w) => {
|
||
self.job_waiters.push((w, reply));
|
||
ctx.request_repaint();
|
||
}
|
||
control::Outcome::Screenshot { path, crop } => {
|
||
// A covered or minimized window presents no frames (macOS skips its redraws), so
|
||
// a screenshot would never arrive and nothing would tick its timeout.
|
||
if ctx.input(|i| i.viewport().occluded == Some(true) || i.viewport().minimized == Some(true)) {
|
||
let _ = reply.send(json!({"ok": false, "error": "window is covered or minimized: call ui.focus first (render.frame renders comp pixels without the window)"}));
|
||
continue;
|
||
}
|
||
let token = self.next_token;
|
||
self.next_token += 1;
|
||
let settle = now + 0.35;
|
||
self.queued_screenshots.push((token, settle, 0));
|
||
self.pending_screenshots.push((token, path, crop, reply, settle + SCREENSHOT_TIMEOUT_S));
|
||
}
|
||
}
|
||
}
|
||
self.control_rx = Some(rx);
|
||
self.finish_job_waiters(ctx);
|
||
}
|
||
|
||
/// Reply to the control requests whose offloaded jobs ended.
|
||
fn finish_job_waiters(&mut self, ctx: &egui::Context) {
|
||
if self.job_waiters.is_empty() {
|
||
return;
|
||
}
|
||
self.session.poll_render();
|
||
self.session.poll_offload();
|
||
let waiters = std::mem::take(&mut self.job_waiters);
|
||
for (w, reply) in waiters {
|
||
if w.pending(self) {
|
||
self.job_waiters.push((w, reply));
|
||
} else {
|
||
let v = w.finish(self);
|
||
let _ = reply.send(v);
|
||
}
|
||
}
|
||
if !self.job_waiters.is_empty() {
|
||
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||
}
|
||
}
|
||
|
||
fn issue_screenshots(&mut self, ctx: &egui::Context) {
|
||
let now = ctx.input(|i| i.time);
|
||
let mut any = false;
|
||
let busy = self.frames.inflight() > 0;
|
||
self.queued_screenshots.retain_mut(|(token, at, frames)| {
|
||
*frames += 1;
|
||
any = true;
|
||
// Wait for the viewer's frame to finish rendering (bounded by the timeout below).
|
||
if now >= *at && *frames >= 3 && (!busy || now > *at + 2.5) {
|
||
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token)));
|
||
false
|
||
} else {
|
||
true
|
||
}
|
||
});
|
||
self.pending_screenshots.retain(|(.., reply, deadline)| {
|
||
if now > *deadline {
|
||
let _ = reply.send(json!({"ok": false, "error": "no frame was presented (window hidden or display asleep)"}));
|
||
false
|
||
} else {
|
||
true
|
||
}
|
||
});
|
||
if any {
|
||
ctx.request_repaint();
|
||
} else if !self.pending_screenshots.is_empty() {
|
||
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
||
}
|
||
}
|
||
|
||
fn collect_screenshots(&mut self, ctx: &egui::Context) {
|
||
if self.pending_screenshots.is_empty() {
|
||
return;
|
||
}
|
||
let events: Vec<_> = ctx.input(|i| {
|
||
i.raw
|
||
.events
|
||
.iter()
|
||
.filter_map(|e| match e {
|
||
egui::Event::Screenshot { user_data, image, .. } => {
|
||
let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::<u64>()).copied()?;
|
||
Some((token, image.clone()))
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
});
|
||
for (token, image) in events {
|
||
if let Some(i) = self.pending_screenshots.iter().position(|(t, ..)| *t == token) {
|
||
let (_, path, crop, reply, _) = self.pending_screenshots.remove(i);
|
||
let r = control::save_screenshot(ctx, &image, path.as_deref(), crop);
|
||
let _ = reply.send(r);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- frame
|
||
|
||
fn handle_events(&mut self, ctx: &egui::Context) {
|
||
for ev in self.session.drain_events() {
|
||
match ev {
|
||
effectcraft_engine::Event::OpenComp(c) => {
|
||
panels::viewers::on_open_comp(self, c);
|
||
self.ui.timeline.pps = None;
|
||
}
|
||
effectcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, ctx.input(|i| i.time))),
|
||
effectcraft_engine::Event::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
|
||
// Frames are keyed by content: an edit keeps those of comps it doesn't touch.
|
||
effectcraft_engine::Event::ProjectChanged { .. } => {}
|
||
effectcraft_engine::Event::Frontend { command, params } => {
|
||
if let Err(e) = crate::menus::frontend(self, ctx, &command, params) {
|
||
self.ui.status = e;
|
||
}
|
||
}
|
||
effectcraft_engine::Event::PurgeCaches => self.frames.clear(),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn frame(&mut self, ui: &mut egui::Ui) {
|
||
let ctx = ui.ctx().clone();
|
||
self.apply_gpu_failure(&ctx);
|
||
self.run_dialog_answers();
|
||
self.auto.begin_frame();
|
||
self.frames.set_context(&ctx);
|
||
if self.session.render_job.is_some() {
|
||
self.session.poll_render();
|
||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||
}
|
||
if self.session.track_job.is_some() {
|
||
self.session.poll_track();
|
||
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||
}
|
||
if self.session.mask_job.is_some() {
|
||
self.session.poll_mask_track();
|
||
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||
}
|
||
// Background tasks (Content-Aware Fill, Scene Edit Detection, imports).
|
||
if !self.session.tasks.is_empty() {
|
||
self.session.poll_jobs();
|
||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||
}
|
||
// Analyses running in a worker (the web app).
|
||
if !self.session.offloaded.is_empty() {
|
||
self.session.poll_offload();
|
||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||
}
|
||
// Warp Stabilizer: finish analyses and start queued (re-)analyses in the background.
|
||
if self.session.warp_job.is_some() || !self.session.warp_pending.is_empty() {
|
||
self.session.poll_warp(true);
|
||
self.session.poll_roto(true);
|
||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||
}
|
||
// 3D Camera Tracker: likewise (tracking, then solving).
|
||
if self.session.camera_job.is_some() || !self.session.camera_pending.is_empty() {
|
||
self.session.poll_camera(true);
|
||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||
}
|
||
self.apply_prefs(&ctx);
|
||
self.handle_events(&ctx);
|
||
panels::unsaved::on_close_requested(self, &ctx);
|
||
let title = panels::unsaved::window_title(self);
|
||
if title != self.window_title {
|
||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
|
||
self.window_title = title;
|
||
}
|
||
self.tick_autosave(&ctx);
|
||
prefs_live::frame(self, &ctx);
|
||
if let Some(rx) = self.command_inbox.take() {
|
||
while let Ok(id) = rx.try_recv() {
|
||
if let Err(e) = menus::invoke(self, &ctx, &id, json!({})) {
|
||
self.ui.status = e;
|
||
}
|
||
}
|
||
self.command_inbox = Some(rx);
|
||
}
|
||
let tab_had_widget_focus = ctx.egui_wants_keyboard_input();
|
||
menus::handle_shortcuts(self, &ctx);
|
||
if !ctx.input(|i| i.pointer.primary_down()) {
|
||
self.ui.viewer.property_interacting = false;
|
||
}
|
||
let t = self.tokens;
|
||
let full = ui.max_rect();
|
||
if self.dialog.is_some() {
|
||
ui.disable();
|
||
}
|
||
ui.painter().rect_filled(full, 0.0, t.app_bg);
|
||
let mut top = full.min.y;
|
||
if self.ui.show_menu_bar {
|
||
let mb = egui::Rect::from_min_size(full.min, egui::vec2(full.width(), 24.0));
|
||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(mb));
|
||
child.painter().rect_filled(mb, 0.0, t.header_bg);
|
||
menus::menu_bar(self, &mut child);
|
||
top = mb.max.y;
|
||
}
|
||
let header_h = 40.0;
|
||
let header = egui::Rect::from_min_size(egui::pos2(full.min.x, top), egui::vec2(full.width(), header_h));
|
||
header::show(self, ui, header);
|
||
let body = egui::Rect::from_min_max(egui::pos2(full.min.x + 4.0, header.max.y + 2.0), egui::pos2(full.max.x - 4.0, full.max.y - 4.0));
|
||
self.dock_area(ui, body);
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
prefs_live::dropped_files(self, ui, full);
|
||
if !tab_had_widget_focus {
|
||
menus::handle_tab_shortcut(self, &ctx);
|
||
}
|
||
panels::precomp::mini_flowchart(self, &ctx);
|
||
panels::home::capture_thumbnail(self);
|
||
panels::dialogs::show(self, &ctx);
|
||
panels::scriptui_view::sync_panels(self);
|
||
panels::scriptui_view::show_windows(self, &ctx);
|
||
panels::learn::coach(self, &ctx);
|
||
let lift = panels::media_panels::import_card(self, ui, full);
|
||
self.draw_toast(ui, full, lift);
|
||
if self.session.config.is_some() && self.workspace_store.has_changes(&self.ui) && !ctx.input(|i| i.pointer.any_down()) {
|
||
if self.workspace_store.due(ctx.input(|i| i.time)) {
|
||
self.save_workspace_state();
|
||
}
|
||
if self.workspace_store.has_changes(&self.ui) {
|
||
ctx.request_repaint_after(std::time::Duration::from_secs(1));
|
||
}
|
||
}
|
||
}
|
||
|
||
fn save_workspace_state(&mut self) {
|
||
if let Err(error) = self.workspace_store.save(self.session.config.as_deref(), &self.ui) {
|
||
if self.workspace_store.error.as_ref() != Some(&error) {
|
||
log::warn!("{error}");
|
||
self.ui.status = error.clone();
|
||
}
|
||
self.workspace_store.error = Some(error);
|
||
}
|
||
}
|
||
|
||
/// The status message or the latest toast, bottom left (`lift` points higher while the
|
||
/// Importing card is there).
|
||
fn draw_toast(&mut self, ui: &mut egui::Ui, full: egui::Rect, lift: f32) {
|
||
let now = ui.input(|i| i.time);
|
||
let msg = if !self.ui.status.is_empty() {
|
||
Some(self.ui.status.clone())
|
||
} else {
|
||
self.toast.as_ref().filter(|(_, at)| now - at < 4.0).map(|(m, _)| m.clone())
|
||
};
|
||
if let Some(m) = msg {
|
||
let t = &self.tokens;
|
||
let galley = ui.painter().layout_no_wrap(m.clone(), Tokens::ui(12.0), t.text);
|
||
let r = egui::Rect::from_min_size(egui::pos2(full.min.x + 16.0, full.max.y - 44.0 - lift), galley.size() + egui::vec2(24.0, 14.0));
|
||
ui.painter().rect_filled(r, 6.0, t.panel_bg);
|
||
ui.painter().rect_stroke(r, 6.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
|
||
ui.painter().galley(r.min + egui::vec2(12.0, 7.0), galley, t.text);
|
||
self.auto.add("toast", r, &m);
|
||
let resp = ui.interact(r, egui::Id::new("toast"), egui::Sense::click());
|
||
if resp.clicked() {
|
||
self.ui.status.clear();
|
||
self.toast = None;
|
||
}
|
||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||
if !self.ui.status.is_empty() {
|
||
let since: f64 = ui.data(|d| d.get_temp(egui::Id::new("status-since")).unwrap_or(now));
|
||
ui.data_mut(|d| d.insert_temp(egui::Id::new("status-since"), since));
|
||
if now - since > 5.0 {
|
||
self.ui.status.clear();
|
||
ui.data_mut(|d| d.remove::<f64>(egui::Id::new("status-since")));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
impl EffectcraftApp {
|
||
/// Auto-save a dirty project when the interval has passed (Settings ▸ Project ▸ Auto-Save).
|
||
fn tick_autosave(&mut self, ctx: &egui::Context) {
|
||
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0);
|
||
if let Some(Err(e)) = self.session.autosave_tick(now) {
|
||
self.ui.status = e.to_string();
|
||
}
|
||
if self.session.prefs.auto_save.enabled {
|
||
ctx.request_repaint_after(std::time::Duration::from_secs(30));
|
||
}
|
||
// File ▸ Watch Folder: look for new projects every 10 seconds.
|
||
if self.session.state.watch_folder.is_some() {
|
||
let key = egui::Id::new("watchFolder.lastPoll");
|
||
let last: f64 = ctx.data(|d| d.get_temp(key)).unwrap_or(0.0);
|
||
if now - last >= 10.0 {
|
||
ctx.data_mut(|d| d.insert_temp(key, now));
|
||
match self.session.execute("file.watchFolder.poll", serde_json::json!({})) {
|
||
Ok(r) => {
|
||
if let Some(n) = r["rendered"].as_array().map(Vec::len).filter(|n| *n > 0) {
|
||
self.ui.status = format!("Watch Folder: rendered {n} project(s)");
|
||
}
|
||
}
|
||
Err(e) => self.ui.status = e.to_string(),
|
||
}
|
||
}
|
||
ctx.request_repaint_after(std::time::Duration::from_secs(10));
|
||
}
|
||
self.tick_footage_reload(ctx, now);
|
||
}
|
||
|
||
/// Settings ▸ Import ▸ Reload Footage Changed on Disk: every two seconds, and as soon as the
|
||
/// window comes back to the front (after saving in another app), stamp the footage files on a
|
||
/// worker thread and reload the ones that changed.
|
||
fn tick_footage_reload(&mut self, ctx: &egui::Context, now: f64) {
|
||
use effectcraft_engine::footage_reload;
|
||
if let Some(Err(e)) = footage_reload::poll_scan(&mut self.session) {
|
||
self.ui.status = e.to_string();
|
||
}
|
||
let (last_key, focus_key) = (egui::Id::new("footageReload.lastScan"), egui::Id::new("footageReload.focused"));
|
||
let focused = ctx.input(|i| i.focused);
|
||
let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused);
|
||
ctx.data_mut(|d| d.insert_temp(focus_key, focused));
|
||
if self.session.prefs.import.auto_reload_footage == "off" || self.session.project.items.is_empty() {
|
||
return;
|
||
}
|
||
let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(0.0);
|
||
if now - last >= 2.0 || (focused && !was_focused) {
|
||
ctx.data_mut(|d| d.insert_temp(last_key, now));
|
||
footage_reload::start_scan(&mut self.session);
|
||
}
|
||
if self.session.footage_scan.is_some() {
|
||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||
} else {
|
||
ctx.request_repaint_after(std::time::Duration::from_secs(2));
|
||
}
|
||
}
|
||
|
||
/// Take the pending synthetic input (from `ui.click`, `ui.key`, …). Hosts that don't call
|
||
/// [`eframe::App::raw_input_hook`] (the headless test harness) feed these in themselves.
|
||
pub fn take_synthetic_input(&mut self) -> Vec<egui::Event> {
|
||
std::mem::take(&mut self.synthetic)
|
||
}
|
||
}
|
||
|
||
impl eframe::App for EffectcraftApp {
|
||
fn on_exit(&mut self) {
|
||
self.save_workspace_state();
|
||
// Clean exit: no crash recovery next launch (unless the graphics device was lost with
|
||
// unsaved changes, which are in the auto-save it offers).
|
||
if self.device_lost_close != Some(true) {
|
||
self.session.end_recovery();
|
||
}
|
||
}
|
||
|
||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
||
let injected = !self.synthetic.is_empty();
|
||
if injected {
|
||
// Pointer events go one per frame so egui sees press → moves → release as a real drag.
|
||
let pointer = |e: &egui::Event| matches!(e, egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. });
|
||
let n = if pointer(&self.synthetic[0]) {
|
||
1
|
||
} else {
|
||
self.synthetic
|
||
.iter()
|
||
.position(|e| pointer(e) || matches!(e, egui::Event::Key { pressed: false, .. }))
|
||
.map_or(self.synthetic.len(), |i| if pointer(&self.synthetic[i]) { i.max(1) } else { i + 1 })
|
||
};
|
||
raw_input.events.extend(self.synthetic.drain(..n));
|
||
}
|
||
if pointer_wrap::prepare_input(ctx, raw_input, injected) {
|
||
self.session.history.merge_key = None;
|
||
}
|
||
}
|
||
|
||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||
self.apply_gpu_failure(ctx);
|
||
// The CJK fallback fonts depend on the interface language, so they follow a change of
|
||
// Settings ▸ General ▸ Language instead of only the first frame.
|
||
let language = crate::i18n::language(self);
|
||
crate::i18n::set_current(language);
|
||
if !self.styled || self.styled_language != language {
|
||
theme::install(ctx, &self.tokens, language);
|
||
self.styled_language = language;
|
||
if !self.styled {
|
||
fit_window(ctx);
|
||
self.styled = true;
|
||
}
|
||
ctx.request_repaint();
|
||
} else {
|
||
self.fonts_ready = true;
|
||
}
|
||
let now = ctx.input(|i| i.time);
|
||
let dt = (now - self.last_time) as f32;
|
||
if dt > 0.0 {
|
||
self.fps = self.fps * 0.9 + (1.0 / dt).min(480.0) * 0.1;
|
||
}
|
||
self.last_time = now;
|
||
self.drain_control(ctx);
|
||
if !self.synthetic.is_empty() {
|
||
ctx.request_repaint();
|
||
} else if !self.input_waiters.is_empty() {
|
||
for w in self.input_waiters.drain(..) {
|
||
let _ = w.send(json!({"ok": true, "result": null}));
|
||
}
|
||
}
|
||
self.issue_screenshots(ctx);
|
||
self.collect_screenshots(ctx);
|
||
}
|
||
|
||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||
if self.gpu_failure_applied == Some(true) {
|
||
self.close_after_device_loss(ui.ctx());
|
||
return;
|
||
}
|
||
self.init_gpu(frame);
|
||
if !self.fonts_ready {
|
||
ui.ctx().request_repaint();
|
||
return;
|
||
}
|
||
self.frame(ui);
|
||
let ctx = ui.ctx().clone();
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pointer_wrap::finish_native(&ctx, frame);
|
||
// wasm32: no frame threads; render queued frames now, between UI frames.
|
||
if cfg!(target_arch = "wasm32") && self.frames.pump(std::time::Duration::from_millis(if self.playback.playing { 24 } else { 40 })) {
|
||
ctx.request_repaint();
|
||
}
|
||
self.last_ui_time = ctx.input(|i| i.time);
|
||
if !self.synthetic.is_empty() {
|
||
ctx.request_repaint();
|
||
} else if !self.input_waiters.is_empty() {
|
||
ctx.request_repaint();
|
||
for w in self.input_waiters.drain(..) {
|
||
let _ = w.send(json!({"ok": true, "result": null}));
|
||
}
|
||
}
|
||
if self.frames.inflight() > 0 {
|
||
ctx.request_repaint_after(std::time::Duration::from_millis(30));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Advance playback with the viewer's scale (called by the viewer panel each frame).
|
||
pub(crate) fn tick_playback(app: &mut EffectcraftApp, ctx: &egui::Context, scale: f64) {
|
||
app.advance_playback(ctx, scale);
|
||
}
|
||
|
||
/// Maximize a first window that doesn't fit the screen, which fits it to the space beside the
|
||
/// taskbar or dock. The 1680 × 1020 window on a smaller screen is only shrunk to the monitor's
|
||
/// size, so with its title bar it ran under the taskbar and cut menus off at the bottom of the
|
||
/// screen (#269).
|
||
fn fit_window(ctx: &egui::Context) {
|
||
let (monitor, window) = ctx.input(|i| (i.viewport().monitor_size, i.viewport().outer_rect.or(i.viewport().inner_rect)));
|
||
if let (Some(monitor), Some(window)) = (monitor, window)
|
||
&& overflows_screen(window.size(), monitor)
|
||
{
|
||
ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(true));
|
||
}
|
||
}
|
||
|
||
/// Whether a window `size` leaves no room on a `monitor` for a taskbar or dock (points).
|
||
fn overflows_screen(size: egui::Vec2, monitor: egui::Vec2) -> bool {
|
||
const TASKBAR: f32 = 64.0;
|
||
size.x > monitor.x || size.y > monitor.y - TASKBAR
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod fit_window_tests {
|
||
use super::overflows_screen;
|
||
use egui::vec2;
|
||
|
||
#[test]
|
||
fn a_window_taller_than_the_screen_beside_its_taskbar_overflows() {
|
||
// The default window on a 1366 × 768 laptop (shrunk to the monitor) and on 1080p.
|
||
assert!(overflows_screen(vec2(1366.0, 799.0), vec2(1366.0, 768.0)));
|
||
assert!(overflows_screen(vec2(1680.0, 1051.0), vec2(1920.0, 1080.0)));
|
||
assert!(!overflows_screen(vec2(1680.0, 1051.0), vec2(2560.0, 1440.0)));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod gpu_failure_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn host_failure_preserves_document_undo_and_cpu_pixels_and_detaches_late_readbacks() {
|
||
let mut session = Session::default();
|
||
let comp = ItemId(session.execute("comp.new", json!({"name": "Recovery", "width": 4, "height": 4, "duration": 1})).unwrap()["comp"].as_u64().unwrap());
|
||
session.execute("layer.newSolid", json!({"comp": comp.0, "color": "#ff0000"})).unwrap();
|
||
let before = session.render(comp, Tick::ZERO, RenderOpts::default());
|
||
let project = session.project.clone();
|
||
let revision = session.revision;
|
||
let prefs = serde_json::to_value(&session.prefs).unwrap();
|
||
let mut app = EffectcraftApp::new(session);
|
||
let ctx = egui::Context::default();
|
||
let bridge = gpu_failure::GpuFailureBridge::new(&ctx);
|
||
app.set_gpu_failure_bridge(bridge.clone());
|
||
let late_slot = app.viewer_readback.1.clone();
|
||
let key = FrameKey { revision, content: 1, comp: comp.0, frame: 0, scale: 1000, view: 0, opts: 0 };
|
||
app.viewer_readback.0 = Some(key);
|
||
bridge.report("synthetic host device failure", true);
|
||
app.apply_gpu_failure(&ctx);
|
||
assert!(Arc::ptr_eq(&project, &app.session.project));
|
||
assert_eq!(app.session.revision, revision);
|
||
assert_eq!(serde_json::to_value(&app.session.prefs).unwrap(), prefs);
|
||
assert!(app.gpu_checked, "failed device cannot be initialized again next frame");
|
||
assert!(app.session.accel.is_none());
|
||
assert!(app.ui.status.contains("presentation requires restarting"));
|
||
assert_eq!(app.session.render(comp, Tick::ZERO, RenderOpts::default()).data, before.data);
|
||
*late_slot.lock().unwrap() = Some((key, Arc::new(egui::ColorImage::new([1, 1], vec![egui::Color32::RED]))));
|
||
assert!(app.viewer_readback.0.is_none());
|
||
assert!(app.viewer_readback.1.lock().unwrap().is_none(), "a late completion cannot refill the replacement slot");
|
||
app.session.execute("edit.undo", json!({})).unwrap();
|
||
assert!(app.session.project.comp(comp).unwrap().layers.is_empty());
|
||
app.session.execute("edit.redo", json!({})).unwrap();
|
||
assert_eq!(app.session.render(comp, Tick::ZERO, RenderOpts::default()).data, before.data);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod appearance_tests {
|
||
use super::*;
|
||
use crate::theme::ThemeKind;
|
||
|
||
fn app() -> EffectcraftApp {
|
||
EffectcraftApp::new(effectcraft_engine::Session::default())
|
||
}
|
||
|
||
/// The Tools bar's appearance button: Sync with System, Light, Dark, then Sync with System
|
||
/// again, showing the saved theme of each family without losing either choice.
|
||
#[test]
|
||
fn appearance_button_cycles_modes_without_losing_theme_choices() {
|
||
let ctx = egui::Context::default();
|
||
let mut app = app();
|
||
app.hooks.system_theme = Some(Box::new(|_| Some(egui::Theme::Dark)));
|
||
app.set_pref("appearance.darkTheme", json!("darker")).unwrap();
|
||
app.apply_prefs(&ctx);
|
||
assert_eq!(app.ui.theme, ThemeKind::Darker);
|
||
for (mode, shown) in [("auto", ThemeKind::Darker), ("light", ThemeKind::Light), ("dark", ThemeKind::Darker), ("auto", ThemeKind::Darker)] {
|
||
menus::invoke(&mut app, &ctx, "view.theme.toggle", json!({})).unwrap();
|
||
assert_eq!(app.session.prefs.appearance.appearance_mode, mode);
|
||
assert_eq!(app.ui.theme, shown, "{mode}");
|
||
assert_eq!(app.tokens.kind, shown);
|
||
assert_eq!(app.session.prefs.appearance.dark_theme, "darker");
|
||
assert_eq!(app.session.prefs.appearance.light_theme, "light");
|
||
}
|
||
}
|
||
|
||
/// Sync with System follows the host's answer (the Linux portal) when it changes, and falls
|
||
/// back to Dark when nothing reports a system appearance.
|
||
#[test]
|
||
fn auto_follows_a_stubbed_system_theme() {
|
||
use std::sync::{Arc, Mutex};
|
||
let ctx = egui::Context::default();
|
||
let mut app = app();
|
||
let system = Arc::new(Mutex::new(Some(egui::Theme::Light)));
|
||
let read = Arc::clone(&system);
|
||
app.hooks.system_theme = Some(Box::new(move |_| *read.lock().unwrap_or_else(std::sync::PoisonError::into_inner)));
|
||
app.set_pref("appearance.appearanceMode", json!("auto")).unwrap();
|
||
app.apply_prefs(&ctx);
|
||
assert_eq!(app.ui.theme, ThemeKind::Light);
|
||
*system.lock().unwrap() = Some(egui::Theme::Dark);
|
||
app.apply_prefs(&ctx);
|
||
assert_eq!(app.ui.theme, ThemeKind::Dark, "a system change applies without a settings change");
|
||
*system.lock().unwrap() = None;
|
||
app.apply_prefs(&ctx);
|
||
assert_eq!(app.ui.theme, ThemeKind::Dark);
|
||
// A fixed mode ignores the system.
|
||
*system.lock().unwrap() = Some(egui::Theme::Light);
|
||
app.set_pref("appearance.appearanceMode", json!("dark")).unwrap();
|
||
app.apply_prefs(&ctx);
|
||
assert_eq!(app.ui.theme, ThemeKind::Dark);
|
||
}
|
||
|
||
/// Sync with System leaves egui's theme preference at System, so egui never gives the native
|
||
/// window an appearance of its own (on macOS that stops system appearance changes reaching
|
||
/// the app, #642); egui's light and dark styles both carry the resolved theme. Fixed modes
|
||
/// pin the window to their family.
|
||
#[test]
|
||
fn auto_mode_leaves_the_native_window_following_the_system() {
|
||
let ctx = egui::Context::default();
|
||
let mut app = app();
|
||
app.hooks.system_theme = Some(Box::new(|_| Some(egui::Theme::Light)));
|
||
app.set_pref("appearance.appearanceMode", json!("auto")).unwrap();
|
||
app.apply_prefs(&ctx);
|
||
assert_eq!(app.ui.theme, ThemeKind::Light);
|
||
assert_eq!(ctx.options(|o| o.theme_preference), egui::ThemePreference::System);
|
||
for theme in [egui::Theme::Light, egui::Theme::Dark] {
|
||
assert_eq!(ctx.style_of(theme).visuals.panel_fill, app.tokens.panel_bg, "{theme:?}");
|
||
}
|
||
// egui asks the window to follow the system, never to take Light or Dark.
|
||
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||
out.textures_delta.clear();
|
||
let cmds = out.viewport_output.values().flat_map(|v| v.commands.iter()).collect::<Vec<_>>();
|
||
assert!(!cmds.iter().any(|c| matches!(c, egui::ViewportCommand::SetTheme(t) if t != &egui::SystemTheme::SystemDefault)), "{cmds:?}");
|
||
for (mode, pref) in [("light", egui::ThemePreference::Light), ("dark", egui::ThemePreference::Dark)] {
|
||
app.set_pref("appearance.appearanceMode", json!(mode)).unwrap();
|
||
app.apply_prefs(&ctx);
|
||
assert_eq!(ctx.options(|o| o.theme_preference), pref, "{mode}");
|
||
assert_eq!(ctx.global_style().visuals.panel_fill, app.tokens.panel_bg, "{mode}");
|
||
}
|
||
}
|
||
|
||
/// Picking a theme (`view.theme.*`, `ui.set {theme}`) fixes the mode to its family.
|
||
#[test]
|
||
fn picking_a_theme_fixes_its_mode() {
|
||
let ctx = egui::Context::default();
|
||
let mut app = app();
|
||
app.set_pref("appearance.appearanceMode", json!("auto")).unwrap();
|
||
menus::invoke(&mut app, &ctx, "view.theme.light", json!({})).unwrap();
|
||
assert_eq!(app.session.prefs.appearance.appearance_mode, "light");
|
||
assert_eq!(app.ui.theme, ThemeKind::Light);
|
||
menus::invoke(&mut app, &ctx, "view.theme.darker", json!({})).unwrap();
|
||
assert_eq!(app.session.prefs.appearance.appearance_mode, "dark");
|
||
assert_eq!(app.session.prefs.appearance.dark_theme, "darker");
|
||
assert_eq!(app.ui.theme, ThemeKind::Darker);
|
||
}
|
||
}
|