feat(app): project browser on real project data + drag-drop import (M12 P3)
- ProjectDataSource<RealEngine> reads the real bin tree (roots/ children via the facade folder/footage enumeration) - AppEngine::import_footage implemented (facade project_import_footage, last_error surfaced); double-click opens in the source viewer - dragging files onto the browser imports them (also fixes a real gpui bug: ExternalPaths arrives as the bare type, not Arc-wrapped, so drops never fired) - tests: real-engine import->browser listing, widget drop routing, facade folder enumeration failure matrix
This commit is contained in:
@@ -29,18 +29,20 @@ use std::ffi::{c_char, c_int};
|
||||
|
||||
use crate::node::{
|
||||
oakengine_footage_borrow, oakengine_footage_last_error, oakengine_footage_probe,
|
||||
oakengine_folder_add_child, oakengine_folder_item_child, oakengine_folder_item_child_count,
|
||||
oakengine_node_connect, oakengine_node_disconnect, oakengine_node_factory_create_from_id,
|
||||
oakengine_node_factory_id_count, oakengine_node_factory_name_from_id,
|
||||
oakengine_node_factory_node_at, oakengine_node_get_input, oakengine_node_get_input_at_time,
|
||||
oakengine_node_get_label, oakengine_node_get_name, oakengine_node_get_type_id,
|
||||
oakengine_node_input_get_type, oakengine_node_input_id, oakengine_node_input_is_connected,
|
||||
oakengine_node_is_clip, oakengine_node_is_folder, oakengine_node_is_track,
|
||||
oakengine_node_is_viewer_output, oakengine_node_keyframe_count, oakengine_node_set_input,
|
||||
oakengine_node_set_input_at_time, oakengine_node_set_label, oakengine_project_add_node,
|
||||
oakengine_project_create, oakengine_project_filename, oakengine_project_free,
|
||||
oakengine_project_import_footage, oakengine_project_load, oakengine_project_name,
|
||||
oakengine_project_new, oakengine_project_node_at, oakengine_project_node_count,
|
||||
oakengine_project_save, oakengine_project_set_filename, OakNodeValue,
|
||||
oakengine_node_identity, oakengine_node_input_get_type, oakengine_node_input_id,
|
||||
oakengine_node_input_is_connected, oakengine_node_is_clip, oakengine_node_is_folder,
|
||||
oakengine_node_is_track, oakengine_node_is_viewer_output, oakengine_node_keyframe_count,
|
||||
oakengine_node_free, oakengine_node_set_input, oakengine_node_set_input_at_time, oakengine_node_set_label,
|
||||
oakengine_project_add_node, oakengine_project_create, oakengine_project_filename,
|
||||
oakengine_project_free, oakengine_project_import_footage, oakengine_project_load,
|
||||
oakengine_project_name, oakengine_project_new, oakengine_project_node_at,
|
||||
oakengine_project_node_count, oakengine_project_root, oakengine_project_save,
|
||||
oakengine_project_set_filename, OakNodeValue,
|
||||
};
|
||||
|
||||
/// Registered generator node ids used by the tests.
|
||||
@@ -214,6 +216,53 @@ fn project_node_keyframe_lifecycle() {
|
||||
assert_eq!(unsafe { oakengine_node_is_folder(solid) }, 0);
|
||||
assert_eq!(unsafe { oakengine_node_is_viewer_output(solid) }, 0);
|
||||
|
||||
// ---- the project browser's folder-tree walk (M12 P3) -----------------
|
||||
// The traversal exports the material bin walks: `project_root` resolves
|
||||
// the root folder and `folder_item_child_count` / `folder_item_child`
|
||||
// enumerate its children (folders and media alike), all resolved by the
|
||||
// nodes' stable identities. The added nodes sit in the graph only until
|
||||
// an explicit graft (the same FolderAddChild the footage import uses).
|
||||
let root = unsafe { oakengine_project_root(project) };
|
||||
assert!(!root.is_null(), "a new project has a root folder");
|
||||
assert_eq!(unsafe { oakengine_node_is_folder(root) }, 1);
|
||||
assert_eq!(unsafe { oakengine_folder_item_child_count(root) }, 0);
|
||||
|
||||
// Graft two graph nodes under the root folder.
|
||||
assert_eq!(unsafe { oakengine_folder_add_child(root, solid) }, 0);
|
||||
assert_eq!(unsafe { oakengine_folder_add_child(root, transform) }, 0);
|
||||
assert_eq!(
|
||||
unsafe { oakengine_folder_item_child_count(root) },
|
||||
2,
|
||||
"the grafted nodes are listed under the root folder"
|
||||
);
|
||||
let solid_id = unsafe { oakengine_node_identity(solid) };
|
||||
let mut seen_solid = false;
|
||||
for i in 0..2 {
|
||||
let child = unsafe { oakengine_folder_item_child(root, i) };
|
||||
assert!(!child.is_null(), "child {i} resolves");
|
||||
assert!(unsafe { oakengine_node_identity(child) } != 0);
|
||||
if unsafe { oakengine_node_identity(child) } == solid_id {
|
||||
seen_solid = true;
|
||||
// The child carries the node's factory type id.
|
||||
let len = unsafe { oakengine_node_get_type_id(child, buf.as_mut_ptr(), 256) };
|
||||
assert!(len > 0);
|
||||
assert_eq!(unsafe { read_buf(&mut buf) }, TYPE_ID_SOLID);
|
||||
assert_eq!(unsafe { oakengine_node_is_folder(child) }, 0);
|
||||
}
|
||||
unsafe { oakengine_node_free(child) };
|
||||
}
|
||||
assert!(seen_solid, "the solid generator is a root child");
|
||||
|
||||
// Failure paths: NULL project/folder, out-of-range index, non-folder
|
||||
// handles (the app never hands those to the browser).
|
||||
assert!(unsafe { oakengine_project_root(std::ptr::null_mut()) }.is_null());
|
||||
assert_eq!(unsafe { oakengine_folder_item_child_count(std::ptr::null()) }, 0);
|
||||
assert!(unsafe { oakengine_folder_item_child(std::ptr::null(), 0) }.is_null());
|
||||
assert!(unsafe { oakengine_folder_item_child(root, 99) }.is_null());
|
||||
assert_eq!(unsafe { oakengine_folder_item_child_count(solid) }, 0);
|
||||
assert!(unsafe { oakengine_folder_item_child(solid, 0) }.is_null());
|
||||
unsafe { oakengine_node_free(root) };
|
||||
|
||||
// ---- undoable label + readback --------------------------------------
|
||||
assert_eq!(
|
||||
unsafe { oakengine_node_set_label(solid, c"My Solid".as_ptr()) },
|
||||
|
||||
+50
-1
@@ -1553,7 +1553,7 @@ fn run_with<E: AppEngine>(args: AppArgs) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::oakui::EngineGateway as _;
|
||||
use gpui::{px, size, TestAppContext};
|
||||
use gpui::{px, size, ExternalPaths, FileDropEvent, TestAppContext, VisualTestContext};
|
||||
|
||||
/// The 视图/View menu carries a 语言/Language submenu whose items are
|
||||
/// labeled in their own language and whose checkmark follows the active
|
||||
@@ -1779,6 +1779,55 @@ mod tests {
|
||||
assert_eq!(imported, 1, "a cancelled picker imports nothing");
|
||||
}
|
||||
|
||||
/// Dragging files onto the project explorer routes them to the engine's
|
||||
/// import: the explorer emits `FileDropRequested` on a real drop and the
|
||||
/// panel's subscription calls `AppEngine::import_footage` per path (the
|
||||
/// mock engine records them). The drop is simulated as the platform
|
||||
/// delivers it — the OS drag entering the window, then the release.
|
||||
#[gpui::test]
|
||||
async fn dropping_files_onto_the_project_browser_imports_them(cx: &mut TestAppContext) {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let (window, root) = mock_shell(cx);
|
||||
let mut cx = VisualTestContext::from_window(window.into(), cx);
|
||||
|
||||
// The mock shell's parked frame is live: dispatch the drop against it
|
||||
// (an extra draw would repaint the cached element tree and consume the
|
||||
// one-shot interactive listeners).
|
||||
let bounds = cx
|
||||
.debug_bounds("gpui-widgets-explorer-entry-1")
|
||||
.expect("the explorer's first row is rendered");
|
||||
let dropped = PathBuf::from("/media/raw/drag-drop.mov");
|
||||
let paths = ExternalPaths(std::iter::once(dropped.clone()).collect());
|
||||
// Deliver the whole drag sequence against the same rendered frame:
|
||||
// gpui's interactive listeners are registered per render, so a repaint
|
||||
// between the events would consume them before the drop lands.
|
||||
cx.update(|window, cx| {
|
||||
window.dispatch_event(
|
||||
gpui::PlatformInput::FileDrop(FileDropEvent::Entered {
|
||||
position: bounds.center(),
|
||||
paths,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
window.dispatch_event(
|
||||
gpui::PlatformInput::FileDrop(FileDropEvent::Pending {
|
||||
position: bounds.center(),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
window.dispatch_event(
|
||||
gpui::PlatformInput::FileDrop(FileDropEvent::Submit {
|
||||
position: bounds.center(),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let imported = cx.read(|app| root.read(app).engine.read(app).imported_footage().to_vec());
|
||||
assert_eq!(imported, vec![dropped]);
|
||||
}
|
||||
|
||||
/// The command-line parser understands the project path and the mock
|
||||
/// flag, and the `OAK_ENGINE` env var forces the mock.
|
||||
#[test]
|
||||
|
||||
@@ -385,6 +385,10 @@ unsafe extern "C" {
|
||||
) -> *mut OakEngineFootage;
|
||||
/// `oakengine_footage_free` — release a footage handle.
|
||||
pub fn oakengine_footage_free(self_: *mut OakEngineFootage);
|
||||
/// `oakengine_footage_last_error` — last probe/import error on this
|
||||
/// thread (two-stage buf/size getter; empty when the last call
|
||||
/// succeeded).
|
||||
pub fn oakengine_footage_last_error(buf: *mut c_char, buf_size: c_int) -> c_int;
|
||||
/// `oakengine_sequence_add_footage_clip_ex` — place a clip of
|
||||
/// `footage` on the track, skipping the unenforceable same-project
|
||||
/// check (sequences live in their own scratch project — documented
|
||||
|
||||
@@ -2212,6 +2212,34 @@ impl AppEngine for RealEngine {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn import_footage(&mut self, path: PathBuf, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
let Some(project) = self.project_ptr() else {
|
||||
return Err("no project open".into());
|
||||
};
|
||||
let Some(path_c) = cstr_path(&path) else {
|
||||
return Err("invalid import path".into());
|
||||
};
|
||||
// SAFETY: `project` is the live facade handle the engine owns; the
|
||||
// returned footage box is freed below.
|
||||
let footage = unsafe { oakengine_project_import_footage(project, path_c.as_ptr()) };
|
||||
if footage.is_null() {
|
||||
let error = read_string(|buf, size| unsafe {
|
||||
oakengine_footage_last_error(buf, size)
|
||||
});
|
||||
return Err(if error.is_empty() {
|
||||
format!("failed to import \"{}\"", path.display())
|
||||
} else {
|
||||
error
|
||||
});
|
||||
}
|
||||
// SAFETY: `footage` is an owned facade box (`oakengine_footage_free`).
|
||||
unsafe { oakengine_footage_free(footage) };
|
||||
// The material bin reads the folder tree live from the facade, so a
|
||||
// notify is enough for the explorer to list the new entry.
|
||||
cx.notify();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- project library (M13 D4) --------------------------------------
|
||||
|
||||
fn storage_bound(&self) -> bool {
|
||||
@@ -3115,6 +3143,53 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&media);
|
||||
}
|
||||
|
||||
/// M12 P3 acceptance through the app seam: a `RealEngine` built exactly
|
||||
/// as the app builds it imports a generated media file through the same
|
||||
/// [`AppEngine::import_footage`] method the explorer's drag-drop handler
|
||||
/// calls, and the project browser's `roots()` lists the entry under the
|
||||
/// root folder. Selecting the entry (the double-click path) resolves back
|
||||
/// to the footage node.
|
||||
#[gpui::test]
|
||||
async fn real_engine_import_footage_lists_in_the_project_browser(
|
||||
cx: &mut gpui::TestAppContext,
|
||||
) {
|
||||
let _media = media_lock();
|
||||
let engine = cx.update(|cx| cx.new(|cx| RealEngine::create(cx)));
|
||||
cx.update(|app| engine.update(app, |engine, cx| engine.new_project(cx)));
|
||||
|
||||
let media = std::env::temp_dir().join(format!(
|
||||
"oakapp_engine_import_{}.mp4",
|
||||
std::process::id()
|
||||
));
|
||||
let cpath = CString::new(media.to_string_lossy().into_owned()).unwrap();
|
||||
assert_eq!(
|
||||
unsafe { oakengine_testmedia_write_clip(cpath.as_ptr(), 64, 64, 10, 10) },
|
||||
0,
|
||||
"generate e2e test media"
|
||||
);
|
||||
let imported = cx
|
||||
.update(|app| engine.update(app, |engine, cx| engine.import_footage(media.clone(), cx)));
|
||||
assert!(imported.is_ok(), "import through the seam succeeds: {imported:?}");
|
||||
|
||||
// The project browser (ProjectDataSource) lists the file at the root.
|
||||
let name = media.file_name().unwrap().to_string_lossy().into_owned();
|
||||
let entry = cx.read(|app| {
|
||||
engine
|
||||
.read(app)
|
||||
.roots()
|
||||
.into_iter()
|
||||
.find(|e| e.name.as_ref() == name)
|
||||
});
|
||||
let entry = entry.expect("the imported footage is listed by its file name");
|
||||
assert!(!entry.is_dir, "footage entries are files");
|
||||
assert!(entry.id != 0, "entry id is the node identity");
|
||||
|
||||
// The double-click path: selecting the entry must resolve to a node.
|
||||
cx.update(|app| engine.update(app, |engine, cx| engine.select_item(entry.id, cx)));
|
||||
|
||||
let _ = std::fs::remove_file(&media);
|
||||
}
|
||||
|
||||
/// M12 P2 acceptance: a real project with a sequence + footage clip
|
||||
/// builds a NON-EMPTY node graph with the wires the node editor shows:
|
||||
/// the footage feeds the clip's `tex_in` (a real edge), and every clip
|
||||
|
||||
@@ -40,10 +40,27 @@ impl<E: AppEngine> ProjectExplorerPanel<E> {
|
||||
&explorer,
|
||||
|this, _explorer, event: &ProjectExplorerEvent, cx| match event {
|
||||
ProjectExplorerEvent::OpenRequested { id, .. } => {
|
||||
// Demo "open": select the item in the engine's model.
|
||||
// Open the item in the engine's model (double-click on a
|
||||
// footage entry selects it for the source viewer).
|
||||
this.engine
|
||||
.update(cx, |engine, cx| engine.select_item(*id, cx));
|
||||
}
|
||||
ProjectExplorerEvent::FileDropRequested { paths, .. } => {
|
||||
// Drag-and-drop import: probe and add each dropped file
|
||||
// (the first failure is logged after the rest run).
|
||||
let mut first_error = None;
|
||||
for path in paths {
|
||||
if let Err(err) =
|
||||
this.engine
|
||||
.update(cx, |engine, cx| engine.import_footage(path.clone(), cx))
|
||||
{
|
||||
first_error.get_or_insert(err);
|
||||
}
|
||||
}
|
||||
if let Some(err) = first_error {
|
||||
println!("[project explorer] import failed: {err}");
|
||||
}
|
||||
}
|
||||
other => println!("[project explorer] request: {other:?}"),
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user