feat(media-browser): add Copy Source Path

This commit is contained in:
arnav-hiwarkar
2026-10-14 11:42:13 +05:30
parent 549e9d8251
commit 8041329df8
5 changed files with 147 additions and 1 deletions
+7
View File
@@ -228,6 +228,7 @@ const JAPANESE: &[(&str, &str)] = &[
("EffectCraft didn't quit normally last time.", "EffectCraftは前回正常に終了しませんでした。"),
("Effects & Presets options", "エフェクト&プリセットのオプション"),
("Copy Presets Folder Path", "プリセットフォルダーのパスをコピー"),
("Copy Source Path", "ソースパスをコピー"),
("Effects: {}", "エフェクト:{}"),
("Empty folder", "空のフォルダー"),
("Enable Depth of Field", "被写界深度を有効化"),
@@ -772,6 +773,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常退出。"),
("Effects & Presets options", "效果和预设选项"),
("Copy Presets Folder Path", "复制预设文件夹路径"),
("Copy Source Path", "复制源路径"),
("Effects: {}", "效果:{}"),
("Empty folder", "空文件夹"),
("Enable Depth of Field", "启用景深"),
@@ -1504,6 +1506,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常結束。"),
("Effects & Presets options", "效果和預設集選項"),
("Copy Presets Folder Path", "複製預設集資料夾路徑"),
("Copy Source Path", "複製來源路徑"),
("Effects: {}", "效果:{}"),
("Empty folder", "空資料夾"),
("Enable Depth of Field", "啟用景深"),
@@ -2239,6 +2242,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
("EffectCraft didn't quit normally last time.", "Минулого разу EffectCraft завершив роботу некоректно."),
("Effects & Presets options", "Параметри ефектів і наборів"),
("Copy Presets Folder Path", "Копіювати шлях до папки наборів"),
("Copy Source Path", "Копіювати шлях до джерела"),
("Effects: {}", "Ефекти: {}"),
("Empty folder", "Порожня тека"),
("Enable Depth of Field", "Увімкнути глибину різкості"),
@@ -2791,6 +2795,7 @@ const PORTUGUESE: &[(&str, &str)] = &[
("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"),
("Copy Source Path", "Copiar caminho da origem"),
("Effects: {}", "Efeitos: {}"),
("Empty folder", "Pasta vazia"),
("Enable Depth of Field", "Ativar profundidade de campo"),
@@ -3241,6 +3246,7 @@ const CZECH: &[(&str, &str)] = &[
("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í"),
("Copy Source Path", "Kopírovat cestu ke zdroji"),
("Effects: {}", "Efekty: {}"),
("Empty folder", "Prázdná složka"),
("Enable Depth of Field", "Povolit hloubku ostrosti"),
@@ -3653,6 +3659,7 @@ const CZECH: &[(&str, &str)] = &[
/// order. Terms follow the Spanish menu catalog (composición, material de archivo, cola de procesamiento,
/// ajuste preestablecido, método abreviado). Written from the English source.
const SPANISH: &[(&str, &str)] = &[
("Copy Source Path", "Copiar ruta de origen"),
("Appearance Settings…", "Ajustes de apariencia…"),
("Add to favorites", "Añadir a favoritos"),
("Remove from favorites", "Quitar de favoritos"),
+6 -1
View File
@@ -291,7 +291,7 @@ fn only_comp(p: &Value) -> Option<effectcraft_engine::project::ItemId> {
/// Execute a UI or engine command by id.
pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
if id == "effects.copyPresetsFolderPath" {
if matches!(id, "effects.copyPresetsFolderPath" | "mediaBrowser.copySourcePath") {
return frontend(app, ctx, id, params);
}
// Layer ▸ New ▸ Text (menu, shortcut, context menus) puts an insertion point in the centre of
@@ -880,6 +880,11 @@ 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 {
"mediaBrowser.copySourcePath" => {
let path = p.get("path").and_then(Value::as_str).filter(|path| !path.is_empty()).ok_or("A source path is required")?;
ctx.copy_text(path.to_string());
json!({"path": path})
}
"effects.copyPresetsFolderPath" => {
if cfg!(target_arch = "wasm32") {
return Err("The web app has no local presets folder".into());
@@ -209,6 +209,14 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
egui::DragAndDrop::set_payload(&ctx, DragPayload::Files(vec![e.path.clone()]));
}
crate::widgets::context_menu(&resp, |ui| {
let copy = ui.button(tr("Copy Source Path"));
app.auto.add(&format!("mediaBrowser.entry.{}.copySourcePath", e.name), copy.rect, "Copy Source Path");
if copy.clicked() {
if let Err(e) = crate::menus::invoke(app, &ctx, "mediaBrowser.copySourcePath", json!({"path": e.path})) {
app.ui.status = e;
}
ui.close();
}
if !e.is_dir && ui.button(tr("Import")).clicked() {
actions.push(("mediaBrowser.import", json!({"paths": [e.path]})));
ui.close();
@@ -0,0 +1,125 @@
use std::sync::Arc;
use effectcraft_engine::{
Session,
media_browser::{Browser, Entry, VirtualTree},
};
use effectcraft_ui_egui::{ControlRequest, EffectcraftApp, control, dock::PanelKind};
use egui::{Event, Modifiers, OutputCommand, PointerButton, pos2};
use egui_kittest::Harness;
use serde_json::{Value, json};
struct TestBrowser(VirtualTree);
impl Browser for TestBrowser {
fn home(&self) -> String {
"/Media".into()
}
fn list(&self, dir: &str, only: bool) -> Result<Vec<Entry>, String> {
self.0.list(dir, only)
}
}
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str, button: PointerButton) -> Vec<String> {
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);
h.input_mut().events.push(Event::PointerMoved(pos));
h.step();
let mut copied = Vec::new();
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos, button, pressed, modifiers: Modifiers::NONE });
h.step();
copied.extend(h.output().platform_output.commands.iter().filter_map(|c| match c {
OutputCommand::CopyText(text) => Some(text.clone()),
_ => None,
}));
}
copied
}
#[test]
fn file_folder_and_virtual_paths_copy_from_the_context_menu_without_document_changes() {
let cases = [
("作品 clip.csv", "/Users/example/Shoot 01/作品 clip.csv"),
("Notes.txt", "./relative notes.txt"),
("Virtual.csv", "browser://assets/opaque%20作品.csv?version=2"),
("Folder 作品", "/Media/Folder 作品"),
];
let mut tree = VirtualTree::default();
for (name, path) in cases.iter().take(3) {
tree.add_file(&format!("/Media/{name}"), path, 1, None);
}
tree.add_dir("/Media/Folder 作品");
let mut session = Session { browser: Some(Arc::new(TestBrowser(tree))), ..Default::default() };
session.prefs.general.language = "en".into();
session.execute("comp.new", json!({"width": 64, "height": 36, "duration": 2})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| {
let mut app = EffectcraftApp::new(session);
app.ui.start_screen = false;
app.show_panel(PanelKind::MediaBrowser);
app.ui.maximized = Some(PanelKind::MediaBrowser);
app
});
h.run_steps(3);
let project = h.state().session.project.clone();
let revision = h.state().session.revision;
let history = h.state().session.history.undo.len();
let browser = serde_json::to_value(&h.state().session.state.media_browser).unwrap();
for (name, path) in cases {
let id = format!("mediaBrowser.entry.{name}.copySourcePath");
assert!(h.state().auto.find(&id).is_none());
assert!(click(&mut h, &format!("mediaBrowser.entry.{name}"), PointerButton::Secondary).is_empty());
h.run_steps(3);
assert!(h.state().auto.find(&id).is_some());
if name == "作品 clip.csv"
&& let Ok(file) = std::env::var("MEDIA_BROWSER_COPY_SNAPSHOT")
{
h.render().unwrap().save(file).unwrap();
}
assert_eq!(click(&mut h, &id, PointerButton::Primary), [path]);
h.run_steps(3);
assert!(h.state().auto.find(&id).is_none());
assert_eq!(h.state().session.project, project);
assert_eq!(h.state().session.revision, revision);
assert_eq!(h.state().session.history.undo.len(), history);
assert_eq!(serde_json::to_value(&h.state().session.state.media_browser).unwrap(), browser);
}
}
#[test]
fn copy_path_control_command_preserves_the_path_and_rejects_bad_arguments() {
let mut app = EffectcraftApp::new(Session::default());
let ctx = egui::Context::default();
let project = app.session.project.clone();
let revision = app.session.revision;
let history = app.session.history.undo.len();
for params in [json!({"path": " ./作品 clip.csv "}), json!({}), json!({"path": ""}), json!({"path": 42}), json!({"path": null})] {
let mut reply = None;
let mut out = ctx.run_ui(Default::default(), |ui| {
let (request, _rx) = ControlRequest::new("ui.menu.invoke", json!({"id": "mediaBrowser.copySourcePath", "params": params}));
reply = match control::handle(&mut app, ui.ctx(), &request) {
control::Outcome::Done(value) => Some(value),
_ => panic!("expected immediate reply"),
};
});
out.textures_delta.clear();
let copied: Vec<_> = out
.platform_output
.commands
.iter()
.filter_map(|c| match c {
OutputCommand::CopyText(text) => Some(text.as_str()),
_ => None,
})
.collect();
if params.get("path").and_then(Value::as_str).is_some_and(|s| !s.is_empty()) {
assert_eq!(reply, Some(json!({"ok": true, "result": {"path": " ./作品 clip.csv "}})));
assert_eq!(copied, [" ./作品 clip.csv "]);
} else {
assert_eq!(reply, Some(json!({"ok": false, "error": "A source path is required"})));
assert!(copied.is_empty());
}
assert_eq!(app.session.project, project);
assert_eq!(app.session.revision, revision);
assert_eq!(app.session.history.undo.len(), history);
}
}
+1
View File
@@ -37,6 +37,7 @@ printf '{"id":1,"method":"engine.execute","params":{"command":"comp.new","params
|---|---|---|
| `engine.execute` | `{command, params?}` | The command's result. Every engine command (`effectcraft-cli commands`) runs exactly as it does headless, so it never opens a dialog: `comp.new {}` creates a default comp. UI-only ids (`tool.*`, `view.*`, `window.*`, `playback.*`, `timeline.*`, `app.*`) fall through to the menu dispatcher. Unknown top-level params are rejected, and the error lists the accepted keys. |
| `ui.menu.invoke` | `{id, params?}` | Like a menu click: commands with empty params may open their dialog (New Composition, Solid Settings, file pickers). |
| Copy a Media Browser source path | `ui.menu.invoke {id: "mediaBrowser.copySourcePath", params: {path}}` | Copies the exact non-empty path string to the clipboard and returns `{path}`. Supports file/folder paths and virtual source identifiers. Missing, empty or non-string paths return an error. |
| UI commands (via either) | | `timeline.column {column, visible?}` (av, keys, label, num, comment, switches, modes, parent, in, out, duration, stretch), `timeline.sourceName {value?}`, `timeline.search {query}`, `timeline.reveal.<kind>` / `timeline.revealAdd.<kind>` (the property shortcuts and Shift+shortcut: position, scale, rotation, opacity, anchor, effects, masks, feather, levels, animated, and the double-press sets maskPath, maskOpacity, timeRemap, expressions, material, paint, selected, missingEffects, waveform), `timeline.keyAt.<anchor\|position\|scale\|rotation\|opacity>` (Alt+Shift+A/P/S/R/T), `timeline.twirlSelected` (Ctrl/Cmd+`), `timeline.collapseAll`, `flowchart.options {layers?, effects?, solids?, direction?: lr\|tb, comp?}`, `flowchart.graph {comp?}` → `{nodes, edges}`, `window.workspace {name}` (all After Effects workspaces). |
| `engine.commands` | `{filter?, enabledOnly?}` | `[{id, label, menu, shortcut, params, enabled, why}]` |
| `ui.menu.list` | `{}` | The menu tree with enablement. |