diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs index 06278c2c..74468e08 100644 --- a/crates/engine/src/commands/frontend.rs +++ b/crates/engine/src/commands/frontend.rs @@ -37,6 +37,7 @@ pub fn specs() -> Vec { fe!("app.keyboardShortcuts", "Keyboard Shortcuts", ["Edit"], Some("Cmd+Alt+'"), "{}", always), fe!("app.templates", "Templates", [], None, "{kind: renderSettings|outputModule}", always), fe!("app.find", "Find", ["File"], Some("Cmd+F"), "{query?}", always), + fe!("effects.copyPresetsFolderPath", "Copy Presets Folder Path", [], None, "{}", always), // Preview. fe!( "playback.toggle", diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 89152356..dcbef2cb 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -227,6 +227,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Edit in the Timeline", "タイムラインで編集"), ("EffectCraft didn't quit normally last time.", "EffectCraftは前回正常に終了しませんでした。"), ("Effects & Presets options", "エフェクト&プリセットのオプション"), + ("Copy Presets Folder Path", "プリセットフォルダーのパスをコピー"), ("Effects: {}", "エフェクト:{}"), ("Empty folder", "空のフォルダー"), ("Enable Depth of Field", "被写界深度を有効化"), @@ -770,6 +771,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Edit in the Timeline", "在时间轴中编辑"), ("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常退出。"), ("Effects & Presets options", "效果和预设选项"), + ("Copy Presets Folder Path", "复制预设文件夹路径"), ("Effects: {}", "效果:{}"), ("Empty folder", "空文件夹"), ("Enable Depth of Field", "启用景深"), @@ -1501,6 +1503,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Edit in the Timeline", "在時間軸中編輯"), ("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常結束。"), ("Effects & Presets options", "效果和預設集選項"), + ("Copy Presets Folder Path", "複製預設集資料夾路徑"), ("Effects: {}", "效果:{}"), ("Empty folder", "空資料夾"), ("Enable Depth of Field", "啟用景深"), @@ -2235,6 +2238,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Edit in the Timeline", "Редагувати на шкалі часу"), ("EffectCraft didn't quit normally last time.", "Минулого разу EffectCraft завершив роботу некоректно."), ("Effects & Presets options", "Параметри ефектів і наборів"), + ("Copy Presets Folder Path", "Копіювати шлях до папки наборів"), ("Effects: {}", "Ефекти: {}"), ("Empty folder", "Порожня тека"), ("Enable Depth of Field", "Увімкнути глибину різкості"), @@ -2786,6 +2790,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Edit in the Timeline", "Editar na linha do tempo"), ("EffectCraft didn't quit normally last time.", "O EffectCraft não foi encerrado normalmente da última vez."), ("Effects & Presets options", "Opções de Efeitos e predefinições"), + ("Copy Presets Folder Path", "Copiar caminho da pasta de predefinições"), ("Effects: {}", "Efeitos: {}"), ("Empty folder", "Pasta vazia"), ("Enable Depth of Field", "Ativar profundidade de campo"), @@ -3235,6 +3240,7 @@ const CZECH: &[(&str, &str)] = &[ ("Edit in the Timeline", "Upravit na časové ose"), ("EffectCraft didn't quit normally last time.", "EffectCraft nebyl při posledním použití ukončen běžným způsobem."), ("Effects & Presets options", "Možnosti efektů a přednastavení"), + ("Copy Presets Folder Path", "Kopírovat cestu ke složce přednastavení"), ("Effects: {}", "Efekty: {}"), ("Empty folder", "Prázdná složka"), ("Enable Depth of Field", "Povolit hloubku ostrosti"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ab2285b3..8b5fd1c3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -291,6 +291,9 @@ fn only_comp(p: &Value) -> Option { /// Execute a UI or engine command by id. pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result { + if id == "effects.copyPresetsFolderPath" { + return frontend(app, ctx, id, params); + } // Layer ▸ New ▸ Text (menu, shortcut, context menus) puts an insertion point in the centre of // the comp, as After Effects does, so typing goes into Source Text rather than running // shortcuts (#866). `engine.execute` keeps the headless default. @@ -877,6 +880,16 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu let now = ctx.input(|i| i.time); let v = &mut app.ui.viewer; Ok(match id { + "effects.copyPresetsFolderPath" => { + if cfg!(target_arch = "wasm32") { + return Err("The web app has no local presets folder".into()); + } + let path = crate::panels::effects_presets::presets_dir().filter(|path| !path.as_os_str().is_empty()).ok_or("No presets folder is configured")?; + let path = std::path::absolute(path).map_err(|e| format!("Cannot resolve the presets folder path: {e}"))?; + let path = path.to_str().ok_or("The presets folder path is not valid UTF-8")?; + ctx.copy_text(path.to_string()); + json!({"path": path}) + } "app.about" => { app.dialog = Some(crate::Dialog::About); Value::Null diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index 561cf57d..d3921cd8 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -300,9 +300,34 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { "-".into(), " Refresh List".into(), " Clear Recently Used".into(), + "-".into(), + format!(" {}", tr("Copy Presets Folder Path")), ]; let mut force_scan = false; - if let Some(i) = widgets::popup_menu(ui, mpop, mr.left_bottom() - vec2(220.0, 0.0), &menu, None) { + let separators = menu.iter().filter(|label| *label == "-").count(); + let choice = if widgets::popup_is_open(ui, mpop) { + widgets::popup_list(ui, mpop, mr.left_bottom() - vec2(220.0, 0.0), Rect::NOTHING, menu.len().saturating_sub(separators), separators, |ui| { + ui.set_min_width(160.0); + let mut chosen = None; + for (i, label) in menu.iter().enumerate() { + if label == "-" { + ui.separator(); + } else { + let resp = ui.selectable_label(false, label); + if i == 12 { + app.auto.add("effects.copyPresetsFolderPath", resp.rect, "Copy Presets Folder Path"); + } + if resp.clicked() { + chosen = Some(i); + } + } + } + chosen + }) + } else { + None + }; + if let Some(i) = choice { let v = &mut app.ui.effects_view; match i { 0 => v.depth = "all".into(), @@ -313,6 +338,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { 7 => v.alphabetical = true, 9 => force_scan = true, 10 => app.ui.effects_recent.clear(), + 12 => { + if let Err(e) = crate::menus::invoke(app, &ctx, "effects.copyPresetsFolderPath", json!({})) { + app.ui.status = e; + } + } _ => {} } } diff --git a/crates/ui-egui/tests/ui_presets_folder.rs b/crates/ui-egui/tests/ui_presets_folder.rs new file mode 100644 index 00000000..7ba632a6 --- /dev/null +++ b/crates/ui-egui/tests/ui_presets_folder.rs @@ -0,0 +1,90 @@ +//! Effects & Presets: copy the configured folder path through the contents menu. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::{ControlRequest, EffectcraftApp, control, dock::PanelKind}; +use egui::{Event, Modifiers, OutputCommand, PointerButton, pos2}; +use egui_kittest::Harness; +use serde_json::json; + +fn expected_folder() -> String { + let path = std::env::var("EC_PRESETS_DIR").unwrap_or_else(|_| { + let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap(); + std::path::Path::new(&home).join("Documents/EffectCraft/Presets").to_str().unwrap().to_string() + }); + if path.is_empty() { path } else { std::path::absolute(path).unwrap().to_str().unwrap().to_string() } +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) -> Vec { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")); + let pos = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + let mut copied = Vec::new(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(pos)); + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + for command in &h.output().platform_output.commands { + if let OutputCommand::CopyText(text) = command { + copied.push(text.clone()); + } + } + } + copied +} + +#[test] +fn presets_menu_copies_the_configured_folder_without_editing_the_project() { + let expected = expected_folder(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| { + let mut app = EffectcraftApp::new(Session::default()); + app.ui.start_screen = false; + app.show_panel(PanelKind::EffectsPresets); + app + }); + h.run_steps(3); + let before = serde_json::to_value(&h.state().session.project).unwrap(); + let revision = h.state().session.revision; + assert!(h.state().auto.find("effects.copyPresetsFolderPath").is_none()); + click(&mut h, "effects.menu"); + h.run_steps(3); + if let Ok(path) = std::env::var("PRESETS_FOLDER_SNAPSHOT") { + h.render().unwrap().save(path).unwrap(); + } + assert_eq!(click(&mut h, "effects.copyPresetsFolderPath"), [expected]); + h.run_steps(2); + assert!(h.state().auto.find("effects.copyPresetsFolderPath").is_none()); + assert_eq!(serde_json::to_value(&h.state().session.project).unwrap(), before); + assert_eq!(h.state().session.revision, revision); +} + +#[test] +fn presets_folder_control_command_returns_the_path_or_a_configuration_error() { + let mut app = EffectcraftApp::new(Session::default()); + let ctx = egui::Context::default(); + let expected = expected_folder(); + let mut reply = None; + let mut output = ctx.run_ui(Default::default(), |ui| { + let (request, _rx) = ControlRequest::new("ui.menu.invoke", json!({"id": "effects.copyPresetsFolderPath"})); + reply = match control::handle(&mut app, ui.ctx(), &request) { + control::Outcome::Done(value) => Some(value), + _ => panic!("expected an immediate reply"), + }; + }); + output.textures_delta.clear(); + let copied: Vec<_> = output + .platform_output + .commands + .iter() + .filter_map(|command| match command { + OutputCommand::CopyText(text) => Some(text.as_str()), + _ => None, + }) + .collect(); + if expected.is_empty() { + assert_eq!(reply, Some(json!({"ok": false, "error": "No presets folder is configured"}))); + assert!(copied.is_empty()); + assert_eq!(app.ui.status, "No presets folder is configured"); + } else { + assert_eq!(reply, Some(json!({"ok": true, "result": {"path": expected}}))); + assert_eq!(copied, [expected.as_str()]); + } +} diff --git a/docs/agents.md b/docs/agents.md index ccd7c57e..1bec71d5 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -10,6 +10,15 @@ user can do, an agent can do too. There are three ways in: | **CLI** (`effectcraft-cli exec/get/set/...`) | one-shot scripting and CI; JSON output with `--json` | no | | **Control channel** (`effectcraft --control 9877`) | driving and seeing the live UI; see [control-protocol.md](control-protocol.md) | yes | +## Find the presets folder + +The native Effects & Presets panel's options menu includes **Copy Presets Folder Path**. It copies +the folder the panel scans: `EC_PRESETS_DIR` when set, otherwise `~/Documents/EffectCraft/Presets` +using `HOME` or `USERPROFILE`. Relative paths are resolved to an absolute path. The folder may +not exist yet; this action does not create it. +Over the live UI control channel, `ui.menu.invoke {"id":"effects.copyPresetsFolderPath"}` copies +the same path and returns `{path}`. The web app reports that it has no local presets folder. + ## MCP setup Build once with `cargo build --release -p effectcraft-cli`, then register the server. For Claude