Merge pull request #505 from luniiya/feat/system-appearance
Standardise system appearance modes and FilmCraft theme previews
This commit is contained in:
commit
4b172dcede
|
|
@ -1684,6 +1684,7 @@ dependencies = [
|
|||
"sys-locale",
|
||||
"winit",
|
||||
"winresource",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -36,6 +36,11 @@ objc2 = "0.6"
|
|||
# Same winit as eframe: macOS event-loop options (no app activation when driven by an agent).
|
||||
winit = { version = "0.30", default-features = false }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Settings ▸ Appearance ▸ Sync with system: the XDG desktop portal's colour scheme. The zbus the
|
||||
# portal file dialogs (rfd) and accesskit already use.
|
||||
zbus = "5.19"
|
||||
|
||||
# Icon + VERSIONINFO for filmcraft.exe (build.rs); only needed when building on Windows.
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
winresource = "0.1"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
//! The system's light or dark appearance for Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with
|
||||
//! system, on Linux desktops where winit reports none (Wayland compositors send no theme).
|
||||
//!
|
||||
//! The XDG desktop portal is read once (`org.freedesktop.portal.Settings.Read` of
|
||||
//! `org.freedesktop.appearance` / `color-scheme`), then its `SettingChanged` signal is followed on a
|
||||
//! worker thread, which wakes the UI only when the value changes. There is no polling. Without a
|
||||
//! portal answer, `gsettings` runs once at start-up, by absolute path. Elsewhere the host returns no
|
||||
//! service and the UI uses what egui reports.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
|
||||
#[cfg(target_os = "linux")]
|
||||
const PORTAL_KEY: &str = "color-scheme";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
|
||||
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
|
||||
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option<egui::Theme> {
|
||||
let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?;
|
||||
decode(portal_code(value)?)
|
||||
}
|
||||
|
||||
/// The `u32` inside a portal reply. `Read` returns a variant, and some portals nest another
|
||||
/// variant inside it.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_code(value: zbus::zvariant::OwnedValue) -> Option<u32> {
|
||||
let mut value: zbus::zvariant::Value<'_> = value.into();
|
||||
for _ in 0..4 {
|
||||
match value {
|
||||
zbus::zvariant::Value::Value(inner) => value = *inner,
|
||||
other => return u32::try_from(other).ok(),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The portal's `color-scheme`: 0 no preference, 1 prefer dark, 2 prefer light.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn decode(code: u32) -> Option<egui::Theme> {
|
||||
match code {
|
||||
1 => Some(egui::Theme::Dark),
|
||||
2 => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot fallback for desktops without the settings portal: runs once, by absolute path.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn gtk_theme() -> Option<egui::Theme> {
|
||||
let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?;
|
||||
let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
|
||||
match s.trim().trim_matches('\'') {
|
||||
"prefer-dark" => Some(egui::Theme::Dark),
|
||||
"prefer-light" => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn code_of(theme: Option<egui::Theme>) -> u8 {
|
||||
match theme {
|
||||
Some(egui::Theme::Dark) => 1,
|
||||
Some(egui::Theme::Light) => 2,
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish initial and subsequent answers alike: the initial Read may finish after the UI's
|
||||
/// bounded startup wait, so it must also wake an already-created context.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn publish(value: &std::sync::atomic::AtomicU8, code: u8, repaint: impl FnOnce()) {
|
||||
if value.swap(code, std::sync::atomic::Ordering::Relaxed) != code {
|
||||
repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// The host hook (`HostHooks::system_theme`), or `None` where egui's own report is used.
|
||||
pub fn service() -> Option<filmcraft_ui_egui::SystemThemeFn> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
let value = Arc::new(AtomicU8::new(0));
|
||||
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
|
||||
let (worker_value, worker_wake) = (Arc::clone(&value), Arc::clone(&wake));
|
||||
let (ready, wait) = std::sync::mpsc::channel();
|
||||
let worker = move || {
|
||||
let proxy = portal_proxy();
|
||||
// Subscribe before Read so a change during startup cannot be missed.
|
||||
let signals = proxy.as_ref().and_then(|p| p.receive_signal("SettingChanged").ok());
|
||||
let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme);
|
||||
publish(&worker_value, code_of(initial), || {
|
||||
if let Some(ctx) = worker_wake.get() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
let _ = ready.send(());
|
||||
let Some(signals) = signals else { return };
|
||||
// Sleeps in the signal iterator: no polling.
|
||||
for message in signals {
|
||||
let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else {
|
||||
continue;
|
||||
};
|
||||
if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY {
|
||||
continue;
|
||||
}
|
||||
let code = code_of(portal_code(changed).and_then(decode));
|
||||
publish(&worker_value, code, || {
|
||||
if let Some(ctx) = worker_wake.get() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
let spawned = std::thread::Builder::new().name("appearance-portal".into()).spawn(move || {
|
||||
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(worker)).is_err() {
|
||||
log::warn!("the system appearance watcher stopped; Sync with system keeps the last value");
|
||||
}
|
||||
});
|
||||
if spawned.is_err() {
|
||||
return None;
|
||||
}
|
||||
// The first reading, so the first frame already shows the right theme (bounded wait).
|
||||
let _ = wait.recv_timeout(std::time::Duration::from_millis(250));
|
||||
Some(Box::new(move |ctx: &egui::Context| {
|
||||
let _ = wake.set(ctx.clone());
|
||||
decode(u32::from(value.load(Ordering::Relaxed)))
|
||||
}))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn initial_and_signal_answers_repaint_only_on_changes() {
|
||||
use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
|
||||
let repaints = AtomicUsize::new(0);
|
||||
let value = AtomicU8::new(0);
|
||||
let repaint = || {
|
||||
repaints.fetch_add(1, Ordering::Relaxed);
|
||||
};
|
||||
// A late initial Read takes the same publication path as later signals.
|
||||
super::publish(&value, 2, repaint);
|
||||
assert_eq!(repaints.load(Ordering::Relaxed), 1);
|
||||
super::publish(&value, 2, repaint);
|
||||
assert_eq!(repaints.load(Ordering::Relaxed), 1);
|
||||
super::publish(&value, 1, repaint);
|
||||
assert_eq!(repaints.load(Ordering::Relaxed), 2);
|
||||
assert_eq!(value.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn portal_and_gsettings_values_decode() {
|
||||
assert_eq!(super::decode(0), None);
|
||||
assert_eq!(super::decode(1), Some(egui::Theme::Dark));
|
||||
assert_eq!(super::decode(2), Some(egui::Theme::Light));
|
||||
assert_eq!(super::decode(9), None);
|
||||
let nested = zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::U32(2)))));
|
||||
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2));
|
||||
let plain = zbus::zvariant::OwnedValue::from(1u32);
|
||||
assert_eq!(super::portal_code(plain), Some(1));
|
||||
let wrong = zbus::zvariant::OwnedValue::try_from(zbus::zvariant::Value::from("dark")).unwrap();
|
||||
assert_eq!(super::portal_code(wrong), None);
|
||||
assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light));
|
||||
assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark));
|
||||
assert_eq!(super::parse_gtk_scheme("'default'\n"), None);
|
||||
assert_eq!(super::parse_gtk_scheme(""), None);
|
||||
assert_eq!((super::code_of(None), super::code_of(Some(egui::Theme::Dark)), super::code_of(Some(egui::Theme::Light))), (0, 1, 2));
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@
|
|||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
mod app_nap;
|
||||
mod appearance;
|
||||
mod args;
|
||||
mod audio;
|
||||
mod audio_in;
|
||||
|
|
@ -229,6 +230,9 @@ fn main() -> eframe::Result {
|
|||
app.hooks.open_path = Some(Box::new(open_path));
|
||||
// Settings ▸ General ▸ Interface Language ▸ System Language (#218).
|
||||
app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect()));
|
||||
// Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system on Linux desktops whose
|
||||
// compositor reports no theme to winit (no polling: see appearance.rs).
|
||||
app.hooks.system_theme = appearance::service();
|
||||
app.hooks.raise_without_focus = Some(Box::new(|| {
|
||||
window_raise::raise_without_focus();
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -392,6 +392,7 @@ impl Preferences {
|
|||
self.playback.postroll_seconds = secs(self.playback.postroll_seconds);
|
||||
self.trim.large_trim_offset = self.trim.large_trim_offset.clamp(1, 1000);
|
||||
self.labels.sanitize_labels();
|
||||
self.appearance.sync_color_theme();
|
||||
self.version = settings::PREFS_VERSION;
|
||||
}
|
||||
|
||||
|
|
@ -454,6 +455,12 @@ impl Preferences {
|
|||
let _: Preferences = serde_json::from_value(v.clone()).map_err(|e| format!("`{key}`: {e}"))?;
|
||||
settings::sanitize(&mut v, &Self::default().to_value());
|
||||
let mut p: Preferences = serde_json::from_value(v).map_err(|e| format!("`{key}`: {e}"))?;
|
||||
// Old clients that set the single Color Theme still pick the theme they name (before
|
||||
// `clamp`, which brings `colorTheme` back in line with the mode and theme choices).
|
||||
if key == "appearance.colorTheme" {
|
||||
let theme = p.appearance.color_theme.clone();
|
||||
p.appearance.select_theme(&theme);
|
||||
}
|
||||
p.clamp();
|
||||
*self = p;
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -88,8 +88,18 @@ impl Default for GeneralPrefs {
|
|||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct AppearancePrefs {
|
||||
/// "Color Theme": `darkest` (default) | `dark` | `light`.
|
||||
/// Legacy single "Color Theme": `darkest` | `dark` | `light`. Setting it (old settings files,
|
||||
/// automation, View ▸ Appearance) selects that theme and fixes the mode to its family. It is
|
||||
/// kept in line with the fields below ([`AppearancePrefs::sync_color_theme`]): the light or
|
||||
/// dark theme of a fixed mode, and the dark theme in Auto (whose shown theme depends on the
|
||||
/// system; read `appearanceMode` for that).
|
||||
pub color_theme: String,
|
||||
/// "Appearance Mode": `auto` (follow the operating system) | `dark` (default) | `light`.
|
||||
pub appearance_mode: String,
|
||||
/// The theme shown in dark mode: `darkest` (default) | `dark`.
|
||||
pub dark_theme: String,
|
||||
/// The theme shown in light mode: `light`.
|
||||
pub light_theme: String,
|
||||
/// "Accessible color contrast": brighter secondary text and borders.
|
||||
pub accessible_contrast: bool,
|
||||
/// Highlight (accent) colour of selections, focus and primary buttons (`#rrggbb`).
|
||||
|
|
@ -98,7 +108,55 @@ pub struct AppearancePrefs {
|
|||
|
||||
impl Default for AppearancePrefs {
|
||||
fn default() -> Self {
|
||||
Self { color_theme: "darkest".into(), accessible_contrast: false, highlight_color: DEFAULT_HIGHLIGHT.into() }
|
||||
Self {
|
||||
color_theme: "darkest".into(),
|
||||
appearance_mode: "dark".into(),
|
||||
dark_theme: "darkest".into(),
|
||||
light_theme: "light".into(),
|
||||
accessible_contrast: false,
|
||||
highlight_color: DEFAULT_HIGHLIGHT.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppearancePrefs {
|
||||
/// The theme to show (`darkest` | `dark` | `light`): the light or dark theme of the mode. Auto
|
||||
/// follows `system_light` (the operating system's appearance) and is dark without an answer.
|
||||
pub fn shown_theme(&self, system_light: Option<bool>) -> &str {
|
||||
let light = match self.appearance_mode.as_str() {
|
||||
"light" => true,
|
||||
"auto" => system_light == Some(true),
|
||||
_ => false,
|
||||
};
|
||||
if light { &self.light_theme } else { &self.dark_theme }
|
||||
}
|
||||
|
||||
/// Select `theme` (a Color Theme value) as the theme of its family and fix the mode to that
|
||||
/// family. Unknown names are ignored.
|
||||
pub fn select_theme(&mut self, theme: &str) {
|
||||
if LIGHT_THEMES.iter().any(|(v, _)| *v == theme) {
|
||||
self.light_theme = theme.into();
|
||||
self.appearance_mode = "light".into();
|
||||
} else if DARK_THEMES.iter().any(|(v, _)| *v == theme) {
|
||||
self.dark_theme = theme.into();
|
||||
self.appearance_mode = "dark".into();
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring the legacy `color_theme` in line with the mode and theme choices, so clients that
|
||||
/// still read it get the theme a fixed mode shows (the dark theme in Auto).
|
||||
pub fn sync_color_theme(&mut self) {
|
||||
let theme = self.shown_theme(None).to_string();
|
||||
self.color_theme = theme;
|
||||
}
|
||||
|
||||
/// The mode after this one in the header button's cycle: Auto, Light, Dark, Auto…
|
||||
pub fn next_mode(&self) -> &'static str {
|
||||
match self.appearance_mode.as_str() {
|
||||
"auto" => "light",
|
||||
"light" => "dark",
|
||||
_ => "auto",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -607,6 +665,9 @@ const OPENING: &[(&str, &str)] = &[("showOpenDialog", "Show Open Dialog"), ("sho
|
|||
const BIN_OPEN: &[(&str, &str)] = &[("openInPlace", "Open in place"), ("openNewTab", "Open new tab"), ("openNewWindow", "Open in new window")];
|
||||
const PROJECT_OPEN: &[(&str, &str)] = &[("openNewTab", "Open new tab"), ("openNewWindow", "Open in new window")];
|
||||
const THEMES: &[(&str, &str)] = &[("darkest", "Darkest"), ("dark", "Dark"), ("light", "Light")];
|
||||
pub const APPEARANCE_MODES: &[(&str, &str)] = &[("auto", "Sync with system"), ("dark", "Dark"), ("light", "Light")];
|
||||
pub const DARK_THEMES: &[(&str, &str)] = &[("darkest", "Darkest"), ("dark", "Dark")];
|
||||
pub const LIGHT_THEMES: &[(&str, &str)] = &[("light", "Light")];
|
||||
const MIXDOWN: &[(&str, &str)] = &[("front", "Front Only"), ("frontRear", "Front + Rear"), ("frontLfe", "Front + LFE"), ("frontRearLfe", "Front + Rear + LFE")];
|
||||
const AUDITION: &[(&str, &str)] = &[("scratch", "Scratch disk location for Captured Audio"), ("nextToMedia", "Next to original media files")];
|
||||
const BUFFERS: &[(&str, &str)] = &[("64", "64"), ("128", "128"), ("256", "256"), ("512", "512"), ("1024", "1024"), ("2048", "2048"), ("4096", "4096")];
|
||||
|
|
@ -722,6 +783,12 @@ static CATEGORIES: &[Category] = &[
|
|||
id: "appearance",
|
||||
title: "Appearance",
|
||||
rows: &[
|
||||
// Drawn by the dialog as the Appearance Mode selector above the light and dark theme
|
||||
// cards; the fields below keep their keys, validation and `prefs.schema` entries.
|
||||
Row::Custom("appearanceModes"),
|
||||
f("appearance.appearanceMode", "Appearance Mode", Kind::Choice(APPEARANCE_MODES), true),
|
||||
f("appearance.lightTheme", "Light Theme", Kind::Choice(LIGHT_THEMES), true),
|
||||
f("appearance.darkTheme", "Dark Theme", Kind::Choice(DARK_THEMES), true),
|
||||
f("appearance.colorTheme", "Color Theme", Kind::Choice(THEMES), true),
|
||||
b("appearance.accessibleContrast", "Accessible color contrast", true),
|
||||
f("appearance.highlightColor", "Highlight Color", Kind::Color, true),
|
||||
|
|
@ -1141,6 +1208,18 @@ pub fn migrate(v: &mut Value) {
|
|||
// pre/postroll; v2 stores them as reals, which serde reads either way.
|
||||
m.insert("version".into(), json!(2));
|
||||
}
|
||||
// Files from before appearance modes had one Color Theme: keep showing it, as a fixed Dark or
|
||||
// Light mode, instead of switching established users to another theme or to Auto.
|
||||
if let Some(a) = m.get_mut("appearance").and_then(Value::as_object_mut)
|
||||
&& !a.contains_key("appearanceMode")
|
||||
&& let Some(theme) = a.get("colorTheme").and_then(Value::as_str).map(str::to_string)
|
||||
{
|
||||
let mut p = AppearancePrefs::default();
|
||||
p.select_theme(&theme);
|
||||
a.insert("appearanceMode".into(), json!(p.appearance_mode));
|
||||
a.insert("darkTheme".into(), json!(p.dark_theme));
|
||||
a.insert("lightTheme".into(), json!(p.light_theme));
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::autosave::Preferences {
|
||||
|
|
|
|||
|
|
@ -551,3 +551,70 @@ fn hardware_decoding_setting_drives_the_decoder_switch() {
|
|||
assert!(filmcraft_codecs::hw::hardware_decoding());
|
||||
assert!(settings::field("playback.hardwareDecoding").is_some_and(|f| f.wired && matches!(f.kind, Kind::Choice(_))));
|
||||
}
|
||||
|
||||
/// New users keep the dark Darkest theme (following the system is opt-in); a file saved before
|
||||
/// appearance modes keeps showing its Color Theme as a fixed Dark or Light mode.
|
||||
#[test]
|
||||
fn appearance_defaults_and_legacy_theme_migrate() {
|
||||
let a = Preferences::default().appearance;
|
||||
assert_eq!((a.appearance_mode.as_str(), a.dark_theme.as_str(), a.light_theme.as_str()), ("dark", "darkest", "light"));
|
||||
assert_eq!(a.shown_theme(Some(true)), "darkest", "Dark mode ignores a light system");
|
||||
let dir = tmp_dir("prefs-appearance-migrate");
|
||||
let path = dir.join("preferences.json");
|
||||
for (theme, mode, dark, light) in [("darkest", "dark", "darkest", "light"), ("dark", "dark", "dark", "light"), ("light", "light", "darkest", "light")] {
|
||||
std::fs::write(&path, json!({"version": 2, "appearance": {"colorTheme": theme}}).to_string()).unwrap();
|
||||
let a = Preferences::load(&path).appearance;
|
||||
assert_eq!((a.appearance_mode.as_str(), a.dark_theme.as_str(), a.light_theme.as_str()), (mode, dark, light), "{theme}");
|
||||
assert_eq!(a.shown_theme(None), theme);
|
||||
}
|
||||
// a v1 file without an appearance section, and a hostile theme, get the defaults
|
||||
std::fs::write(&path, r#"{"autoSave":{"enabled":true}}"#).unwrap();
|
||||
assert_eq!(Preferences::load(&path).appearance, settings::AppearancePrefs::default());
|
||||
std::fs::write(&path, r#"{"appearance":{"colorTheme":"neon","appearanceMode":7,"darkTheme":"light"}}"#).unwrap();
|
||||
assert_eq!(Preferences::load(&path).appearance, settings::AppearancePrefs::default());
|
||||
// a file with a mode keeps it
|
||||
std::fs::write(&path, r#"{"appearance":{"colorTheme":"darkest","appearanceMode":"auto","lightTheme":"light","darkTheme":"dark"}}"#).unwrap();
|
||||
let a = Preferences::load(&path).appearance;
|
||||
assert_eq!((a.appearance_mode.as_str(), a.shown_theme(Some(true)), a.shown_theme(Some(false)), a.shown_theme(None)), ("auto", "light", "dark", "dark"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_choices_validate_and_legacy_theme_still_selects() {
|
||||
let mut s = Session::default();
|
||||
s.execute("prefs.set", json!({"values": {"appearance.appearanceMode": "auto", "appearance.darkTheme": "dark"}})).unwrap();
|
||||
assert_eq!((s.prefs.appearance.appearance_mode.as_str(), s.prefs.appearance.dark_theme.as_str()), ("auto", "dark"));
|
||||
for (key, value) in [
|
||||
("appearance.darkTheme", json!("light")),
|
||||
("appearance.lightTheme", json!("darkest")),
|
||||
("appearance.appearanceMode", json!("system")),
|
||||
("appearance.appearanceMode", json!(1)),
|
||||
] {
|
||||
assert!(s.execute("prefs.set", json!({"key": key, "value": value})).is_err(), "{key} {value}");
|
||||
}
|
||||
// the single Color Theme of older clients selects that theme and fixes the mode to its family
|
||||
set(&mut s, "appearance.colorTheme", json!("light"));
|
||||
assert_eq!((s.prefs.appearance.appearance_mode.as_str(), s.prefs.appearance.light_theme.as_str()), ("light", "light"));
|
||||
assert_eq!(s.prefs.appearance.dark_theme, "dark", "the dark choice is kept");
|
||||
set(&mut s, "appearance.colorTheme", json!("darkest"));
|
||||
assert_eq!((s.prefs.appearance.appearance_mode.as_str(), s.prefs.appearance.dark_theme.as_str()), ("dark", "darkest"));
|
||||
// the legacy Color Theme follows the mode and theme choices, so clients reading it aren't stale
|
||||
assert_eq!(s.prefs.appearance.color_theme, "darkest");
|
||||
set(&mut s, "appearance.appearanceMode", json!("light"));
|
||||
assert_eq!(s.prefs.appearance.color_theme, "light");
|
||||
set(&mut s, "appearance.appearanceMode", json!("dark"));
|
||||
set(&mut s, "appearance.darkTheme", json!("dark"));
|
||||
assert_eq!(s.prefs.appearance.color_theme, "dark");
|
||||
set(&mut s, "appearance.appearanceMode", json!("auto"));
|
||||
assert_eq!(s.prefs.appearance.color_theme, "dark", "Auto: the dark theme");
|
||||
set(&mut s, "appearance.colorTheme", json!("light"));
|
||||
assert_eq!((s.prefs.appearance.appearance_mode.as_str(), s.prefs.appearance.color_theme.as_str()), ("light", "light"));
|
||||
// the header button's cycle
|
||||
let mut a = settings::AppearancePrefs::default();
|
||||
let mut seen = Vec::new();
|
||||
for _ in 0..4 {
|
||||
a.appearance_mode = a.next_mode().into();
|
||||
seen.push(a.appearance_mode.clone());
|
||||
}
|
||||
assert_eq!(seen, ["auto", "light", "dark", "auto"]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,16 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("quick-export-toggled"), true));
|
||||
}
|
||||
crate::panels::export_mode::quick_export(app, ui.ctx(), pos2(qx - 340.0, rect.max.y + 4.0));
|
||||
// Appearance Mode: click for the next one (Auto, Light, Dark); the icon shows the current one.
|
||||
let (icon, mode) = match app.session.prefs.appearance.appearance_mode.as_str() {
|
||||
"auto" => (Icon::Monitor, tl!("Sync with system")),
|
||||
"light" => (Icon::Sun, tl!("Light")),
|
||||
_ => (Icon::Moon, tl!("Dark")),
|
||||
};
|
||||
let tip = format!("{}: {mode}", tl!("Appearance Mode"));
|
||||
if btn(ui, icon, "appearance", &tip, app).clicked() {
|
||||
crate::panels::settings::cycle_appearance(app, ui.ctx());
|
||||
}
|
||||
let ws_resp = btn(ui, Icon::Workspaces, "workspaces", tl!("Workspaces"), app);
|
||||
// workspace name (caps)
|
||||
let ws = crate::i18n::t(&app.ui.workspace).to_uppercase();
|
||||
|
|
|
|||
|
|
@ -3488,6 +3488,14 @@
|
|||
New Sequence Nueva secuencia
|
||||
Tracks Pistas
|
||||
tracks pistas
|
||||
|
||||
# System appearance modes and theme cards.
|
||||
Active Activo
|
||||
Appearance Mode Modo de apariencia
|
||||
Dark Theme Tema oscuro
|
||||
Light Theme Tema claro
|
||||
Next Appearance Mode Siguiente modo de apariencia
|
||||
Sync with system Sincronizar con el sistema
|
||||
In Entrada
|
||||
Out Salida
|
||||
Export Markers as CSV… Exportar marcadores como CSV…
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
|
|
@ -3435,6 +3435,14 @@
|
|||
Select All Project Items すべてのプロジェクト項目を選択
|
||||
Username ユーザー名
|
||||
Vertical Type Tool 縦書き文字ツール
|
||||
|
||||
# System appearance modes and theme cards.
|
||||
Active 使用中
|
||||
Appearance Mode 外観モード
|
||||
Dark Theme ダークテーマ
|
||||
Light Theme ライトテーマ
|
||||
Next Appearance Mode 次の外観モード
|
||||
Sync with system システムと同期
|
||||
Go to previous keyframe 前のキーフレームへ移動
|
||||
Go to next keyframe 次のキーフレームへ移動
|
||||
Add keyframe キーフレームを追加
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 11 and column 59.
|
|
|
@ -352,3 +352,6 @@
|
|||
Zoom In Ampliar
|
||||
Zoom Out Reduzir
|
||||
Zoom to Sequence Zoom para a sequência
|
||||
|
||||
# System appearance mode menu command.
|
||||
Next Appearance Mode Próximo modo de aparência
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 8.
|
|
|
@ -216,6 +216,7 @@
|
|||
New Створити
|
||||
New Layer Новий шар
|
||||
New Project Panel Нова панель проєкту
|
||||
Next Appearance Mode Наступний режим вигляду
|
||||
Next in Sequence Наступний у секвенції
|
||||
Next in Track Наступний на доріжці
|
||||
Normalize Mix Track… Нормалізувати доріжку міксу…
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 7.
|
|
|
@ -3481,3 +3481,7 @@
|
|||
In 入点
|
||||
Out 出点
|
||||
Export Markers as CSV… 将标记导出为 CSV…
|
||||
Active 当前
|
||||
Appearance Mode 外观模式
|
||||
Next Appearance Mode 下一个外观模式
|
||||
Sync with system 跟随系统
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
|
|
@ -114,6 +114,10 @@ pub enum Icon {
|
|||
Network,
|
||||
/// Media Browser: recent directories (a clock face).
|
||||
Clock,
|
||||
/// Appearance Mode: Auto (a display on a stand), Light (a sun), Dark (a crescent moon).
|
||||
Monitor,
|
||||
Sun,
|
||||
Moon,
|
||||
/// Colour parameters: pick a colour from the Program monitor (a pipette).
|
||||
Eyedropper,
|
||||
}
|
||||
|
|
@ -727,6 +731,31 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
|
|||
pen.circle(8.0, 8.0, 6.0);
|
||||
pen.line(&[(8.0, 4.5), (8.0, 8.0), (10.5, 9.5)]);
|
||||
}
|
||||
Monitor => {
|
||||
pen.rect(2.0, 2.5, 14.0, 11.0);
|
||||
pen.line(&[(8.0, 11.0), (8.0, 13.5)]);
|
||||
pen.line(&[(5.0, 13.5), (11.0, 13.5)]);
|
||||
}
|
||||
Sun => {
|
||||
pen.circle(8.0, 8.0, 2.8);
|
||||
for i in 0..8 {
|
||||
let a = (i as f32 * 45.0).to_radians();
|
||||
let (c, s) = (a.cos(), a.sin());
|
||||
pen.line(&[(8.0 + 4.6 * c, 8.0 + 4.6 * s), (8.0 + 6.2 * c, 8.0 + 6.2 * s)]);
|
||||
}
|
||||
}
|
||||
Moon => {
|
||||
// The outer circle's arc through the bottom left, back along a smaller circle that
|
||||
// bites the top right out of it.
|
||||
let arc = |cx: f32, cy: f32, r: f32, a0: f32, a1: f32| {
|
||||
(0..=12).map(move |i| {
|
||||
let a = (a0 + (a1 - a0) * i as f32 / 12.0).to_radians();
|
||||
(cx + r * a.cos(), cy + r * a.sin())
|
||||
})
|
||||
};
|
||||
let pts: Vec<(f32, f32)> = arc(8.0, 8.5, 5.5, 8.56, 261.44).chain(arc(11.2, 5.3, 4.6, 209.12, 60.88).skip(1)).collect();
|
||||
pen.closed(&pts);
|
||||
}
|
||||
Eyedropper => {
|
||||
// a pipette: bulb at the top right, glass tube running to a tip at the bottom left
|
||||
pen.closed(&[(10.0, 4.5), (11.5, 3.0), (13.0, 3.0), (13.0, 4.5), (11.5, 6.0)]);
|
||||
|
|
@ -768,6 +797,9 @@ mod tests {
|
|||
use egui::epaint::{ColorMode, Shape};
|
||||
|
||||
const ALL: &[Icon] = &[
|
||||
Monitor,
|
||||
Sun,
|
||||
Moon,
|
||||
Selection,
|
||||
TrackSelectFwd,
|
||||
TrackSelectBack,
|
||||
|
|
|
|||
|
|
@ -132,8 +132,15 @@ pub struct HostHooks {
|
|||
/// Open a file in its default application, or (`true`) reveal it in the file manager (Edit ▸
|
||||
/// Edit Original, Help ▸ Reveal Log Files).
|
||||
pub open_path: Option<Box<dyn FnMut(&str, bool) -> Result<(), String>>>,
|
||||
/// The operating system's light or dark appearance when egui cannot report it (Linux desktops
|
||||
/// whose Wayland compositor sends no theme to winit). Without it, or without an answer, Auto
|
||||
/// uses `egui::Context::system_theme`.
|
||||
pub system_theme: Option<SystemThemeFn>,
|
||||
}
|
||||
|
||||
/// Reads the system appearance (Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system).
|
||||
pub type SystemThemeFn = Box<dyn Fn(&egui::Context) -> Option<egui::Theme>>;
|
||||
|
||||
/// The command a [`HostHooks::pick_file_for_relink`] caller runs with the chosen file, for hosts
|
||||
/// that can only run it later.
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
@ -511,6 +518,18 @@ impl FilmcraftApp {
|
|||
self.ui.dark = k != ThemeKind::Light;
|
||||
}
|
||||
|
||||
/// The system appearance: the host's reading, else what the windowing toolkit reports.
|
||||
pub fn system_theme(&self, ctx: &egui::Context) -> Option<egui::Theme> {
|
||||
self.hooks.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme())
|
||||
}
|
||||
|
||||
/// The theme Settings ▸ Appearance selects: the light or dark theme of the Appearance Mode,
|
||||
/// Auto following the system (dark when it gives no answer).
|
||||
pub fn appearance_kind(&self, ctx: &egui::Context) -> ThemeKind {
|
||||
let light = self.system_theme(ctx).map(|t| t == egui::Theme::Light);
|
||||
ThemeKind::from_pref(self.session.prefs.appearance.shown_theme(light))
|
||||
}
|
||||
|
||||
fn apply_tooltips(&self, ctx: &egui::Context) {
|
||||
// Settings ▸ General ▸ Show Tool Tips
|
||||
let delay = if self.session.prefs.general.show_tool_tips { 0.5 } else { 1.0e9 };
|
||||
|
|
@ -520,13 +539,22 @@ impl FilmcraftApp {
|
|||
/// Make the UI follow the settings after they change (theme, tooltips, frame cache budget,
|
||||
/// play after rendering, audio device).
|
||||
pub fn apply_prefs(&mut self, ctx: &egui::Context) {
|
||||
// Appearance Mode ▸ Sync with system: follow the system while running. The host reports
|
||||
// changes as they happen (no polling) and wakes the UI only when the value changes.
|
||||
if self.applied_prefs.is_some() {
|
||||
let k = self.appearance_kind(ctx);
|
||||
if k != self.tokens.kind {
|
||||
self.set_theme(ctx, k);
|
||||
}
|
||||
}
|
||||
if self.applied_prefs.as_ref() == Some(&self.session.prefs) {
|
||||
return;
|
||||
}
|
||||
let p = self.session.prefs.clone();
|
||||
let prev = self.applied_prefs.take();
|
||||
if prev.as_ref().is_none_or(|q| q.appearance != p.appearance || q.general.show_tool_tips != p.general.show_tool_tips) {
|
||||
self.set_theme(ctx, ThemeKind::from_pref(&p.appearance.color_theme));
|
||||
let k = self.appearance_kind(ctx);
|
||||
self.set_theme(ctx, k);
|
||||
}
|
||||
if prev.as_ref().is_none_or(|q| q.general.interface_language != p.general.interface_language) {
|
||||
let language = match i18n::Language::parse(&p.general.interface_language) {
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
uic!("view.theme.dark", "Darkest", ["View", "Appearance"], None),
|
||||
uic!("view.theme.medium", "Dark", ["View", "Appearance"], None),
|
||||
uic!("view.theme.light", "Light", ["View", "Appearance"], None),
|
||||
uic!("view.appearanceMode.next", "Next Appearance Mode", ["View", "Appearance"], None),
|
||||
uic!("window.workspace.editing", "Editing", ["Window", "Workspaces"], Some("Alt+Shift+1")),
|
||||
uic!("window.workspace.assembly", "Assembly", ["Window", "Workspaces"], Some("Alt+Shift+2")),
|
||||
uic!("window.workspace.color", "Color", ["Window", "Workspaces"], Some("Alt+Shift+3")),
|
||||
|
|
@ -499,6 +500,10 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
|
|||
}
|
||||
_ => {}
|
||||
}
|
||||
if id == "view.appearanceMode.next" {
|
||||
crate::panels::settings::cycle_appearance(app, ctx);
|
||||
return Ok(json!({"appearanceMode": app.session.prefs.appearance.appearance_mode}));
|
||||
}
|
||||
if let Some(th) = id.strip_prefix("view.theme.") {
|
||||
let k = crate::theme::ThemeKind::from_name(th).ok_or("unknown theme")?;
|
||||
crate::panels::settings::set_theme(app, ctx, k);
|
||||
|
|
|
|||
|
|
@ -79,6 +79,192 @@ pub fn set_theme(app: &mut FilmcraftApp, ctx: &egui::Context, k: crate::theme::T
|
|||
app.set_theme(ctx, k);
|
||||
}
|
||||
|
||||
/// The header's appearance button and View ▸ Appearance ▸ Next Appearance Mode: Auto, Light, Dark,
|
||||
/// then Auto again. The light and dark theme choices are kept.
|
||||
pub fn cycle_appearance(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let next = app.session.prefs.appearance.next_mode();
|
||||
if let Err(e) = app.session.execute("prefs.set", json!({"key": "appearance.appearanceMode", "value": next})) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
app.apply_prefs(ctx);
|
||||
}
|
||||
|
||||
/// Keys the Appearance page draws as the mode selector and theme cards instead of plain fields.
|
||||
const APPEARANCE_KEYS: &[&str] = &["appearance.appearanceMode", "appearance.lightTheme", "appearance.darkTheme", "appearance.colorTheme"];
|
||||
|
||||
/// A small FilmCraft editor painted with `t`'s colours: the header with the Import / Edit / Export
|
||||
/// tabs, the Source and Program monitors, the Project panel, the tools, the Timeline with video and
|
||||
/// audio clips and the playhead, and the audio meters (the Editing workspace).
|
||||
fn theme_preview(ui: &mut Ui, t: &Tokens, width: f32) {
|
||||
use egui::pos2;
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, 128.0), Sense::hover());
|
||||
let p = ui.painter_at(rect);
|
||||
let bar = |r: Rect, w: f32, c: Color32| p.rect_filled(Rect::from_min_size(r.min, vec2(w, 2.0)), 1.0, c);
|
||||
p.rect_filled(rect, 3.0, t.app_bg);
|
||||
// header: home, the three mode tabs (Edit active, underlined), the title, quick actions
|
||||
let header = Rect::from_min_size(rect.min, vec2(rect.width(), 12.0));
|
||||
p.rect_filled(header, CornerRadius { nw: 3, ne: 3, sw: 0, se: 0 }, t.header_bg);
|
||||
p.rect_filled(Rect::from_center_size(pos2(header.left() + 7.0, header.center().y), vec2(4.0, 4.0)), 1.0, t.text_dim);
|
||||
for (i, w) in [9.0, 6.0, 9.0].into_iter().enumerate() {
|
||||
let x = header.left() + 14.0 + i as f32 * 13.0;
|
||||
let c = if i == 1 { t.tab_text_active } else { t.tab_text };
|
||||
bar(Rect::from_min_size(pos2(x, header.center().y - 1.0), vec2(w, 2.0)), w, c);
|
||||
if i == 1 {
|
||||
p.rect_filled(Rect::from_min_size(pos2(x, header.bottom() - 2.5), vec2(w, 1.0)), 0.0, c);
|
||||
}
|
||||
}
|
||||
bar(Rect::from_min_size(pos2(header.center().x - 16.0, header.center().y - 1.0), vec2(32.0, 2.0)), 32.0, t.tab_text_active);
|
||||
for i in 0..4 {
|
||||
p.rect_filled(Rect::from_center_size(pos2(header.right() - 7.0 - i as f32 * 8.0, header.center().y), vec2(4.0, 4.0)), 1.0, t.icon);
|
||||
}
|
||||
let body = Rect::from_min_max(pos2(rect.left() + 2.0, header.bottom() + 2.0), rect.max - vec2(2.0, 2.0));
|
||||
let split_y = body.top() + body.height() * 0.5;
|
||||
let panel = |r: Rect, tab_w: f32| {
|
||||
p.rect_filled(r, 2.0, t.panel_bg);
|
||||
bar(Rect::from_min_size(r.min + vec2(4.0, 3.5), vec2(tab_w, 2.0)), tab_w, t.tab_text_active);
|
||||
Rect::from_min_max(r.min + vec2(3.0, 9.0), r.max - vec2(3.0, 3.0))
|
||||
};
|
||||
// Source monitor (left) and Program monitor (right)
|
||||
let source = panel(Rect::from_min_max(body.min, pos2(body.left() + body.width() * 0.36, split_y - 1.0)), 14.0);
|
||||
p.rect_filled(source, 0.0, t.monitor_bg);
|
||||
p.rect_filled(source.shrink2(vec2(6.0, 8.0)), 0.0, t.field_bg);
|
||||
let program = panel(Rect::from_min_max(pos2(body.left() + body.width() * 0.36 + 2.0, body.top()), pos2(body.right(), split_y - 1.0)), 16.0);
|
||||
p.rect_filled(program, 0.0, t.monitor_bg);
|
||||
let frame = Rect::from_center_size(program.center() - vec2(0.0, 3.0), vec2((program.height() - 9.0) * 16.0 / 9.0, program.height() - 9.0));
|
||||
p.rect_filled(frame, 0.0, Color32::from_rgb(0x2c, 0x4a, 0x2e));
|
||||
p.rect_filled(Rect::from_min_max(pos2(frame.left(), frame.bottom() - frame.height() * 0.3), frame.max), 0.0, Color32::from_rgb(0x4a, 0x3c, 0x30));
|
||||
p.circle_filled(pos2(frame.left() + frame.width() * 0.62, frame.center().y + 2.0), frame.height() * 0.26, Color32::from_rgb(0xc8, 0x2a, 0x26));
|
||||
bar(Rect::from_min_size(pos2(program.left() + 2.0, program.bottom() - 3.0), vec2(14.0, 2.0)), 14.0, t.timecode);
|
||||
// Project panel, tools, Timeline, meters
|
||||
let low = split_y + 1.0;
|
||||
let project = panel(Rect::from_min_max(pos2(body.left(), low), pos2(body.left() + body.width() * 0.24, body.bottom())), 12.0);
|
||||
for row in 0..2 {
|
||||
for col in 0..2 {
|
||||
let cell =
|
||||
Rect::from_min_size(project.min + vec2(col as f32 * (project.width() / 2.0), 2.0 + row as f32 * 18.0), vec2(project.width() / 2.0 - 3.0, 11.0));
|
||||
p.rect_filled(cell, 1.0, if (row + col) % 2 == 0 { t.field_bg } else { t.row_selected });
|
||||
bar(Rect::from_min_size(cell.left_bottom() + vec2(0.0, 2.0), vec2(cell.width() * 0.7, 2.0)), cell.width() * 0.7, t.text_faint);
|
||||
}
|
||||
}
|
||||
let tools = Rect::from_min_max(pos2(project.right() + 5.0, low), pos2(project.right() + 15.0, body.bottom()));
|
||||
p.rect_filled(tools, 2.0, t.panel_bg);
|
||||
for i in 0..5 {
|
||||
let c = if i == 0 { t.accent } else { t.icon };
|
||||
p.rect_filled(Rect::from_center_size(pos2(tools.center().x, tools.top() + 7.0 + i as f32 * 9.0), vec2(5.0, 5.0)), 1.0, c);
|
||||
}
|
||||
let meters = Rect::from_min_max(pos2(body.right() - 12.0, low), pos2(body.right(), body.bottom()));
|
||||
p.rect_filled(meters, 2.0, t.panel_bg);
|
||||
for (i, h) in [0.55, 0.62].into_iter().enumerate() {
|
||||
let x = meters.left() + 3.0 + i as f32 * 4.0;
|
||||
p.rect_filled(
|
||||
Rect::from_min_max(pos2(x, meters.bottom() - 3.0 - (meters.height() - 6.0) * h), pos2(x + 2.0, meters.bottom() - 3.0)),
|
||||
0.0,
|
||||
t.render_green,
|
||||
);
|
||||
}
|
||||
let tl = Rect::from_min_max(pos2(tools.right() + 2.0, low), pos2(meters.left() - 2.0, body.bottom()));
|
||||
p.rect_filled(tl, 2.0, t.tl_bg);
|
||||
bar(Rect::from_min_size(tl.min + vec2(4.0, 3.5), vec2(14.0, 2.0)), 14.0, t.tab_text_active);
|
||||
bar(Rect::from_min_size(tl.min + vec2(4.0, 9.0), vec2(16.0, 2.0)), 16.0, t.timecode);
|
||||
let heads_w = 22.0;
|
||||
let ruler = Rect::from_min_max(pos2(tl.left() + heads_w, tl.top() + 7.0), pos2(tl.right() - 2.0, tl.top() + 13.0));
|
||||
p.rect_filled(ruler, 0.0, t.tl_ruler_bg);
|
||||
let mut x = ruler.left() + 2.0;
|
||||
while x < ruler.right() {
|
||||
p.line_segment([pos2(x, ruler.bottom() - 2.0), pos2(x, ruler.bottom())], Stroke::new(1.0, t.tl_ruler_tick));
|
||||
x += 6.0;
|
||||
}
|
||||
let video = Label::Iris.rgb();
|
||||
let audio = Label::Caribbean.rgb();
|
||||
let music = Label::Forest.rgb();
|
||||
let track_h = ((tl.bottom() - ruler.bottom() - 3.0) / 4.0).max(4.0);
|
||||
for i in 0..4 {
|
||||
let y = ruler.bottom() + 1.0 + i as f32 * track_h;
|
||||
let head = Rect::from_min_size(pos2(tl.left() + 2.0, y), vec2(heads_w - 3.0, track_h - 1.0));
|
||||
p.rect_filled(head, 1.0, t.tl_header_bg);
|
||||
p.rect_filled(Rect::from_min_size(head.min + vec2(2.0, 1.5), vec2(5.0, track_h - 4.0)), 1.0, t.accent);
|
||||
let lane = Rect::from_min_max(pos2(ruler.left(), y), pos2(ruler.right(), y + track_h - 1.0));
|
||||
p.rect_filled(lane, 0.0, if i % 2 == 0 { t.tl_track_bg } else { t.tl_track_bg_alt });
|
||||
let clips: &[(f32, f32)] = match i {
|
||||
0 => &[(0.55, 0.75)],
|
||||
1 | 2 => &[(0.0, 0.22), (0.23, 0.48), (0.49, 0.7), (0.71, 0.9)],
|
||||
_ => &[(0.0, 0.9)],
|
||||
};
|
||||
let c = match i {
|
||||
0 | 1 => video,
|
||||
2 => audio,
|
||||
_ => music,
|
||||
};
|
||||
for (a, b) in clips {
|
||||
let r = Rect::from_min_max(pos2(lane.left() + lane.width() * a, lane.top() + 0.5), pos2(lane.left() + lane.width() * b, lane.bottom() - 0.5));
|
||||
p.rect_filled(r, 1.0, Color32::from_rgb(c[0], c[1], c[2]).gamma_multiply(0.85));
|
||||
}
|
||||
}
|
||||
let ph = ruler.left() + ruler.width() * 0.3;
|
||||
p.line_segment([pos2(ph, ruler.top()), pos2(ph, tl.bottom() - 2.0)], Stroke::new(1.0, t.playhead));
|
||||
}
|
||||
|
||||
/// One theme card of the Appearance page: title, "Active" while the mode shows it, the preview and
|
||||
/// a radio button per theme (automation ids `settings.<key>.<value>`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn theme_card(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, key: &str, title: &str, opts: &[(&str, &str)], active: bool, width: f32) {
|
||||
let t = app.tokens;
|
||||
let current = choice_text(get(&d.values, key));
|
||||
let shown = opts.iter().find(|(v, _)| *v == current).or(opts.first()).map_or("darkest", |(v, _)| v);
|
||||
let highlight = settings::parse_hex(&choice_text(get(&d.values, "appearance.highlightColor")));
|
||||
let contrast = get(&d.values, "appearance.accessibleContrast").as_bool().unwrap_or(false);
|
||||
let look = Tokens::for_kind(crate::theme::ThemeKind::from_pref(shown)).with_appearance(highlight, contrast);
|
||||
let r = Frame::new()
|
||||
.fill(t.field_bg)
|
||||
.stroke(Stroke::new(1.0, if active { t.accent } else { t.field_border }))
|
||||
.corner_radius(CornerRadius::same(6))
|
||||
.inner_margin(Margin::same(10))
|
||||
.show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(width - 20.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(crate::i18n::t(title)).size(12.5).strong());
|
||||
if active {
|
||||
ui.label(RichText::new(tl!("Active")).size(11.0).color(t.accent));
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
theme_preview(ui, &look, width - 20.0);
|
||||
ui.add_space(4.0);
|
||||
for (v, l) in opts {
|
||||
let r = ui.radio(current == *v, RichText::new(crate::i18n::t(l)).size(12.5));
|
||||
app.auto.add(&format!("settings.{key}.{v}"), r.rect, l);
|
||||
if r.clicked() {
|
||||
put(&mut d.values, key, json!(v));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
app.auto.add(&format!("settings.{key}"), r.response.rect, title);
|
||||
}
|
||||
|
||||
/// Settings ▸ Appearance: the Appearance Mode selector above the light and dark theme cards.
|
||||
fn appearance_modes(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft) {
|
||||
let key = "appearance.appearanceMode";
|
||||
let mode = choice_text(get(&d.values, key));
|
||||
ui.horizontal(|ui| {
|
||||
field_label(ui, "Appearance Mode");
|
||||
if let Some(v) = combo(app, ui, key, &mode, &static_opts(settings::APPEARANCE_MODES), 200.0) {
|
||||
put(&mut d.values, key, json!(v));
|
||||
}
|
||||
});
|
||||
let mode = choice_text(get(&d.values, key));
|
||||
let light_active = mode == "light" || (mode == "auto" && app.system_theme(ui.ctx()) == Some(egui::Theme::Light));
|
||||
ui.add_space(6.0);
|
||||
let width = ((ui.available_width() - 12.0) / 2.0).max(200.0);
|
||||
ui.horizontal_top(|ui| {
|
||||
theme_card(app, ui, d, "appearance.lightTheme", "Light Theme", settings::LIGHT_THEMES, light_active, width);
|
||||
ui.add_space(4.0);
|
||||
theme_card(app, ui, d, "appearance.darkTheme", "Dark Theme", settings::DARK_THEMES, !light_active, width);
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
|
||||
/// Patch the open dialog's draft (`ui.set {"settings": {"page": "trim", "values": {key: value}}}`).
|
||||
pub fn patch(app: &mut FilmcraftApp, p: &Value) -> Result<(), String> {
|
||||
let d = app.ui.settings.as_mut().ok_or("the Settings dialog is not open")?;
|
||||
|
|
@ -422,6 +608,7 @@ fn custom(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, name: &str
|
|||
),
|
||||
);
|
||||
}
|
||||
"appearanceModes" => appearance_modes(app, ui, d),
|
||||
"autoSaveStatus" => {
|
||||
let status = app.session.execute("file.autoSaveStatus", json!({})).unwrap_or_default();
|
||||
if let Some(dir) = status["autoSaveDir"].as_str() {
|
||||
|
|
@ -467,6 +654,7 @@ fn rows(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, list: &[Row]
|
|||
continue;
|
||||
}
|
||||
match r {
|
||||
Row::Field(f) if APPEARANCE_KEYS.contains(&f.key) => continue,
|
||||
Row::Field(f) => draw_field(app, ui, d, f),
|
||||
Row::Group(title, inner) => group(ui, &t, title, |ui| rows(app, ui, d, inner)),
|
||||
Row::Note(text) => note(ui, &t, text),
|
||||
|
|
@ -595,6 +783,66 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::theme::ThemeKind;
|
||||
|
||||
#[test]
|
||||
fn appearance_button_cycles_modes_without_losing_theme_choices() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = FilmcraftApp::new(filmcraft_engine::Session::default());
|
||||
app.apply_prefs(&ctx);
|
||||
assert_eq!((app.session.prefs.appearance.appearance_mode.as_str(), app.tokens.kind), ("dark", ThemeKind::Dark), "new users keep Darkest");
|
||||
app.session.execute("prefs.set", json!({"values": {"appearance.darkTheme": "dark"}})).unwrap();
|
||||
app.hooks.system_theme = Some(Box::new(|_| Some(egui::Theme::Dark)));
|
||||
for (mode, shown) in [("auto", ThemeKind::Medium), ("light", ThemeKind::Light), ("dark", ThemeKind::Medium), ("auto", ThemeKind::Medium)] {
|
||||
cycle_appearance(&mut app, &ctx);
|
||||
assert_eq!(app.session.prefs.appearance.appearance_mode, mode);
|
||||
assert_eq!(app.tokens.kind, shown, "{mode}");
|
||||
assert_eq!((app.session.prefs.appearance.dark_theme.as_str(), app.session.prefs.appearance.light_theme.as_str()), ("dark", "light"));
|
||||
}
|
||||
// View ▸ Appearance picks a theme and fixes the mode to its family
|
||||
set_theme(&mut app, &ctx, ThemeKind::Light);
|
||||
assert_eq!((app.session.prefs.appearance.appearance_mode.as_str(), app.tokens.kind), ("light", ThemeKind::Light));
|
||||
set_theme(&mut app, &ctx, ThemeKind::Dark);
|
||||
assert_eq!((app.session.prefs.appearance.appearance_mode.as_str(), app.tokens.kind), ("dark", ThemeKind::Dark));
|
||||
assert_eq!(app.session.prefs.appearance.dark_theme, "darkest");
|
||||
}
|
||||
|
||||
/// Auto follows the host's reading of the system appearance as it changes, and falls back to
|
||||
/// dark without an answer.
|
||||
#[test]
|
||||
fn auto_follows_a_stubbed_system_theme() {
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = FilmcraftApp::new(filmcraft_engine::Session::default());
|
||||
let system = Arc::new(AtomicU8::new(2));
|
||||
let read = system.clone();
|
||||
app.hooks.system_theme = Some(Box::new(move |_| match read.load(Ordering::Relaxed) {
|
||||
1 => Some(egui::Theme::Dark),
|
||||
2 => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}));
|
||||
app.session.execute("prefs.set", json!({"key": "appearance.appearanceMode", "value": "auto"})).unwrap();
|
||||
app.apply_prefs(&ctx);
|
||||
assert_eq!(app.tokens.kind, ThemeKind::Light);
|
||||
assert!(!app.ui.dark);
|
||||
assert_eq!(ctx.theme(), egui::Theme::Light, "egui's style follows FilmCraft's choice");
|
||||
system.store(1, Ordering::Relaxed);
|
||||
app.apply_prefs(&ctx);
|
||||
assert_eq!(app.tokens.kind, ThemeKind::Dark);
|
||||
assert_eq!(ctx.theme(), egui::Theme::Dark);
|
||||
system.store(2, Ordering::Relaxed);
|
||||
app.apply_prefs(&ctx);
|
||||
assert_eq!(app.tokens.kind, ThemeKind::Light);
|
||||
system.store(0, Ordering::Relaxed);
|
||||
app.apply_prefs(&ctx);
|
||||
assert_eq!(app.tokens.kind, ThemeKind::Dark, "no answer: dark");
|
||||
// a fixed mode ignores the system
|
||||
app.session.execute("prefs.set", json!({"key": "appearance.appearanceMode", "value": "dark"})).unwrap();
|
||||
system.store(2, Ordering::Relaxed);
|
||||
app.apply_prefs(&ctx);
|
||||
assert_eq!(app.tokens.kind, ThemeKind::Dark);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_lists_changed_leaves() {
|
||||
|
|
|
|||
|
|
@ -436,7 +436,11 @@ fn add_craft_fonts(fonts: &mut FontDefinitions) {
|
|||
}
|
||||
|
||||
pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) {
|
||||
let mut v = if t.kind == ThemeKind::Light { Visuals::light() } else { Visuals::dark() };
|
||||
let light = t.kind == ThemeKind::Light;
|
||||
// FilmCraft decides between light and dark itself (Settings ▸ Appearance ▸ Appearance Mode), so
|
||||
// egui must not switch to its other built-in style when the system appearance changes.
|
||||
ctx.set_theme(if light { egui::Theme::Light } else { egui::Theme::Dark });
|
||||
let mut v = if light { Visuals::light() } else { Visuals::dark() };
|
||||
v.panel_fill = t.panel_bg;
|
||||
v.window_fill = t.panel_bg;
|
||||
v.extreme_bg_color = t.field_bg;
|
||||
|
|
|
|||
|
|
@ -251,9 +251,12 @@ fn view_menu_and_settings_name_each_color_theme_alike() {
|
|||
let menu = d.ok("ui.menu.list", json!({}));
|
||||
let menu_label = |id: &str| menu.as_array().unwrap().iter().find(|m| m["id"] == id).unwrap()["label"].as_str().unwrap().to_string();
|
||||
d.menu("app.settings.appearance");
|
||||
d.click("settings.appearance.colorTheme");
|
||||
for (menu_id, pref) in [("view.theme.dark", "darkest"), ("view.theme.medium", "dark"), ("view.theme.light", "light")] {
|
||||
let id = format!("settings.appearance.colorTheme.{pref}");
|
||||
// the theme cards' choices (the single Color Theme is no longer drawn)
|
||||
for (menu_id, id) in [
|
||||
("view.theme.dark", "settings.appearance.darkTheme.darkest"),
|
||||
("view.theme.medium", "settings.appearance.darkTheme.dark"),
|
||||
("view.theme.light", "settings.appearance.lightTheme.light"),
|
||||
] {
|
||||
let found = d.ok("ui.elements", json!({"prefix": id}));
|
||||
let choice = found.as_array().unwrap().iter().find(|e| e["id"] == json!(id)).unwrap();
|
||||
assert_eq!(menu_label(menu_id), choice["label"].as_str().unwrap(), "{menu_id} and {id} name the same theme");
|
||||
|
|
@ -265,17 +268,39 @@ fn appearance_labels_and_tooltips_take_effect() {
|
|||
let mut d = Driver::demo();
|
||||
assert_eq!(d.inspect()["ui"]["dark"], true);
|
||||
d.menu("app.settings.appearance");
|
||||
d.click("settings.appearance.colorTheme");
|
||||
d.click("settings.appearance.colorTheme.light");
|
||||
// the Appearance Mode selector above the light and dark theme cards
|
||||
assert!(d.has("settings.appearance.lightTheme.light") && d.has("settings.appearance.darkTheme.dark"));
|
||||
assert!(!d.has("settings.appearance.colorTheme"), "the single Color Theme is not shown");
|
||||
// Each radio belongs below its preview, even though the cards are laid out side by side.
|
||||
let elements = d.ok("ui.elements", json!({"prefix": "settings.appearance."}));
|
||||
let top = |id: &str| elements.as_array().unwrap().iter().find(|e| e["id"] == id).unwrap()["rect"][1].as_f64().unwrap();
|
||||
for (card, radio) in
|
||||
[("settings.appearance.lightTheme", "settings.appearance.lightTheme.light"), ("settings.appearance.darkTheme", "settings.appearance.darkTheme.darkest")]
|
||||
{
|
||||
assert!(top(radio) > top(card) + 128.0, "{radio} sits below its preview");
|
||||
}
|
||||
d.click("settings.appearance.darkTheme.dark");
|
||||
d.click("settings.appearance.appearanceMode");
|
||||
d.click("settings.appearance.appearanceMode.light");
|
||||
d.ok("ui.set", json!({"settings": {"values": {"appearance.highlightColor": "#e0457b"}}}));
|
||||
d.click("settings.ok");
|
||||
d.frames(2);
|
||||
assert_eq!(d.inspect()["ui"]["dark"], false, "Light theme applied");
|
||||
assert_eq!((d.pref("appearance.appearanceMode"), d.pref("appearance.darkTheme")), (json!("light"), json!("dark")));
|
||||
// the header button cycles Auto, Light, Dark
|
||||
d.click("header.appearance");
|
||||
assert_eq!(d.pref("appearance.appearanceMode"), "dark");
|
||||
assert_eq!(d.harness.state().tokens.kind, filmcraft_ui_egui::theme::ThemeKind::Medium, "the saved dark theme");
|
||||
d.click("header.appearance");
|
||||
assert_eq!(d.pref("appearance.appearanceMode"), "auto");
|
||||
d.menu("view.appearanceMode.next");
|
||||
assert_eq!(d.pref("appearance.appearanceMode"), "light");
|
||||
assert_eq!(d.pref("appearance.highlightColor"), "#e0457b");
|
||||
assert_eq!(d.harness.state().tokens.accent, egui::Color32::from_rgb(0xe0, 0x45, 0x7b));
|
||||
// View ▸ Appearance writes the setting
|
||||
d.menu("view.theme.dark");
|
||||
assert_eq!(d.pref("appearance.colorTheme"), "darkest");
|
||||
assert_eq!((d.pref("appearance.appearanceMode"), d.pref("appearance.darkTheme")), (json!("dark"), json!("darkest")));
|
||||
assert_eq!(d.inspect()["ui"]["dark"], true);
|
||||
|
||||
// label names show in Edit ▸ Label
|
||||
|
|
|
|||
|
|
@ -215,7 +215,7 @@ opens the dialog on a page.
|
|||
| Category | Takes effect |
|
||||
|---|---|
|
||||
| General | At Startup (Show Home = demo project, Open Most Recent, empty project; recent projects are remembered on open/save), Show Tool Tips |
|
||||
| Appearance | Color Theme (Darkest / Dark / Light; View ▸ Appearance writes it too), highlight colour, accessible contrast |
|
||||
| Appearance | Appearance Mode `appearanceMode` (`auto` = Sync with system / `dark` (default) / `light`; the header button and View ▸ Appearance ▸ Next Appearance Mode cycle it), Dark Theme `darkTheme` (`darkest` (default) / `dark`), Light Theme `lightTheme` (`light`), highlight colour, accessible contrast. `colorTheme` (Darkest / Dark / Light) is legacy input: setting it (older files and clients, View ▸ Appearance ▸ a theme) selects that theme and fixes the mode to its family; a file without `appearanceMode` migrates to its theme's fixed mode. It is kept in line with the choices: the theme a fixed mode shows, the dark theme in Auto |
|
||||
| Audio | Automatch Time, Large Volume Adjustment, automation keyframe thinning (linear, minimum time) |
|
||||
| Audio Hardware | device class (cpal host), output device, I/O buffer size, sample rate, force document rate, Output Mapping (programme L/R → device channels) |
|
||||
| Auto Save | the auto-save ring and the crash-recovery journal |
|
||||
|
|
|
|||
Loading…
Reference in New Issue