diff --git a/.debrand b/.debrand index f3b4212c..080dbdc5 100644 --- a/.debrand +++ b/.debrand @@ -22,3 +22,7 @@ sub the Tools bar and About dialog carry the ArtCraft mark => the Tools bar and forbid (?i)artcraft(?! Team and the EffectCraft contributors) forbid (?i)discord sub ffmpeg export, Discord presence, => ffmpeg export, +dropline ^crates/ui-egui/src/i18n(?:\.rs|/ui\.rs)$ :: \s*\(".*(?:ArtCraft|Discord) +sub (?m)^ \(\n "[^"\n]*ArtCraft[^"\n]*",\n "[^"\n]*",\n \),\n => +sub \(#612, reported on Discord\) => (#612, reported by a user) +sub Reported on Discord: => Reported by a user: diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 83015f1a..6ecc9a4c 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -45,6 +45,21 @@ fn app_icon() -> egui::IconData { /// Both native JSON projects and Save a Copy As XML projects are openable on launch. /// Keep this classification shared by project opening and the subsequent media-import pass. +/// Windows and Linux: no OS title bar; the menu bar row is the title bar +/// (`effectcraft_ui_egui::titlebar`). macOS keeps its traffic lights over the integrated top row. +const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos"); + +/// Whether this start draws its own title bar: Windows and Linux do, unless Settings ▸ Appearance ▸ +/// Use System Title Bar asks for the system's. Read from the saved settings before the window +/// opens; a missing or unreadable file keeps the default. +fn custom_titlebar(config_dir: Option<&std::path::Path>) -> bool { + let prefs = config_dir + .and_then(|d| std::fs::read_to_string(d.join(effectcraft_engine::prefs::PREFS_FILE)).ok()) + .map(|t| effectcraft_engine::prefs::Prefs::from_json(&t)) + .unwrap_or_default(); + CUSTOM_TITLEBAR && !prefs.appearance.system_title_bar +} + fn is_project_file(path: &str) -> bool { std::path::Path::new(path) .extension() @@ -117,12 +132,17 @@ fn main() -> eframe::Result { let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), std::env::var_os("WGPU_BACKEND").is_some()); let on_gl = launch.backends == Some(eframe::wgpu::Backends::GL); let notice = launch.notice.take(); + let custom_titlebar = custom_titlebar(effectcraft_host::config_dir().as_deref()); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("EffectCraft") .with_inner_size(window::DEFAULT_SIZE) .with_min_inner_size(window::MIN_SIZE) .with_drag_and_drop(true) + .with_decorations(!custom_titlebar) + .with_fullsize_content_view(true) + .with_titlebar_shown(false) + .with_title_shown(false) // Started for an agent (`--control`): open without taking the user's keyboard focus. .with_active(control_port.is_none()) .with_icon(app_icon()) @@ -205,6 +225,8 @@ fn main() -> eframe::Result { // reported in a toast and forgotten). let _ = session.execute("window.restoreScriptPanels", json!({})); let mut app = EffectcraftApp::new(session); + app.custom_titlebar = custom_titlebar; + app.integrated_titlebar = cfg!(target_os = "macos"); app.set_gpu_failure_bridge(gpu_failures); app.ui.start_screen = show_home; if let Some(n) = notice { @@ -424,7 +446,20 @@ fn disable_app_nap() { #[cfg(test)] mod tests { - use super::{Args, parse_args}; + use super::{Args, custom_titlebar, parse_args}; + + #[test] + fn the_system_title_bar_setting_keeps_the_window_decorations() { + let dir = std::env::temp_dir().join(format!("ec-titlebar-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join(effectcraft_engine::prefs::PREFS_FILE); + assert_eq!(custom_titlebar(None), super::CUSTOM_TITLEBAR, "no settings folder: the default"); + std::fs::write(&file, "not json").unwrap(); + assert_eq!(custom_titlebar(Some(&dir)), super::CUSTOM_TITLEBAR, "an unreadable file: the default"); + std::fs::write(&file, r#"{"appearance":{"systemTitleBar":true}}"#).unwrap(); + assert!(!custom_titlebar(Some(&dir))); + std::fs::remove_dir_all(&dir).ok(); + } fn parse(args: &[&str]) -> Option { parse_args(args.iter().map(|a| a.to_string()), None) diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs index 06278c2c..e5cb0de5 100644 --- a/crates/engine/src/commands/frontend.rs +++ b/crates/engine/src/commands/frontend.rs @@ -37,6 +37,16 @@ pub fn specs() -> Vec { fe!("app.keyboardShortcuts", "Keyboard Shortcuts", ["Edit"], Some("Cmd+Alt+'"), "{}", always), fe!("app.templates", "Templates", [], None, "{kind: renderSettings|outputModule}", always), fe!("app.find", "Find", ["File"], Some("Cmd+F"), "{query?}", always), + // Themes (View ▸ Preferences ▸ Theme): `view.theme.toggle` cycles the appearance mode, the + // others show that theme and fix the mode to its family (see `appearance.theme`). + fe!("view.theme.toggle", "Next Appearance Mode", [], None, "{}", always), + fe!("view.theme.dark", "Theme: Dark", [], None, "{}", always), + fe!("view.theme.darker", "Theme: Darker", [], None, "{}", always), + fe!("view.theme.light", "Theme: Light", [], None, "{}", always), + fe!("view.theme.catppuccinLatte", "Theme: Catppuccin Latte", [], None, "{}", always), + fe!("view.theme.catppuccinFrappe", "Theme: Catppuccin Frappé", [], None, "{}", always), + fe!("view.theme.catppuccinMacchiato", "Theme: Catppuccin Macchiato", [], None, "{}", always), + fe!("view.theme.catppuccinMocha", "Theme: Catppuccin Mocha", [], None, "{}", always), // Preview. fe!( "playback.toggle", diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 5c439888..bdf2fac5 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -1150,6 +1150,20 @@ View --- Go to Time... | time.set Enter Full Screen | view.fullScreen + --- + Preferences + Theme + Dark | view.theme.dark + Darker | view.theme.darker + Light | view.theme.light + --- + Catppuccin Latte | view.theme.catppuccinLatte + Catppuccin Frappé | view.theme.catppuccinFrappe + Catppuccin Macchiato | view.theme.catppuccinMacchiato + Catppuccin Mocha | view.theme.catppuccinMocha + --- + Next Appearance Mode | view.theme.toggle + Appearance... | app.settings {"page":"appearance"} Window Workspace Default | window.workspace {"name":"Default"} | Shift+F10 diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 3abf336b..ee3b78b0 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -46,13 +46,27 @@ pub const WINDOW_GRAPHICS: &[(&str, &str)] = &[("Automatic", "auto"), ("OpenGL ( pub const APPEARANCE_MODES: &[(&str, &str)] = &[("Sync with System", "auto"), ("Dark", "dark"), ("Light", "light")]; /// The dark family of themes: (label, `appearance.darkTheme` value). -pub const DARK_THEMES: &[(&str, &str)] = &[("Dark", "dark"), ("Darker", "darker")]; +pub const DARK_THEMES: &[(&str, &str)] = &[ + ("Dark", "dark"), + ("Darker", "darker"), + ("Catppuccin Frappé", "catppuccinFrappe"), + ("Catppuccin Macchiato", "catppuccinMacchiato"), + ("Catppuccin Mocha", "catppuccinMocha"), +]; /// The light family of themes: (label, `appearance.lightTheme` value). -pub const LIGHT_THEMES: &[(&str, &str)] = &[("Light", "light")]; +pub const LIGHT_THEMES: &[(&str, &str)] = &[("Light", "light"), ("Catppuccin Latte", "catppuccinLatte")]; /// Every theme (the legacy single-theme `appearance.theme`). -pub const THEMES: &[(&str, &str)] = &[("Dark", "dark"), ("Darker", "darker"), ("Light", "light")]; +pub const THEMES: &[(&str, &str)] = &[ + ("Dark", "dark"), + ("Darker", "darker"), + ("Light", "light"), + ("Catppuccin Latte", "catppuccinLatte"), + ("Catppuccin Frappé", "catppuccinFrappe"), + ("Catppuccin Macchiato", "catppuccinMacchiato"), + ("Catppuccin Mocha", "catppuccinMocha"), +]; /// The settings whose value must be one of their choices. fn choices(key: &str) -> Option<&'static [(&'static str, &'static str)]> { @@ -188,6 +202,9 @@ page!(Appearance { use_gradients: bool = true, /// macOS: draw the menu bar inside the window instead of the system menu bar. in_window_menu_bar_mac: bool = false, + /// Windows and Linux: keep the system's title bar instead of drawing the window's own. + /// Read before the window opens: it applies from the next launch. + system_title_bar: bool = false, }); page!(Grids { @@ -1242,6 +1259,8 @@ pub fn pages() -> Vec { s("appearance.useGradients", "Use Gradients", B, true), Section("Menu Bar"), s("appearance.inWindowMenuBarMac", "Use In-Window Menu Bar on macOS", B, true), + Section("Title Bar"), + s("appearance.systemTitleBar", "Use System Title Bar on Windows and Linux", B, true), ], }, Page { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index f874903d..e3e232e2 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -413,6 +413,15 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[ ("app.templates", "Output Module...", "输出模块..."), ("app.keyboardShortcuts", "Keyboard Shortcuts", "键盘快捷键"), ("", "Preferences", "首选项"), + ("", "Theme", "主题"), + ("view.theme.dark", "Dark", "深色"), + ("view.theme.darker", "Darker", "更深"), + ("view.theme.light", "Light", "浅色"), + ("view.theme.catppuccinLatte", "Catppuccin Latte", "Catppuccin Latte"), + ("view.theme.catppuccinFrappe", "Catppuccin Frappé", "Catppuccin Frappé"), + ("view.theme.catppuccinMacchiato", "Catppuccin Macchiato", "Catppuccin Macchiato"), + ("view.theme.catppuccinMocha", "Catppuccin Mocha", "Catppuccin Mocha"), + ("view.theme.toggle", "Next Appearance Mode", "下一个外观模式"), ("", "Composition", "合成"), ("comp.new", "New Composition...", "新建合成..."), ("comp.settings", "Composition Settings...", "合成设置..."), @@ -1040,6 +1049,15 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[ ("app.templates", "Output Module...", "輸出模組..."), ("app.keyboardShortcuts", "Keyboard Shortcuts", "鍵盤快速鍵"), ("", "Preferences", "偏好設定"), + ("", "Theme", "主題"), + ("view.theme.dark", "Dark", "深色"), + ("view.theme.darker", "Darker", "更深"), + ("view.theme.light", "Light", "淺色"), + ("view.theme.catppuccinLatte", "Catppuccin Latte", "Catppuccin Latte"), + ("view.theme.catppuccinFrappe", "Catppuccin Frappé", "Catppuccin Frappé"), + ("view.theme.catppuccinMacchiato", "Catppuccin Macchiato", "Catppuccin Macchiato"), + ("view.theme.catppuccinMocha", "Catppuccin Mocha", "Catppuccin Mocha"), + ("view.theme.toggle", "Next Appearance Mode", "下一個外觀模式"), ("", "Composition", "合成"), ("comp.new", "New Composition...", "新增合成..."), ("comp.settings", "Composition Settings...", "合成設定..."), @@ -1664,6 +1682,15 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("app.templates", "Output Module...", "出力モジュール..."), ("app.keyboardShortcuts", "Keyboard Shortcuts", "キーボードショートカット"), ("", "Preferences", "環境設定"), + ("", "Theme", "テーマ"), + ("view.theme.dark", "Dark", "ダーク"), + ("view.theme.darker", "Darker", "暗く"), + ("view.theme.light", "Light", "ライト"), + ("view.theme.catppuccinLatte", "Catppuccin Latte", "Catppuccin Latte"), + ("view.theme.catppuccinFrappe", "Catppuccin Frappé", "Catppuccin Frappé"), + ("view.theme.catppuccinMacchiato", "Catppuccin Macchiato", "Catppuccin Macchiato"), + ("view.theme.catppuccinMocha", "Catppuccin Mocha", "Catppuccin Mocha"), + ("view.theme.toggle", "Next Appearance Mode", "次の外観モード"), ("", "Composition", "コンポジション"), ("comp.new", "New Composition...", "新規コンポジション..."), ("comp.settings", "Composition Settings...", "コンポジション設定..."), @@ -2291,6 +2318,15 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[ ("app.templates", "Output Module...", "Модуль виведення..."), ("app.keyboardShortcuts", "Keyboard Shortcuts", "Клавіатурні скорочення"), ("", "Preferences", "Налаштування"), + ("", "Theme", "Тема"), + ("view.theme.dark", "Dark", "Темна"), + ("view.theme.darker", "Darker", "Темніше"), + ("view.theme.light", "Light", "Світла"), + ("view.theme.catppuccinLatte", "Catppuccin Latte", "Catppuccin Latte"), + ("view.theme.catppuccinFrappe", "Catppuccin Frappé", "Catppuccin Frappé"), + ("view.theme.catppuccinMacchiato", "Catppuccin Macchiato", "Catppuccin Macchiato"), + ("view.theme.catppuccinMocha", "Catppuccin Mocha", "Catppuccin Mocha"), + ("view.theme.toggle", "Next Appearance Mode", "Наступний режим вигляду"), ("", "Composition", "Композиція"), ("comp.new", "New Composition...", "Нова композиція..."), ("comp.settings", "Composition Settings...", "Налаштування композиції..."), @@ -2919,6 +2955,15 @@ const SPANISH: &[(&str, &str, &str)] = &[ ("app.templates", "Output Module...", "Módulo de salida..."), ("app.keyboardShortcuts", "Keyboard Shortcuts", "Métodos abreviados de teclado"), ("", "Preferences", "Preferencias"), + ("", "Theme", "Tema"), + ("view.theme.dark", "Dark", "Oscuro"), + ("view.theme.darker", "Darker", "Más oscuro"), + ("view.theme.light", "Light", "Claro"), + ("view.theme.catppuccinLatte", "Catppuccin Latte", "Catppuccin Latte"), + ("view.theme.catppuccinFrappe", "Catppuccin Frappé", "Catppuccin Frappé"), + ("view.theme.catppuccinMacchiato", "Catppuccin Macchiato", "Catppuccin Macchiato"), + ("view.theme.catppuccinMocha", "Catppuccin Mocha", "Catppuccin Mocha"), + ("view.theme.toggle", "Next Appearance Mode", "Siguiente modo de apariencia"), ("", "Composition", "Composición"), ("comp.new", "New Composition...", "Nueva composición..."), ("comp.settings", "Composition Settings...", "Configuración de la composición..."), @@ -3545,6 +3590,15 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[ ("app.templates", "Output Module...", "Módulo de saída..."), ("app.keyboardShortcuts", "Keyboard Shortcuts", "Atalhos de teclado"), ("", "Preferences", "Preferências"), + ("", "Theme", "Tema"), + ("view.theme.dark", "Dark", "Escuro"), + ("view.theme.darker", "Darker", "Mais escuro"), + ("view.theme.light", "Light", "Claro"), + ("view.theme.catppuccinLatte", "Catppuccin Latte", "Catppuccin Latte"), + ("view.theme.catppuccinFrappe", "Catppuccin Frappé", "Catppuccin Frappé"), + ("view.theme.catppuccinMacchiato", "Catppuccin Macchiato", "Catppuccin Macchiato"), + ("view.theme.catppuccinMocha", "Catppuccin Mocha", "Catppuccin Mocha"), + ("view.theme.toggle", "Next Appearance Mode", "Próximo modo de aparência"), ("", "Composition", "Composição"), ("comp.new", "New Composition...", "Nova composição..."), ("comp.settings", "Composition Settings...", "Configurações da composição..."), @@ -4635,9 +4689,7 @@ const RUSSIAN: &[(&str, &str, &str)] = &[ ("help.systemReport", "System Compatibility Report...", "Отчет о совместимости системы..."), ("help.enableLogging", "Enable Logging", "Включить ведение журнала"), ("help.revealLogFile", "Reveal Logging File", "Показать файл журнала"), - ("help.discord", "Join the ArtCraft Discord...", "Присоединяйтесь к Discord ArtCraft..."), ("help.reportIssue", "Provide Feedback...", "Оставить отзыв..."), - ("help.website", "ArtCraft Website", "Сайт ArtCraft"), ("help.appPage", "EffectCraft Home Page", "Главная страница EffectCraft"), ("help.github", "EffectCraft on GitHub", "EffectCraft на GitHub"), ("file.openDemoProject", "Open Demo Project", "Открыть демонстрационный проект"), @@ -5264,9 +5316,7 @@ const FRENCH: &[(&str, &str, &str)] = &[ ("help.systemReport", "System Compatibility Report...", "Rapport de compatibilité du système..."), ("help.enableLogging", "Enable Logging", "Activer la journalisation"), ("help.revealLogFile", "Reveal Logging File", "Révéler le fichier journal"), - ("help.discord", "Join the ArtCraft Discord...", "Rejoindre le Discord ArtCraft..."), ("help.reportIssue", "Provide Feedback...", "Envoyer des commentaires..."), - ("help.website", "ArtCraft Website", "Site web ArtCraft"), ("help.appPage", "EffectCraft Home Page", "Page d’accueil EffectCraft"), ("help.github", "EffectCraft on GitHub", "EffectCraft sur GitHub"), ("file.openDemoProject", "Open Demo Project", "Ouvrir le projet de démonstration"), @@ -5893,9 +5943,7 @@ const GERMAN: &[(&str, &str, &str)] = &[ ("help.systemReport", "System Compatibility Report...", "Bericht zur Systemkompatibilität..."), ("help.enableLogging", "Enable Logging", "Protokollierung aktivieren"), ("help.revealLogFile", "Reveal Logging File", "Protokolldatei anzeigen"), - ("help.discord", "Join the ArtCraft Discord...", "ArtCraft-Discord beitreten..."), ("help.reportIssue", "Provide Feedback...", "Feedback senden..."), - ("help.website", "ArtCraft Website", "ArtCraft-Website"), ("help.appPage", "EffectCraft Home Page", "EffectCraft-Startseite"), ("help.github", "EffectCraft on GitHub", "EffectCraft auf GitHub"), ("file.openDemoProject", "Open Demo Project", "Demoprojekt öffnen"), @@ -6523,9 +6571,7 @@ const CZECH: &[(&str, &str, &str)] = &[ ("help.systemReport", "System Compatibility Report...", "Zpráva o kompatibilitě systému..."), ("help.enableLogging", "Enable Logging", "Povolit protokolování"), ("help.revealLogFile", "Reveal Logging File", "Zobrazit soubor protokolu"), - ("help.discord", "Join the ArtCraft Discord...", "Připojit se na Discord ArtCraft..."), ("help.reportIssue", "Provide Feedback...", "Poskytnout zpětnou vazbu..."), - ("help.website", "ArtCraft Website", "Web ArtCraft"), ("help.appPage", "EffectCraft Home Page", "Domovská stránka EffectCraft"), ("help.github", "EffectCraft on GitHub", "EffectCraft na GitHubu"), ("file.openDemoProject", "Open Demo Project", "Otevřít ukázkový projekt"), diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 2821f134..024315ee 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -522,6 +522,11 @@ const JAPANESE: &[(&str, &str)] = &[ ("⌘/Ctrl-drag a pin to record its motion", "⌘/Ctrl+ドラッグでピンの動きを記録"), ("▣ {}", "▣ {}"), ("⚠ {} is used by:", "⚠ {}は次のコマンドで使用中:"), + ("Minimize", "最小化"), + ("Maximize", "最大化"), + ("Restore", "元に戻す"), + ("Title Bar", "タイトルバー"), + ("Use System Title Bar on Windows and Linux", "Windows と Linux でシステムのタイトルバーを使用"), ]; const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ @@ -1052,6 +1057,11 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("After Effects project", "After Effects 项目"), ("Lottie animation", "Lottie 动画"), ("Project", "项目"), + ("Minimize", "最小化"), + ("Maximize", "最大化"), + ("Restore", "还原"), + ("Title Bar", "标题栏"), + ("Use System Title Bar on Windows and Linux", "在 Windows 和 Linux 上使用系统标题栏"), ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ @@ -1577,6 +1587,11 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("After Effects project", "After Effects 專案"), ("Lottie animation", "Lottie 動畫"), ("Project", "專案"), + ("Minimize", "最小化"), + ("Maximize", "最大化"), + ("Restore", "還原"), + ("Title Bar", "標題列"), + ("Use System Title Bar on Windows and Linux", "在 Windows 和 Linux 上使用系統標題列"), ]; /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. @@ -2084,7 +2099,6 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Import PDF / Illustrator File", "Імпорт файлу PDF / Illustrator"), ("Import Photoshop File", "Імпорт файлу Photoshop"), ("Interpret Unlabeled Alpha As", "Інтерпретувати непозначену альфу як"), - ("Join the ArtCraft Discord", "Приєднатися до Discord ArtCraft"), ("Mercury GPU Acceleration ({})", "Прискорення Mercury GPU ({})"), ("Mercury Software Only", "Лише програмний Mercury"), ("OCIO (built-in ACES config)", "OCIO (вбудована конфігурація ACES)"), @@ -2130,6 +2144,11 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("After Effects project", "Проєкт After Effects"), ("Lottie animation", "Анімація Lottie"), ("Project", "Проєкт"), + ("Minimize", "Згорнути"), + ("Maximize", "Розгорнути"), + ("Restore", "Відновити"), + ("Title Bar", "Рядок заголовка"), + ("Use System Title Bar on Windows and Linux", "Використовувати системний рядок заголовка у Windows і Linux"), ]; // Brazilian Portuguese. @@ -2140,10 +2159,6 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("(missing)", "(ausente)"), ("360 View Orientation", "Orientação da vista 360°"), ("3:2 Pulldown: {}", "Pulldown 3:2: {}"), - ( - "A clean-room, open-source compositor for motion graphics and visual effects: native on macOS, Windows and Linux, and in the browser. Part of the ArtCraft family of creative apps.", - "Um compositor de código aberto, desenvolvido em clean-room, para motion graphics e efeitos visuais: nativo no macOS, Windows e Linux, e no navegador. Parte da família de aplicativos criativos ArtCraft.", - ), ("About EffectCraft", "Sobre o EffectCraft"), ("About...", "Sobre..."), ("Active Camera", "Câmera ativa"), @@ -2237,7 +2252,6 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Delete selected project items", "Excluir itens de projeto selecionados"), ("Delete this template", "Excluir este modelo"), ("Depth of Field", "Profundidade de campo"), - ("Discord", "Discord"), ("Distribute Layers:", "Distribuir camadas:"), ("Don't Recover", "Não recuperar"), ("Don't Save", "Não salvar"), @@ -2308,7 +2322,6 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Install from File…", "Instalar a partir de arquivo…"), ("Intensity", "Intensidade"), ("Inverted", "Invertido"), - ("Join the ArtCraft community on Discord", "Entre na comunidade ArtCraft no Discord"), ("Key: {}", "Tecla: {}"), ("LEARN", "APRENDER"), ("Label", "Rótulo"), @@ -2344,7 +2357,6 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Models folder: {}", "Pasta de modelos: {}"), ("Modifiers:", "Modificadores:"), ("More", "Mais"), - ("More ArtCraft apps", "Mais aplicativos ArtCraft"), ("More tutorials", "Mais tutoriais"), ("Motion Blur", "Desfoque de movimento"), ("Motion Blur: {}", "Desfoque de movimento: {}"), @@ -2574,10 +2586,6 @@ const CZECH: &[(&str, &str)] = &[ ("(missing)", "(chybí)"), ("360 View Orientation", "Orientace zobrazení 360°"), ("3:2 Pulldown: {}", "Převod 3:2 pulldown: {}"), - ( - "A clean-room, open-source compositor for motion graphics and visual effects: native on macOS, Windows and Linux, and in the browser. Part of the ArtCraft family of creative apps.", - "Kompoziční nástroj s otevřeným zdrojovým kódem pro pohyblivou grafiku a vizuální efekty, vyvinutý nezávisle metodou clean-room. Běží nativně na macOS, Windows a Linuxu i v prohlížeči. Patří do rodiny kreativních aplikací ArtCraft.", - ), ("About EffectCraft", "O aplikaci EffectCraft"), ("About...", "O aplikaci…"), ("Active", "Aktivní"), @@ -2678,7 +2686,6 @@ const CZECH: &[(&str, &str)] = &[ ("Delete selected project items", "Odstranit vybrané položky projektu"), ("Delete this template", "Odstranit tuto šablonu"), ("Depth of Field", "Hloubka ostrosti"), - ("Discord", "Discord"), ("Distribute Layers:", "Rozmístit vrstvy:"), ("Don't Recover", "Neobnovovat"), ("Don't Save", "Neukládat"), @@ -2750,7 +2757,6 @@ const CZECH: &[(&str, &str)] = &[ ("Install from File…", "Nainstalovat ze souboru…"), ("Intensity", "Intenzita"), ("Inverted", "Invertovaná"), - ("Join the ArtCraft community on Discord", "Připojit se ke komunitě ArtCraft na Discordu"), ("Key: {}", "Klávesa: {}"), ("LEARN", "VÝUKA"), ("Label", "Štítek"), @@ -2788,7 +2794,6 @@ const CZECH: &[(&str, &str)] = &[ ("Models folder: {}", "Složka modelů: {}"), ("Modifiers:", "Modifikátory:"), ("More", "Další"), - ("More ArtCraft apps", "Další aplikace ArtCraft"), ("More tutorials", "Další výukové lekce"), ("Motion Blur", "Rozostření pohybem"), ("Motion Blur: {}", "Rozostření pohybem: {}"), @@ -3048,7 +3053,6 @@ const CZECH: &[(&str, &str)] = &[ ("Import PDF / Illustrator File", "Importovat soubor PDF / Illustrator"), ("Import Photoshop File", "Importovat soubor Photoshop"), ("Interpret Unlabeled Alpha As", "Interpretovat neoznačenou alfu jako"), - ("Join the ArtCraft Discord", "Připojit se na Discord ArtCraft"), ("Mercury GPU Acceleration ({})", "GPU akcelerace Mercury ({})"), ("Mercury Software Only", "Pouze softwarové zpracování Mercury"), ("OCIO (built-in ACES config)", "OCIO (vestavěná konfigurace ACES)"), @@ -3111,6 +3115,7 @@ mod tests { ("color_picker.rs", include_str!("../color_picker.rs")), ("credits.rs", include_str!("../credits.rs")), ("header.rs", include_str!("../header.rs")), + ("titlebar.rs", include_str!("../titlebar.rs")), ("menus.rs", include_str!("../menus.rs")), ("widgets.rs", include_str!("../widgets.rs")), ("panels/anim_tools.rs", include_str!("../panels/anim_tools.rs")), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 746d917b..afd25e0d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -30,6 +30,7 @@ mod pointer_wrap; pub mod prefs_live; pub mod state; pub mod theme; +pub mod titlebar; pub mod widgets; mod workspace_state; @@ -318,7 +319,10 @@ pub struct EffectcraftApp { /// Pointer position in comp pixels (Info panel). pub(crate) pointer_comp: Option<[f32; 2]>, pub(crate) dialog_state: panels::dialogs::DialogState, + /// macOS: the traffic lights sit over the top row (no system title bar). pub integrated_titlebar: bool, + /// Windows and Linux: no OS decorations; the menu bar row is the title bar ([`titlebar`]). + pub custom_titlebar: bool, /// Audio preview while playing (audio clock drives playback). pub audio: Option, /// Audio scrubbing's open output (Ctrl/Cmd-drag the current time, `playback.scrubAudio`). @@ -409,6 +413,7 @@ impl EffectcraftApp { pointer_comp: None, dialog_state: Default::default(), integrated_titlebar: false, + custom_titlebar: false, audio: None, scrub: None, meter: Default::default(), @@ -1661,15 +1666,28 @@ impl EffectcraftApp { } 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)); + if self.ui.show_menu_bar || self.custom_titlebar { + let h = if self.custom_titlebar { titlebar::HEIGHT } else { 24.0 }; + let mb = egui::Rect::from_min_size(full.min, egui::vec2(full.width(), h)); + if self.custom_titlebar || self.integrated_titlebar { + titlebar::drag_area(ui, mb); + } 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); + if self.ui.show_menu_bar { + menus::menu_bar(self, &mut child); + } + if self.custom_titlebar { + titlebar::caption_buttons(self, ui, mb); + } 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)); + // macOS without a menu row: the Tools bar is the title bar, under the traffic lights. + if self.integrated_titlebar && !self.ui.show_menu_bar { + titlebar::drag_area(ui, header); + } 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); @@ -1686,6 +1704,9 @@ impl EffectcraftApp { panels::learn::coach(self, &ctx); let lift = panels::media_panels::import_card(self, ui, full); self.draw_toast(ui, full, lift); + if self.custom_titlebar { + titlebar::resize_zones(ui); + } 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(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b10b0e31..e0324e93 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -46,10 +46,6 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("view.safeMargins", "Title/Action Safe", [], None), uic!("view.transparencyGrid", "Transparency Grid", [], None), uic!("view.fastPreviews", "Fast Previews", [], None), - uic!("view.theme.toggle", "Next Appearance Mode", [], None), - uic!("view.theme.dark", "Theme: Dark", [], None), - uic!("view.theme.darker", "Theme: Darker", [], None), - uic!("view.theme.light", "Theme: Light", [], None), uic!("timeline.zoomIn", "Zoom In Time", [], Some("=")), uic!("timeline.zoomOut", "Zoom Out Time", [], Some("-")), uic!("timeline.zoomFit", "Zoom to Fit Comp", [], None), @@ -1833,7 +1829,8 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) { let mut clicked: Option<(String, Value)> = None; let mut nav = crate::menu_keys::Nav::default(); ui.horizontal_centered(|ui| { - ui.add_space(6.0); + // macOS: clear of the traffic lights over the row. + ui.add_space(if app.integrated_titlebar { crate::titlebar::MAC_INSET } else { 6.0 }); egui::MenuBar::new().config(egui::containers::menu::MenuConfig::new().style(crate::widgets::menu_style)).ui(ui, |ui| { for node in effectcraft_engine::menus::menu_bar() { if let MenuNode::Submenu { label, children } = node { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index abc6f576..c2440784 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -6,6 +6,7 @@ use std::sync::Arc; +use craft_ui::theme::catppuccin::Flavor; use egui::{Color32, FontData, FontDefinitions, FontFamily, FontId, Stroke, TextStyle, Visuals}; use serde::{Deserialize, Serialize}; @@ -15,15 +16,29 @@ pub enum ThemeKind { Dark, Darker, Light, + CatppuccinLatte, + CatppuccinFrappe, + CatppuccinMacchiato, + CatppuccinMocha, } impl ThemeKind { + pub const ALL: [ThemeKind; 7] = [ + ThemeKind::Dark, + ThemeKind::Darker, + ThemeKind::Light, + ThemeKind::CatppuccinLatte, + ThemeKind::CatppuccinFrappe, + ThemeKind::CatppuccinMacchiato, + ThemeKind::CatppuccinMocha, + ]; + pub fn from_name(s: &str) -> Option { - match s.to_ascii_lowercase().as_str() { - "dark" | "default" => Some(ThemeKind::Dark), - "darker" | "darkest" => Some(ThemeKind::Darker), - "light" => Some(ThemeKind::Light), - _ => None, + let s = s.to_ascii_lowercase(); + match s.as_str() { + "default" => Some(ThemeKind::Dark), + "darkest" => Some(ThemeKind::Darker), + _ => Self::ALL.into_iter().find(|k| k.name().eq_ignore_ascii_case(&s)), } } @@ -33,11 +48,26 @@ impl ThemeKind { ThemeKind::Dark => "dark", ThemeKind::Darker => "darker", ThemeKind::Light => "light", + ThemeKind::CatppuccinLatte => "catppuccinLatte", + ThemeKind::CatppuccinFrappe => "catppuccinFrappe", + ThemeKind::CatppuccinMacchiato => "catppuccinMacchiato", + ThemeKind::CatppuccinMocha => "catppuccinMocha", + } + } + + /// The Catppuccin flavour behind this theme, if it is one. + pub fn flavor(self) -> Option { + match self { + ThemeKind::CatppuccinLatte => Some(Flavor::Latte), + ThemeKind::CatppuccinFrappe => Some(Flavor::Frappe), + ThemeKind::CatppuccinMacchiato => Some(Flavor::Macchiato), + ThemeKind::CatppuccinMocha => Some(Flavor::Mocha), + _ => None, } } pub fn is_light(self) -> bool { - self == ThemeKind::Light + matches!(self, ThemeKind::Light | ThemeKind::CatppuccinLatte) } } @@ -127,6 +157,59 @@ impl Tokens { } } +/// A Catppuccin flavour (accent Mauve), over `base`'s metrics and label colours. +fn catppuccin(base: Tokens, kind: ThemeKind, f: Flavor) -> Tokens { + let p = f.palette(); + Tokens { + kind, + app_bg: p.crust, + header_bg: p.mantle, + panel_bg: p.base, + tab_text: p.subtext0, + tab_text_active: p.text, + focus: p.mauve, + accent: p.mauve, + accent_hover: p.lavender, + text: p.text, + text_dim: p.subtext0, + text_faint: p.overlay1, + icon: p.subtext1, + icon_active: p.text, + hover: p.surface0, + pressed: p.surface1, + field_bg: p.mantle, + field_border: p.surface1, + separator: p.surface0, + row: p.base, + row_alt: p.mantle, + row_selected: p.surface1, + hot_text: p.mauve, + tl_bg: p.mantle, + tl_ruler_bg: p.base, + tl_ruler_tick: p.overlay0, + tl_ruler_text: p.subtext0, + cti: p.mauve, + work_area: p.surface1, + cache_green: p.green, + cache_blue: p.blue, + keyframe: p.subtext0, + keyframe_selected: p.mauve, + pasteboard: p.crust, + timecode: p.mauve, + danger: p.red, + warning: p.peach, + fx_header: p.surface0, + fx_header_active: p.surface1, + tl_graph_selected: p.surface0, + switch_well: p.mantle, + tl_nav_bar: p.surface2, + tl_work_area_bar: p.overlay0, + expr_bg: p.mantle, + expr_text: p.peach, + ..base + } +} + impl Tokens { pub fn for_kind(kind: ThemeKind) -> Self { let dark = Tokens { @@ -183,8 +266,11 @@ impl Tokens { row_h: 19.0, gradients: true, }; + if let Some(f) = kind.flavor() { + return catppuccin(dark, kind, f); + } match kind { - ThemeKind::Dark => dark, + ThemeKind::Dark | ThemeKind::CatppuccinLatte | ThemeKind::CatppuccinFrappe | ThemeKind::CatppuccinMacchiato | ThemeKind::CatppuccinMocha => dark, ThemeKind::Darker => Tokens { app_bg: Color32::from_rgb(0x08, 0x08, 0x08), header_bg: Color32::from_rgb(0x16, 0x16, 0x16), diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs new file mode 100644 index 00000000..1cb25641 --- /dev/null +++ b/crates/ui-egui/src/titlebar.rs @@ -0,0 +1,290 @@ +//! The window's own title bar on Windows and Linux, where the window has no OS decorations +//! ([`EffectcraftApp::custom_titlebar`]): the menu bar row is the title bar. Empty space in it +//! drags the window and a double-click there maximizes it, caption buttons at the far right +//! minimize, maximize/restore and close, and thin invisible zones along the window's edges resize +//! it. On macOS the traffic lights sit over the same row ([`MAC_INSET`]) and only the drag works. + +use egui::{CursorIcon, Id, LayerId, Order, PointerButton, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2}; +use serde_json::json; + +use crate::EffectcraftApp; +use crate::i18n::tr; + +/// One caption button: as wide as the native Windows ones. +pub const BUTTON_WIDTH: f32 = 46.0; +/// The three caption buttons together. +pub const WIDTH: f32 = 3.0 * BUTTON_WIDTH; +/// Height of the title row (menu bar) when it carries the caption buttons. +pub const HEIGHT: f32 = 32.0; +/// Room for the macOS traffic lights at the row's left end. +pub const MAC_INSET: f32 = 80.0; +/// Resize zone thickness along an edge, and the side of a corner zone. +const EDGE: f32 = 6.0; +const CORNER: f32 = 12.0; + +#[derive(Clone, Copy, PartialEq, Debug)] +enum Caption { + Minimize, + Maximize, + Close, +} + +/// Left to right. +const CAPTIONS: [Caption; 3] = [Caption::Minimize, Caption::Maximize, Caption::Close]; + +/// Caption button ids (stable, so tests and agents can find them). +fn caption_id(c: Caption) -> Id { + Id::new(("titlebar-caption", c as u8)) +} + +fn maximized(ctx: &egui::Context) -> bool { + ctx.input(|i| i.viewport().maximized.unwrap_or(false)) +} + +fn toggle_maximize(ctx: &egui::Context) { + ctx.send_viewport_cmd(ViewportCommand::Maximized(!maximized(ctx))); +} + +/// The bar's background: a press-and-drag on empty space moves the window, a double-click toggles +/// maximize. Call it before laying out the bar so its widgets, registered later, take the clicks. +pub fn drag_area(ui: &Ui, rect: Rect) { + let ctx = ui.ctx(); + let resp = ui.interact(rect, Id::new("titlebar-drag"), Sense::click_and_drag()); + // egui hands a drag that starts on a click-only widget (a menu title) to the drag-sensing + // background below it, so remember whether the press itself landed on bare background. + if ctx.input(|i| i.pointer.any_pressed()) { + let bare = ctx.viewport(|v| v.hits.click.is_some_and(|w| w.id == resp.id)); + ctx.data_mut(|d| d.insert_temp(resp.id, bare)); + } + if resp.double_clicked() { + toggle_maximize(ctx); + } else if resp.drag_started_by(PointerButton::Primary) && ctx.data(|d| d.get_temp(resp.id).unwrap_or(false)) { + ctx.send_viewport_cmd(ViewportCommand::StartDrag); + } +} + +/// Minimize, Maximize/Restore and Close at the right end of `bar` (the title row's outer rect), +/// full row height, flush with the window's edge so a maximized window's corner hits Close. Close +/// runs `app.quit`, which asks about unsaved changes first. +pub fn caption_buttons(app: &mut EffectcraftApp, ui: &mut Ui, bar: Rect) { + let t = app.tokens; + let max = maximized(ui.ctx()); + let mut clicked = None; + for (i, c) in CAPTIONS.into_iter().enumerate() { + let r = Rect::from_min_size(pos2(bar.right() - WIDTH + i as f32 * BUTTON_WIDTH, bar.top()), vec2(BUTTON_WIDTH, bar.height() - 1.0)); + let resp = ui.interact(r, caption_id(c), Sense::click()); + let close = c == Caption::Close; + let fill = match (resp.is_pointer_button_down_on(), resp.hovered()) { + (true, _) if close => t.danger.gamma_multiply(0.8), + (_, true) if close => t.danger, + (true, _) => t.pressed, + (_, true) => t.hover, + _ => egui::Color32::TRANSPARENT, + }; + ui.painter().rect_filled(r, 0.0, fill); + let ink = if close && resp.hovered() { egui::Color32::WHITE } else { t.icon }; + paint_glyph(ui, c, max, r.center(), Stroke::new(1.0, ink)); + let tip = match c { + Caption::Minimize => tr("Minimize"), + Caption::Maximize if max => tr("Restore"), + Caption::Maximize => tr("Maximize"), + Caption::Close => tr("Close"), + }; + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip)); + if resp.on_hover_text(tip).clicked() { + clicked = Some(c); + } + } + match clicked { + Some(Caption::Minimize) => ui.ctx().send_viewport_cmd(ViewportCommand::Minimized(true)), + Some(Caption::Maximize) => toggle_maximize(ui.ctx()), + Some(Caption::Close) => { + let ctx = ui.ctx().clone(); + if let Err(e) = crate::menus::invoke(app, &ctx, "app.quit", json!({})) { + app.ui.status = e; + } + } + None => {} + } +} + +/// The 10 pt line glyphs of the caption buttons. +fn paint_glyph(ui: &Ui, c: Caption, maximized: bool, at: egui::Pos2, s: Stroke) { + let p = ui.painter(); + let g = Rect::from_center_size(at, vec2(10.0, 10.0)); + match c { + Caption::Minimize => { + p.line_segment([g.left_center(), g.right_center()], s); + } + // Restore: a front square with the corner of the one behind it. + Caption::Maximize if maximized => { + let front = Rect::from_min_max(g.min + vec2(0.0, 2.0), g.max - vec2(2.0, 0.0)); + p.rect_stroke(front, 0.0, s, egui::StrokeKind::Middle); + p.line(vec![g.min + vec2(2.0, 2.0), g.min + vec2(2.0, 0.0), g.right_top(), g.max - vec2(0.0, 2.0), g.max - vec2(2.0, 2.0)], s); + } + Caption::Maximize => { + p.rect_stroke(g, 0.0, s, egui::StrokeKind::Middle); + } + Caption::Close => { + p.line_segment([g.left_top(), g.right_bottom()], s); + p.line_segment([g.right_top(), g.left_bottom()], s); + } + } +} + +/// The edge and corner zones of a window with content rect `w`: (zone, direction, cursor). Corners +/// come last so they win where they overlap the edges. +fn zones(w: Rect) -> [(Rect, ResizeDirection, CursorIcon); 8] { + use ResizeDirection::*; + let (e, c) = (EDGE, CORNER); + [ + (Rect::from_min_max(w.left_top(), pos2(w.right(), w.top() + e)), North, CursorIcon::ResizeVertical), + (Rect::from_min_max(pos2(w.left(), w.bottom() - e), w.right_bottom()), South, CursorIcon::ResizeVertical), + (Rect::from_min_max(w.left_top(), pos2(w.left() + e, w.bottom())), West, CursorIcon::ResizeHorizontal), + (Rect::from_min_max(pos2(w.right() - e, w.top()), w.right_bottom()), East, CursorIcon::ResizeHorizontal), + (Rect::from_min_size(w.left_top(), vec2(c, c)), NorthWest, CursorIcon::ResizeNwSe), + (Rect::from_min_size(w.right_top() - vec2(c, 0.0), vec2(c, c)), NorthEast, CursorIcon::ResizeNeSw), + (Rect::from_min_size(w.left_bottom() - vec2(0.0, c), vec2(c, c)), SouthWest, CursorIcon::ResizeNeSw), + (Rect::from_min_size(w.right_bottom() - vec2(c, c), vec2(c, c)), SouthEast, CursorIcon::ResizeNwSe), + ] +} + +/// Invisible resize zones along the window's edges, above everything else; a press in one starts +/// an OS resize in that direction. None while the window is maximized or fullscreen. +pub fn resize_zones(ui: &mut Ui) { + let ctx = ui.ctx().clone(); + if maximized(&ctx) || ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)) { + return; + } + let w = ctx.content_rect(); + let layer = LayerId::new(Order::Foreground, Id::new("titlebar-resize")); + let zui = ui.new_child(egui::UiBuilder::new().layer_id(layer).max_rect(w)); + for (i, (r, dir, cursor)) in zones(w).into_iter().enumerate() { + let resp = zui.interact(r, Id::new(("titlebar-resize", i)), Sense::drag()).on_hover_cursor(cursor); + if resp.drag_started_by(PointerButton::Primary) { + ctx.send_viewport_cmd(ViewportCommand::BeginResize(dir)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::{Event, Pos2, RawInput, ViewportId}; + + /// A headless window `width` pt wide with a title row: the drag area, the caption buttons and + /// the resize zones. + struct Win { + ctx: egui::Context, + app: EffectcraftApp, + width: f32, + time: f64, + maximized: bool, + } + + impl Win { + fn new(width: f32) -> Self { + let ctx = egui::Context::default(); + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.custom_titlebar = true; + app.apply_prefs(&ctx); + Self { ctx, app, width, time: 0.0, maximized: false } + } + + fn frame(&mut self, events: Vec) -> egui::FullOutput { + self.time += 0.05; + let mut input = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, 600.0))), time: Some(self.time), events, ..Default::default() }; + if let Some(v) = input.viewports.get_mut(&ViewportId::ROOT) { + v.maximized = Some(self.maximized); + } + let app = &mut self.app; + let width = self.width; + let mut out = self.ctx.run_ui(input, |ui| { + let bar = Rect::from_min_size(Pos2::ZERO, vec2(width, HEIGHT)); + drag_area(ui, bar); + caption_buttons(app, ui, bar); + resize_zones(ui); + }); + out.textures_delta.clear(); + out + } + + fn gesture(&mut self, path: &[Pos2]) -> Vec { + let button = |p: Pos2, pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + let mut events = vec![vec![Event::PointerMoved(path[0])], vec![button(path[0], true)]]; + events.extend(path[1..].iter().map(|p| vec![Event::PointerMoved(*p)])); + events.push(vec![button(*path.last().unwrap_or(&path[0]), false)]); + events.into_iter().flat_map(|e| self.frame(e).viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default()).collect() + } + + fn click(&mut self, p: Pos2) -> Vec { + self.gesture(&[p]) + } + + fn caption(&self, c: Caption) -> Rect { + self.ctx.read_response(caption_id(c)).map(|r| r.rect).unwrap_or(Rect::NOTHING) + } + } + + #[test] + fn caption_buttons_take_the_right_end_of_the_row() { + for width in [800.0, 1440.0] { + let mut w = Win::new(width); + w.frame(vec![]); + w.frame(vec![]); + let [min, max, close] = CAPTIONS.map(|c| w.caption(c)); + assert_eq!((close.right(), close.top()), (width, 0.0), "Close sits in the window's corner at {width}"); + assert!(close.height() >= 30.0 && [min, max, close].iter().all(|r| r.width() == BUTTON_WIDTH)); + assert_eq!((min.right(), max.right()), (max.left(), close.left())); + } + } + + #[test] + fn minimize_maximize_and_close_follow_the_window_state() { + let mut w = Win::new(1000.0); + w.frame(vec![]); + let (min, max, close) = (w.caption(Caption::Minimize).center(), w.caption(Caption::Maximize).center(), w.caption(Caption::Close).center()); + assert!(w.click(min).contains(&ViewportCommand::Minimized(true))); + assert!(w.click(max).contains(&ViewportCommand::Maximized(true))); + w.maximized = true; + assert!(w.click(max).contains(&ViewportCommand::Maximized(false)), "Restore"); + // Close goes through app.quit (which asks about unsaved changes) rather than the OS. + assert!(w.click(close).contains(&ViewportCommand::Close)); + } + + #[test] + fn empty_row_space_drags_and_double_click_maximizes() { + let mut w = Win::new(1440.0); + w.frame(vec![]); + let empty = pos2(700.0, 16.0); + let cmds = w.gesture(&[empty, empty + vec2(12.0, 4.0), empty + vec2(30.0, 8.0)]); + assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}"); + let mut cmds = w.click(empty); + cmds.extend(w.click(empty)); + assert!(cmds.contains(&ViewportCommand::Maximized(true)), "{cmds:?}"); + } + + #[test] + fn edge_zones_resize_unless_maximized() { + use ResizeDirection::*; + let mut w = Win::new(1000.0); + w.frame(vec![]); + for (p, dir) in [ + (pos2(1.0, 300.0), West), + (pos2(998.0, 300.0), East), + (pos2(500.0, 1.0), North), + (pos2(500.0, 598.0), South), + (pos2(2.0, 2.0), NorthWest), + (pos2(997.0, 3.0), NorthEast), + (pos2(3.0, 597.0), SouthWest), + (pos2(998.0, 598.0), SouthEast), + ] { + let cmds = w.gesture(&[p, p + vec2(6.0, 6.0)]); + assert_eq!(cmds, vec![ViewportCommand::BeginResize(dir)], "at {p:?}"); + } + w.maximized = true; + let cmds = w.gesture(&[pos2(1.0, 300.0), pos2(8.0, 300.0)]); + assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::BeginResize(_))), "{cmds:?}"); + } +} diff --git a/crates/ui-egui/tests/ui_toolbar_gradient.rs b/crates/ui-egui/tests/ui_toolbar_gradient.rs index b5682ffa..b32e229c 100644 --- a/crates/ui-egui/tests/ui_toolbar_gradient.rs +++ b/crates/ui-egui/tests/ui_toolbar_gradient.rs @@ -1,4 +1,4 @@ -//! The toolbar's Fill / Stroke swatch with Linear or Radial Gradient (#612, reported on Discord): +//! The toolbar's Fill / Stroke swatch with Linear or Radial Gradient (#612, reported by a user): //! it opens the Gradient Editor on the selected shape layer's Gradient Fill / Gradient Stroke //! Colors, as in After Effects, instead of Fill / Stroke Options. The words still open the //! options, a Solid Color swatch the colour picker, Alt-click still steps through the kinds, and diff --git a/crates/ui-egui/tests/ui_workflow_selection.rs b/crates/ui-egui/tests/ui_workflow_selection.rs index 697ff2a2..5cc4b511 100644 --- a/crates/ui-egui/tests/ui_workflow_selection.rs +++ b/crates/ui-egui/tests/ui_workflow_selection.rs @@ -432,7 +432,7 @@ fn timeline_selected_effect_row_highlights() { /// Right-clicking a layer in the Timeline keeps the selection when the layer is part of it, /// so the context menu's commands act on every selected layer; right-clicking an unselected -/// layer selects only it (After Effects). Reported on Discord: Pre-compose and Delete from the +/// layer selects only it (After Effects). Reported by a user: Pre-compose and Delete from the /// menu acted on the right-clicked layer only (#878). #[test] fn timeline_context_menu_acts_on_the_whole_selection() { diff --git a/docs/gaps.md b/docs/gaps.md index 6d329702..e821cfff 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -145,7 +145,7 @@ The most important missing piece: it turns every other estimate here into a meas tests, checked headlessly on Windows, drag a selected Gradient Fill's start and end points in the Composition viewer: one undo step, the layer stays, a point inside a moved and scaled group converts to the group's space, and the handles show only for a selected gradient. -- Regression evidence for the toolbar's gradient swatches (#612, reported on Discord): the +- Regression evidence for the toolbar's gradient swatches (#612, reported by a user): the `ui_toolbar_gradient` input tests, checked headlessly on Windows, click a Linear Fill swatch and a Radial Stroke swatch on a selected shape layer and get the Gradient Editor on its Gradient Fill / Gradient Stroke Colors (OK saves); without such a gradient the swatch still opens the options, diff --git a/docs/preferences.md b/docs/preferences.md index 3b92782c..ff1ce3ed 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -55,10 +55,10 @@ Commands (CLI, MCP, control channel): background, from the current time on, until it is cached or the budget is full - `previews.fastPreviews`: Fast Previews (Draft 3D, Faster Effects) - `appearance.appearanceMode`: Appearance Mode (`auto` / `dark` / `light`). `auto` (Sync with System) follows the operating system's light or dark appearance while the app runs: on Linux the desktop app reads the XDG desktop portal (`org.freedesktop.appearance` `color-scheme`) once and then listens for its `SettingChanged` signal on a worker thread, repainting only when the value changes (nothing polls); `gsettings` runs at most once at start-up, by absolute path, when the portal gives no answer. Elsewhere it uses what the window system reports. With no answer it shows the dark theme. New installs stay `dark`; Sync with System is opt-in. The Tools bar's appearance button (a monitor, sun or moon) cycles Sync with System, Light and Dark (`view.theme.toggle`, Next Appearance Mode). -- `appearance.darkTheme`: Dark Theme (`dark` / `darker`), shown in dark appearance -- `appearance.lightTheme`: Light Theme (`light`), shown in light appearance. Settings ▸ Appearance shows the mode above a light and a dark theme card, each with a preview of the EffectCraft window in that theme. -- `appearance.favoriteThemes`: a list of unique theme ids (`dark`, `darker`, `light`), empty by default. Hover a theme row to star it; saved favorites keep an accent-colored star. Right-click the appearance button for Appearance Settings and favorites grouped light first, then dark. Selecting a favorite fixes its appearance family and preserves the opposite family choice; left-click still cycles modes. Settings OK saves favorites and Cancel restores them. Unknown or duplicate ids are rejected by `prefs.set` and removed when loading old settings. -- `appearance.theme`: the single theme from before appearance modes, kept for older settings files and automation. A saved file with only `theme` loads as a fixed Dark or Light mode showing it; setting it (`prefs.set`, `ui.set {"theme": …}`, `view.theme.dark|darker|light`) selects that theme and fixes the mode to its family. While the mode is Dark or Light it mirrors the theme in use. +- `appearance.darkTheme`: Dark Theme (`dark`, `darker`, `catppuccinFrappe`, `catppuccinMacchiato` or `catppuccinMocha`), shown in dark appearance +- `appearance.lightTheme`: Light Theme (`light` or `catppuccinLatte`), shown in light appearance. Settings ▸ Appearance shows the mode above a light and a dark theme card, each with a preview of the EffectCraft window in that theme. +- `appearance.favoriteThemes`: a list of unique theme ids (any theme id, such as `dark`, `darker`, `light` or `catppuccinMocha`), empty by default. Hover a theme row to star it; saved favorites keep an accent-colored star. Right-click the appearance button for Appearance Settings and favorites grouped light first, then dark. Selecting a favorite fixes its appearance family and preserves the opposite family choice; left-click still cycles modes. Settings OK saves favorites and Cancel restores them. Unknown or duplicate ids are rejected by `prefs.set` and removed when loading old settings. +- `appearance.theme`: the single theme from before appearance modes, kept for older settings files and automation. A saved file with only `theme` loads as a fixed Dark or Light mode showing it; setting it (`prefs.set`, `ui.set {"theme": …}`, `view.theme.dark|darker|light|catppuccinLatte|catppuccinFrappe|catppuccinMacchiato|catppuccinMocha`; View ▸ Preferences ▸ Theme) selects that theme and fixes the mode to its family. While the mode is Dark or Light it mirrors the theme in use. - `appearance.brightness`: Brightness - `appearance.uiScale`: UI Scale, the size of the whole interface (any whole percentage from 75 to 200, on top of the display's own scaling), dragged or typed; it applies as soon as it is @@ -106,6 +106,9 @@ Commands (CLI, MCP, control channel): - `appearance.useGradients`: soft gradient on panel tab strips - `appearance.inWindowMenuBarMac`: on macOS, draw the menu bar inside the window instead of the native menu bar +- `appearance.systemTitleBar`: on Windows and Linux, keep the system's title bar instead of the + window's own (the menu bar row with minimize, maximize and close buttons); applies from the next + launch - `grids.gridStyle`, `grids.guideStyle`: lines, dashed lines or dots (grid, proportional grid, guides); `grids.proportionalHorizontal` / `grids.proportionalVertical`: View ▸ Show Proportional Grid divisions