5 Commits

Author SHA1 Message Date
Marcos Paulo 7838dd4189 chore: release 0.8.1 2026-10-12 02:07:14 -03:00
Marcos Paulo f7d8065119 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>
2026-10-12 00:37:34 -03:00
Marcos Paulo eb86a40890 build: use the masi/craft-libs fork
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 00:37:34 -03:00
Marcos Paulo 31e66b0606 build(windows): set working directory to repo root in package.ps1 2026-10-11 22:38:51 -03:00
Marcos Paulo 40b97b359a build(windows): package standalone effectcraft-cli zip and auto-detect rc.exe 2026-10-11 22:37:49 -03:00
14 changed files with 638 additions and 62 deletions
Generated
+37 -37
View File
@@ -965,7 +965,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
source = "git+https://git.marcospaulo.dev.br/masi/craft-libs?rev=22544b6d619d4f16779b8bdfa3f0b642a86fc065#22544b6d619d4f16779b8bdfa3f0b642a86fc065"
dependencies = [
"egui",
"serde",
@@ -1143,7 +1143,7 @@ dependencies = [
[[package]]
name = "effectcraft"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"cpal",
"effectcraft-engine",
@@ -1167,7 +1167,7 @@ dependencies = [
[[package]]
name = "effectcraft-automation"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
@@ -1179,14 +1179,14 @@ dependencies = [
[[package]]
name = "effectcraft-av1enc"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"filmcraft-av1",
]
[[package]]
name = "effectcraft-cli"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"cpu-time",
"effectcraft-automation",
@@ -1202,7 +1202,7 @@ dependencies = [
[[package]]
name = "effectcraft-color"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"proptest",
"serde",
@@ -1210,7 +1210,7 @@ dependencies = [
[[package]]
name = "effectcraft-effects"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1227,7 +1227,7 @@ dependencies = [
[[package]]
name = "effectcraft-engine"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1260,7 +1260,7 @@ dependencies = [
[[package]]
name = "effectcraft-export"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"effectcraft-av1enc",
"effectcraft-color",
@@ -1290,7 +1290,7 @@ dependencies = [
[[package]]
name = "effectcraft-expr"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"boa_engine",
"effectcraft-color",
@@ -1307,7 +1307,7 @@ dependencies = [
[[package]]
name = "effectcraft-geom"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"proptest",
"serde",
@@ -1315,7 +1315,7 @@ dependencies = [
[[package]]
name = "effectcraft-gpu"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"bytemuck",
"effectcraft-color",
@@ -1337,14 +1337,14 @@ dependencies = [
[[package]]
name = "effectcraft-hevcenc"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"filmcraft-hevc",
]
[[package]]
name = "effectcraft-host"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -1368,7 +1368,7 @@ dependencies = [
[[package]]
name = "effectcraft-interchange"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"effectcraft-color",
"effectcraft-keyframe",
@@ -1387,7 +1387,7 @@ dependencies = [
[[package]]
name = "effectcraft-keyframe"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1399,7 +1399,7 @@ dependencies = [
[[package]]
name = "effectcraft-lottie"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1416,7 +1416,7 @@ dependencies = [
[[package]]
name = "effectcraft-media"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"effectcraft-model",
"effectcraft-pdf",
@@ -1442,7 +1442,7 @@ dependencies = [
[[package]]
name = "effectcraft-model"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"effectcraft-geom",
"image",
@@ -1453,14 +1453,14 @@ dependencies = [
[[package]]
name = "effectcraft-opusenc"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"filmcraft-opus",
]
[[package]]
name = "effectcraft-path"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
@@ -1472,7 +1472,7 @@ dependencies = [
[[package]]
name = "effectcraft-pdf"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"effectcraft-raster",
"effectcraft-svg",
@@ -1486,7 +1486,7 @@ dependencies = [
[[package]]
name = "effectcraft-plugin"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
@@ -1498,7 +1498,7 @@ dependencies = [
[[package]]
name = "effectcraft-project"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1511,14 +1511,14 @@ dependencies = [
[[package]]
name = "effectcraft-psd"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"miniz_oxide 0.8.9",
]
[[package]]
name = "effectcraft-raster"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1527,7 +1527,7 @@ dependencies = [
[[package]]
name = "effectcraft-render"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1549,7 +1549,7 @@ dependencies = [
[[package]]
name = "effectcraft-script"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"boa_engine",
"effectcraft-engine",
@@ -1564,7 +1564,7 @@ dependencies = [
[[package]]
name = "effectcraft-segment"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"miniz_oxide 0.8.9",
"ndarray",
@@ -1573,7 +1573,7 @@ dependencies = [
[[package]]
name = "effectcraft-svg"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"effectcraft-geom",
"effectcraft-path",
@@ -1585,7 +1585,7 @@ dependencies = [
[[package]]
name = "effectcraft-text"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"effectcraft-keyframe",
"harfrust",
@@ -1597,7 +1597,7 @@ dependencies = [
[[package]]
name = "effectcraft-time"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"proptest",
"serde",
@@ -1605,7 +1605,7 @@ dependencies = [
[[package]]
name = "effectcraft-track"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"effectcraft-raster",
"effectcraft-segment",
@@ -1617,7 +1617,7 @@ dependencies = [
[[package]]
name = "effectcraft-ui-egui"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"craft-ui",
"effectcraft-color",
@@ -1649,11 +1649,11 @@ dependencies = [
[[package]]
name = "effectcraft-vp9enc"
version = "0.8.0"
version = "0.8.1"
[[package]]
name = "effectcraft-web"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -6463,7 +6463,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"serde_json",
]
+2 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*"]
[workspace.package]
version = "0.8.0"
version = "0.8.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -99,7 +99,7 @@ pollster = "0.4"
# ui
# Shared frontend components; keep this dependency above the engine boundary.
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "22544b6d619d4f16779b8bdfa3f0b642a86fc065", features = ["serde"] }
egui = "0.36"
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
+36 -1
View File
@@ -44,6 +44,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()
@@ -105,12 +120,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([1680.0, 1020.0])
.with_min_inner_size([960.0, 600.0])
.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())
@@ -190,6 +210,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 {
@@ -400,7 +422,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
@@ -35,6 +35,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
@@ -1138,6 +1138,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
@@ -45,13 +45,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)]> {
@@ -185,6 +199,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 {
@@ -1178,6 +1195,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
View File
@@ -384,6 +384,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...", "合成设置..."),
@@ -1005,6 +1014,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...", "合成設定..."),
@@ -1623,6 +1641,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...", "コンポジション設定..."),
@@ -2244,6 +2271,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...", "Налаштування композиції..."),
@@ -2866,6 +2902,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..."),
@@ -3486,6 +3531,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..."),
+21
View File
@@ -505,6 +505,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)] = &[
@@ -1025,6 +1030,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)] = &[
@@ -1540,6 +1550,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.
@@ -1972,6 +1987,11 @@ const UKRAINIAN: &[(&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"),
];
#[cfg(test)]
@@ -1984,6 +2004,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
@@ -29,6 +29,7 @@ mod pointer_wrap;
pub mod prefs_live;
pub mod state;
pub mod theme;
pub mod titlebar;
pub mod widgets;
mod workspace_state;
@@ -315,7 +316,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`).
@@ -405,6 +409,7 @@ impl EffectcraftApp {
pointer_comp: None,
dialog_state: Default::default(),
integrated_titlebar: false,
custom_titlebar: false,
audio: None,
scrub: None,
meter: Default::default(),
@@ -1615,15 +1620,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);
@@ -1640,6 +1658,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
@@ -47,10 +47,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),
@@ -1769,7 +1765,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().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:?}");
}
}
+6 -3
View File
@@ -55,9 +55,9 @@ 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.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.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 (75–200 %, on top of the
display's own scaling), applied as soon as it is chosen
@@ -102,6 +102,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
+27 -1
View File
@@ -6,6 +6,7 @@
Produces, in $env:DIST (default: dist/release):
effectcraft-<version>-windows-<arch>.msi per-machine installer (WiX v5)
effectcraft-<version>-windows-<arch>-portable.zip effectcraft.exe + effectcraft-cli.exe
effectcraft-cli-<version>-windows-<arch>.zip effectcraft-cli.exe standalone
The binaries link the C runtime statically (+crt-static), so neither the MSI nor the portable
zip needs the Visual C++ redistributable. Signing is delegated to sign.ps1 (skipped with a
@@ -25,6 +26,7 @@ param(
)
$ErrorActionPreference = 'Stop'
$Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
Set-Location $Root
function Invoke-Native([string] $What, [scriptblock] $Block) {
Write-Output "==> $What"
@@ -56,6 +58,17 @@ if (-not $env:EFFECTCRAFT_BUILD_DATE) { $env:EFFECTCRAFT_BUILD_DATE = (Get-Date)
Write-Output "EffectCraft $Version for Windows $Arch ($Target)"
if (-not $SkipBuild) {
# Locate rc.exe if not in PATH
if (-not (Get-Command rc.exe -ErrorAction SilentlyContinue)) {
$kits = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin'
$foundRc = Get-ChildItem -Path $kits -Recurse -Filter rc.exe -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match '\\x64\\rc\.exe$' } |
Sort-Object FullName -Descending | Select-Object -First 1
if ($foundRc) {
$env:PATH = "$($foundRc.DirectoryName);$env:PATH"
}
}
# Static CRT: no VC++ redistributable needed. Scoped to the target so host build scripts and
# proc-macros are unaffected.
$flagVar = 'CARGO_TARGET_' + ($Target.ToUpper() -replace '-', '_') + '_RUSTFLAGS'
@@ -114,6 +127,19 @@ $Zip = Join-Path $Dist "effectcraft-$Version-windows-$Arch-portable.zip"
Remove-Item -Force $Zip -ErrorAction SilentlyContinue
Compress-Archive -Path $Portable -DestinationPath $Zip
# ---- cli zip -----------------------------------------------------------------------------------
$CliPortable = Join-Path $TargetDir "windows-package\effectcraft-cli-$Version-windows-$Arch"
Remove-Item -Recurse -Force $CliPortable -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $CliPortable | Out-Null
Copy-Item (Join-Path $Stage 'effectcraft-cli.exe') $CliPortable
foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE') {
$p = Join-Path $Root $f
if (Test-Path $p) { Copy-Item $p $CliPortable }
}
$CliZip = Join-Path $Dist "effectcraft-cli-$Version-windows-$Arch.zip"
Remove-Item -Force $CliZip -ErrorAction SilentlyContinue
Compress-Archive -Path $CliPortable -DestinationPath $CliZip
# Smoke-test the CLI when this machine can run it. An ARM64 build made on an x64 runner can't run
# here; .github/workflows/windows-arm64.yml installs and runs it on ARM64 instead.
$HostArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()
@@ -122,4 +148,4 @@ if ($Arch -ne 'arm64' -or $HostArch -eq 'arm64') {
} else {
Write-Output "skipping effectcraft-cli --version: an $Arch build doesn't run on this $HostArch machine"
}
Get-Item $Msi, $Zip | Format-Table Name, Length
Get-Item $Msi, $Zip, $CliZip | Format-Table Name, Length