feat(ui): Catppuccin themes, View > Preferences > Theme and a custom title bar

Adds the four Catppuccin themes, the View > Preferences > Theme menu, and a window-drawn
title bar on Windows and Linux (Settings > Appearance > Use System Title Bar opts out).
macOS gets the traffic lights over the top row.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Marcos Paulo
2026-10-12 00:37:34 -03:00
parent fe23624202
commit 660f26e2fd
15 changed files with 580 additions and 50 deletions
+4
View File
@@ -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:
+36 -1
View File
@@ -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<Args> {
parse_args(args.iter().map(|a| a.to_string()), None)
+10
View File
@@ -37,6 +37,16 @@ pub fn specs() -> Vec<CommandSpec> {
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",
+14
View File
@@ -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
+22 -3
View File
@@ -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<Page> {
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 {
+54 -8
View File
@@ -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"),
+21 -16
View File
@@ -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")),
+24 -3
View File
@@ -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::AudioPlayback>,
/// 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();
+2 -5
View File
@@ -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 {
+93 -7
View File
@@ -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<ThemeKind> {
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<Flavor> {
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),
+290
View File
@@ -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<Event>) -> 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<ViewportCommand> {
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<ViewportCommand> {
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:?}");
}
}
+1 -1
View File
@@ -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
@@ -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() {
+1 -1
View File
@@ -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,
+7 -4
View File
@@ -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