mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
feat(media-browser): add Copy Source Path
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user