feat(storage,app): PostgreSQL backend (D3) + project manager window (D4)
D3: oakdb+pg:// fully wired (shared sea-orm entities, BIGSERIAL DDL, connect-probe instead of pool retry on dead servers); Storage/Backend=pg + Storage/PgUrl config; 13 OAK_TEST_PG_URL-gated PG tests (verified against a Docker postgres:16), always-on clean-error tests otherwise. D4: DaVinci-style project manager — list with derived stats, create/ rename/duplicate/delete (confirm)/import/export (native dialogs, ove/otio/fcpxml), shown at startup and from the file menu; facade oakengine_library_* exports (list/create/delete/rename/duplicate/ import/export + project_load_library that binds write-through); save/save-as menu becomes 'export project file', open splits into from-library/from-file; status bar shows library write state; storage activates on app start and flushes on exit; spawn_modal reentrancy fixed (window-callback path) with a doc note. Also: the P1 audio test's environment probe was lost in the ffi purge; restored on cpal (the output device is cpal now).
This commit is contained in:
+641
-41
@@ -31,7 +31,7 @@
|
||||
//! │ dock: 项目 | 素材查看器 | 序列查看器+节点编辑器 | 检查器+历史记录
|
||||
//! │ (vertical split) 时间线 (full width, 31px toolbar on top)
|
||||
//! ├─────────────────────────────────────────────────────
|
||||
//! └ status bar: 就绪 | 缓存 | 代理 | 自动保存 || 时间码/时长 | 帧率 | 分辨率 | 引擎
|
||||
//! └ status bar: 就绪 | 缓存 | 代理 | 库写入状态 || 时间码/时长 | 帧率 | 分辨率 | 引擎
|
||||
//! ```
|
||||
|
||||
use std::path::PathBuf;
|
||||
@@ -69,12 +69,13 @@ use crate::panels::timeline::TimelinePanel;
|
||||
mod menu_ids {
|
||||
pub const NEW_PROJECT: usize = 101;
|
||||
pub const OPEN_PROJECT: usize = 102;
|
||||
pub const SAVE: usize = 103;
|
||||
pub const SAVE_AS: usize = 104;
|
||||
pub const EXPORT_PROJECT: usize = 103;
|
||||
pub const CLOSE: usize = 105;
|
||||
pub const EXPORT: usize = 106;
|
||||
pub const QUIT: usize = 107;
|
||||
pub const IMPORT_FOOTAGE: usize = 108;
|
||||
pub const PROJECT_MANAGER: usize = 109;
|
||||
pub const OPEN_FROM_LIBRARY: usize = 110;
|
||||
|
||||
pub const UNDO: usize = 201;
|
||||
pub const REDO: usize = 202;
|
||||
@@ -116,6 +117,9 @@ mod modal_ids {
|
||||
pub const PREFERENCES: usize = 3;
|
||||
pub const EXPORT: usize = 4;
|
||||
pub const EXPORT_PROGRESS: usize = 5;
|
||||
pub const MANAGER: usize = 6;
|
||||
pub const MANAGER_RENAME: usize = 7;
|
||||
pub const MANAGER_DELETE: usize = 8;
|
||||
}
|
||||
|
||||
/// What a picked platform-dialog path should do.
|
||||
@@ -123,11 +127,18 @@ mod modal_ids {
|
||||
enum FileAction {
|
||||
ImportFootage,
|
||||
Open,
|
||||
/// Export the current project to a file (`.ove` / `.otio` / `.fcpxml`,
|
||||
/// dispatched by extension).
|
||||
SaveAs,
|
||||
/// Import a project file into the library (the manager's 导入).
|
||||
ImportProject,
|
||||
/// Export the selected library project (the manager's 导出; the row's
|
||||
/// uuid is stashed in [`OakApp::pending_export`]).
|
||||
ExportProject,
|
||||
}
|
||||
|
||||
/// The modal currently layered on top of the shell, if any.
|
||||
enum ModalState {
|
||||
enum ModalState<E: AppEngine> {
|
||||
None,
|
||||
Preferences {
|
||||
modal: Entity<Modal>,
|
||||
@@ -141,6 +152,19 @@ enum ModalState {
|
||||
modal: Entity<Modal>,
|
||||
content: Entity<ProgressContent>,
|
||||
},
|
||||
/// The project manager (M13 D4).
|
||||
Manager {
|
||||
modal: Entity<Modal>,
|
||||
content: Entity<crate::manager::ProjectManager<E>>,
|
||||
},
|
||||
/// The manager's rename prompt.
|
||||
ManagerRename {
|
||||
modal: Entity<Modal>,
|
||||
content: Entity<crate::manager::NamePrompt>,
|
||||
uuid: String,
|
||||
},
|
||||
/// The manager's delete confirmation.
|
||||
ManagerDelete { modal: Entity<Modal>, uuid: String },
|
||||
}
|
||||
|
||||
/// A running export: the session the tick loop drains for progress.
|
||||
@@ -148,14 +172,17 @@ struct ExportRun {
|
||||
session: ExportSession,
|
||||
}
|
||||
|
||||
impl ModalState {
|
||||
impl<E: AppEngine> ModalState<E> {
|
||||
/// The modal entity currently shown, if any.
|
||||
fn modal_entity(&self) -> Option<Entity<Modal>> {
|
||||
match self {
|
||||
ModalState::None => None,
|
||||
ModalState::Preferences { modal, .. }
|
||||
| ModalState::Export { modal, .. }
|
||||
| ModalState::Progress { modal, .. } => Some(modal.clone()),
|
||||
| ModalState::Progress { modal, .. }
|
||||
| ModalState::Manager { modal, .. }
|
||||
| ModalState::ManagerRename { modal, .. }
|
||||
| ModalState::ManagerDelete { modal, .. } => Some(modal.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,9 +282,11 @@ pub struct OakApp<E: AppEngine> {
|
||||
/// Whether the dark theme is active (toggles via 视图 → 主题).
|
||||
dark: bool,
|
||||
/// The modal currently shown on top of the shell, if any.
|
||||
modal: ModalState,
|
||||
modal: ModalState<E>,
|
||||
/// The running export session, if any.
|
||||
export: Option<ExportRun>,
|
||||
/// The library row pending an export save dialog (manager 导出).
|
||||
pending_export: Option<String>,
|
||||
}
|
||||
|
||||
impl<E: AppEngine> OakApp<E> {
|
||||
@@ -459,6 +488,7 @@ impl<E: AppEngine> OakApp<E> {
|
||||
dark: true,
|
||||
modal: ModalState::None,
|
||||
export: None,
|
||||
pending_export: None,
|
||||
};
|
||||
|
||||
// Open the CLI-provided project once the shell is up.
|
||||
@@ -491,11 +521,11 @@ impl<E: AppEngine> OakApp<E> {
|
||||
use menu_ids::*;
|
||||
match item {
|
||||
// --- File ------------------------------------------------------
|
||||
NEW_PROJECT => self.engine.update(cx, |engine, cx| engine.new_project(cx)),
|
||||
NEW_PROJECT => self.new_project(cx),
|
||||
OPEN_PROJECT => self.open_file_dialog(FileAction::Open, cx),
|
||||
OPEN_FROM_LIBRARY | PROJECT_MANAGER => self.show_project_manager(cx),
|
||||
IMPORT_FOOTAGE => self.open_file_dialog(FileAction::ImportFootage, cx),
|
||||
SAVE => self.save_project(None, cx),
|
||||
SAVE_AS => self.open_file_dialog(FileAction::SaveAs, cx),
|
||||
EXPORT_PROJECT => self.open_file_dialog(FileAction::SaveAs, cx),
|
||||
CLOSE => self
|
||||
.engine
|
||||
.update(cx, |engine, cx| engine.close_project(cx)),
|
||||
@@ -580,7 +610,8 @@ impl<E: AppEngine> OakApp<E> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the project (to its own filename, or the given `path`).
|
||||
/// Exports the project to a file (the 导出工程文件… action's target; the
|
||||
/// format is dispatched by the picked path's extension).
|
||||
fn save_project(&mut self, path: Option<PathBuf>, cx: &mut Context<Self>) {
|
||||
let result = self
|
||||
.engine
|
||||
@@ -590,6 +621,20 @@ impl<E: AppEngine> OakApp<E> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 新建项目: creates a blank project in the library and opens it (the
|
||||
/// write-through persists it from the first edit). Falls back to the
|
||||
/// engine's plain new-project path when the library is unavailable.
|
||||
fn new_project(&mut self, cx: &mut Context<Self>) {
|
||||
let name = crate::i18n::tr("manager.new.default_name").to_string();
|
||||
let result = self
|
||||
.engine
|
||||
.update(cx, |engine, cx| engine.library_create_project(&name, cx));
|
||||
if let Err(err) = result {
|
||||
println!("[file] library create failed ({err}); plain new project");
|
||||
self.engine.update(cx, |engine, cx| engine.new_project(cx));
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the timeline's selected clips (ripple or gap) through the
|
||||
/// engine's edit commands.
|
||||
fn delete_timeline_selection(&mut self, ripple: bool, cx: &mut Context<Self>) {
|
||||
@@ -660,6 +705,228 @@ impl<E: AppEngine> OakApp<E> {
|
||||
.detach();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Project manager (M13 D4)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Opens the project manager (startup without a project argument, and
|
||||
/// 文件 → 项目管理器 / 从库中打开…). Re-entrant: an already-open
|
||||
/// manager just reloads its list.
|
||||
pub fn show_project_manager(&mut self, cx: &mut Context<Self>) {
|
||||
if let ModalState::Manager { content, .. } = &self.modal {
|
||||
let content = content.clone();
|
||||
content.update(cx, |manager, cx| manager.reload(cx));
|
||||
return;
|
||||
}
|
||||
let engine = self.engine.clone();
|
||||
self.spawn_modal(cx, move |window, app| {
|
||||
let content = app.new(|cx| crate::manager::ProjectManager::new(engine, window, cx));
|
||||
let modal = app.new(|cx| {
|
||||
Modal::new(
|
||||
modal_ids::MANAGER,
|
||||
ModalOptions::new(crate::i18n::tr("manager.title"), px(880.0))
|
||||
.with_button(DialogButton::cancel(crate::i18n::tr("dialog.close"))),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.with_content(content.clone())
|
||||
});
|
||||
ModalState::Manager { modal, content }
|
||||
});
|
||||
// The content's requests (open / create / rename / ...) route here.
|
||||
if let ModalState::Manager { content, .. } = &self.modal {
|
||||
let content = content.clone();
|
||||
cx.subscribe(
|
||||
&content,
|
||||
|this, _content, event: &crate::manager::ManagerEvent, cx| {
|
||||
this.on_manager_event(event, cx);
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes a project-manager request through the engine. Open / Create
|
||||
/// close the dialog on success; the mutating actions reload the list;
|
||||
/// failures land in the dialog's status line.
|
||||
fn on_manager_event(&mut self, event: &crate::manager::ManagerEvent, cx: &mut Context<Self>) {
|
||||
use crate::manager::ManagerEvent as E;
|
||||
match event {
|
||||
E::Create => {
|
||||
let name = crate::i18n::tr("manager.new.default_name").to_string();
|
||||
let result = self
|
||||
.engine
|
||||
.update(cx, |engine, cx| engine.library_create_project(&name, cx));
|
||||
match result {
|
||||
Ok(()) => self.close_modal(cx),
|
||||
Err(err) => self.manager_status(err, cx),
|
||||
}
|
||||
}
|
||||
E::Open(uuid) => {
|
||||
let uuid = uuid.clone();
|
||||
let result = self
|
||||
.engine
|
||||
.update(cx, |engine, cx| engine.library_open_project(&uuid, cx));
|
||||
match result {
|
||||
Ok(()) => self.close_modal(cx),
|
||||
Err(err) => self.manager_status(err, cx),
|
||||
}
|
||||
}
|
||||
E::Rename(uuid) => self.open_manager_rename(uuid.clone(), cx),
|
||||
E::Duplicate(uuid) => {
|
||||
let result = self
|
||||
.engine
|
||||
.update(cx, |engine, _cx| engine.library_duplicate_project(uuid));
|
||||
match result {
|
||||
Ok(()) => self.reload_manager(cx),
|
||||
Err(err) => self.manager_status(err, cx),
|
||||
}
|
||||
}
|
||||
E::Delete(uuid) => self.open_manager_delete(uuid.clone(), cx),
|
||||
E::Import => self.open_file_dialog(FileAction::ImportProject, cx),
|
||||
E::Export(uuid) => self.open_manager_export(uuid.clone(), cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reloads the open manager's list (after a library mutation).
|
||||
fn reload_manager(&mut self, cx: &mut Context<Self>) {
|
||||
if let ModalState::Manager { content, .. } = &self.modal {
|
||||
let content = content.clone();
|
||||
content.update(cx, |manager, cx| manager.reload(cx));
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows an engine error in the open manager's status line (or logs it
|
||||
/// when the manager is gone).
|
||||
fn manager_status(&mut self, message: String, cx: &mut Context<Self>) {
|
||||
if let ModalState::Manager { content, .. } = &self.modal {
|
||||
let content = content.clone();
|
||||
content.update(cx, |manager, cx| manager.set_status(Some(message), cx));
|
||||
} else {
|
||||
println!("[manager] {message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected row's display name in the open manager (used to seed
|
||||
/// the rename prompt / the delete confirmation / the export filename).
|
||||
fn manager_selected_name(&self, cx: &App) -> Option<String> {
|
||||
match &self.modal {
|
||||
ModalState::Manager { content, .. } => content.read(cx).selected_name(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Swaps the manager for the rename prompt of row `uuid`.
|
||||
fn open_manager_rename(&mut self, uuid: String, cx: &mut Context<Self>) {
|
||||
let initial = self.manager_selected_name(cx).unwrap_or_default();
|
||||
self.spawn_modal(cx, move |window, app| {
|
||||
let content = app.new(|cx| crate::manager::NamePrompt::new(&initial, cx));
|
||||
let modal = app.new(|cx| {
|
||||
Modal::new(
|
||||
modal_ids::MANAGER_RENAME,
|
||||
ModalOptions::new(crate::i18n::tr("manager.rename.title"), px(380.0))
|
||||
.with_button(DialogButton::primary(crate::i18n::tr(
|
||||
"manager.rename.title",
|
||||
)))
|
||||
.with_button(DialogButton::cancel(crate::i18n::tr("dialog.cancel"))),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.with_content(content.clone())
|
||||
});
|
||||
ModalState::ManagerRename { modal, content, uuid }
|
||||
});
|
||||
}
|
||||
|
||||
/// Swaps the manager for the delete confirmation of row `uuid`.
|
||||
fn open_manager_delete(&mut self, uuid: String, cx: &mut Context<Self>) {
|
||||
let name = self.manager_selected_name(cx).unwrap_or_default();
|
||||
let text = crate::i18n::tr("manager.delete.confirm").replace("{name}", &name);
|
||||
self.spawn_modal(cx, move |window, app| {
|
||||
let content = app.new(|_cx| crate::manager::ConfirmContent::new(text));
|
||||
let modal = app.new(|cx| {
|
||||
Modal::new(
|
||||
modal_ids::MANAGER_DELETE,
|
||||
ModalOptions::new(crate::i18n::tr("manager.delete.title"), px(420.0))
|
||||
.with_button(DialogButton::primary(crate::i18n::tr("manager.delete")))
|
||||
.with_button(DialogButton::cancel(crate::i18n::tr("dialog.cancel"))),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.with_content(content.clone())
|
||||
});
|
||||
ModalState::ManagerDelete { modal, uuid }
|
||||
});
|
||||
}
|
||||
|
||||
/// Confirms the rename prompt (button 0).
|
||||
fn confirm_manager_rename(&mut self, cx: &mut Context<Self>) {
|
||||
let ModalState::ManagerRename { content, uuid, .. } = &self.modal else {
|
||||
return;
|
||||
};
|
||||
let name = content.read(cx).value(cx);
|
||||
let uuid = uuid.clone();
|
||||
if name.is_empty() {
|
||||
self.back_to_manager(cx);
|
||||
return;
|
||||
}
|
||||
let result = self
|
||||
.engine
|
||||
.update(cx, |engine, _cx| engine.library_rename_project(&uuid, &name));
|
||||
match result {
|
||||
Ok(()) => self.back_to_manager(cx),
|
||||
Err(err) => {
|
||||
self.back_to_manager(cx);
|
||||
self.manager_status(err, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirms the delete confirmation (button 0).
|
||||
fn confirm_manager_delete(&mut self, cx: &mut Context<Self>) {
|
||||
let ModalState::ManagerDelete { uuid, .. } = &self.modal else {
|
||||
return;
|
||||
};
|
||||
let uuid = uuid.clone();
|
||||
let result = self
|
||||
.engine
|
||||
.update(cx, |engine, _cx| engine.library_delete_project(&uuid));
|
||||
match result {
|
||||
Ok(()) => self.back_to_manager(cx),
|
||||
Err(err) => {
|
||||
self.back_to_manager(cx);
|
||||
self.manager_status(err, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns from a manager sub-dialog (rename / delete) to the manager.
|
||||
fn back_to_manager(&mut self, cx: &mut Context<Self>) {
|
||||
self.modal = ModalState::None;
|
||||
self.show_project_manager(cx);
|
||||
}
|
||||
|
||||
/// Opens the platform save dialog for exporting the library row `uuid`
|
||||
/// (the suggested name is `<project>.ove`; the format follows the
|
||||
/// extension the user picks).
|
||||
fn open_manager_export(&mut self, uuid: String, cx: &mut Context<Self>) {
|
||||
let name = self
|
||||
.manager_selected_name(cx)
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or_else(|| "project".to_string());
|
||||
self.pending_export = Some(uuid);
|
||||
let receiver =
|
||||
cx.prompt_for_new_path(&PathBuf::from("."), Some(&format!("{name}.ove")));
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Ok(Ok(Some(path))) = receiver.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.on_file_paths(FileAction::ExportProject, vec![path], cx);
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Modal dialogs
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -679,10 +946,15 @@ impl<E: AppEngine> OakApp<E> {
|
||||
/// through a weak handle *inside* the window callback would re-enter this
|
||||
/// entity while it is already being updated (the crash seen when opening
|
||||
/// Preferences from a menu action).
|
||||
///
|
||||
/// The caller must NOT be inside a window update itself (e.g.
|
||||
/// `WindowHandle::update`): the nested `update_window` below would fail
|
||||
/// and the modal would silently not open. Drive the root entity instead
|
||||
/// (menu actions and entity updates are fine).
|
||||
fn spawn_modal(
|
||||
&mut self,
|
||||
cx: &mut Context<Self>,
|
||||
build: impl FnOnce(&mut Window, &mut App) -> ModalState,
|
||||
build: impl FnOnce(&mut Window, &mut App) -> ModalState<E>,
|
||||
) {
|
||||
let windows = cx.windows();
|
||||
let Some(handle) = windows.first() else {
|
||||
@@ -704,14 +976,15 @@ impl<E: AppEngine> OakApp<E> {
|
||||
|
||||
/// Opens the platform file dialog for `action` and routes the picked
|
||||
/// path(s) through the engine. Open / Import use the path picker (import
|
||||
/// allows multiple files); Save As asks for a new path next to the current
|
||||
/// project. The picker resolves asynchronously, so the chosen path is
|
||||
/// applied in a spawned task via [`Self::on_file_paths`].
|
||||
/// footage allows multiple files); Save As and the manager's export ask
|
||||
/// for a new path. The picker resolves asynchronously, so the chosen
|
||||
/// path is applied in a spawned task via [`Self::on_file_paths`].
|
||||
fn open_file_dialog(&mut self, action: FileAction, cx: &mut Context<Self>) {
|
||||
match action {
|
||||
FileAction::Open | FileAction::ImportFootage => {
|
||||
FileAction::Open | FileAction::ImportFootage | FileAction::ImportProject => {
|
||||
let prompt = match action {
|
||||
FileAction::Open => crate::i18n::tr("file.open.title"),
|
||||
FileAction::ImportProject => crate::i18n::tr("manager.import.title"),
|
||||
_ => crate::i18n::tr("file.import_footage.title"),
|
||||
};
|
||||
let receiver = cx.prompt_for_paths(PathPromptOptions {
|
||||
@@ -757,12 +1030,49 @@ impl<E: AppEngine> OakApp<E> {
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
// The manager's export prompts in `open_manager_export` (it needs
|
||||
// the selection's suggested filename).
|
||||
FileAction::ExportProject => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies paths picked in the platform dialog through the engine, using
|
||||
/// the action's routing (open / import / save-as).
|
||||
/// the action's routing (open / import / export).
|
||||
fn on_file_paths(&mut self, action: FileAction, paths: Vec<PathBuf>, cx: &mut Context<Self>) {
|
||||
// The manager's library import/export refresh the open manager and
|
||||
// report failures in its status line.
|
||||
match action {
|
||||
FileAction::ImportProject => {
|
||||
let Some(path) = paths.first() else {
|
||||
return;
|
||||
};
|
||||
let result = self
|
||||
.engine
|
||||
.update(cx, |engine, _cx| engine.library_import_project(path.clone()));
|
||||
match result {
|
||||
Ok(uuid) => {
|
||||
println!("[manager] imported \"{}\" as {uuid}", path.display());
|
||||
self.reload_manager(cx);
|
||||
}
|
||||
Err(err) => self.manager_status(err, cx),
|
||||
}
|
||||
return;
|
||||
}
|
||||
FileAction::ExportProject => {
|
||||
let (Some(uuid), Some(path)) = (self.pending_export.take(), paths.first()) else {
|
||||
return;
|
||||
};
|
||||
let result = self
|
||||
.engine
|
||||
.update(cx, |engine, _cx| engine.library_export_project(&uuid, path.clone()));
|
||||
match result {
|
||||
Ok(()) => println!("[manager] exported to \"{}\"", path.display()),
|
||||
Err(err) => self.manager_status(err, cx),
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let result = self.engine.update(cx, |engine, cx| match action {
|
||||
FileAction::Open => match paths.first() {
|
||||
Some(path) => engine.open_project_path(path.clone(), cx),
|
||||
@@ -786,6 +1096,8 @@ impl<E: AppEngine> OakApp<E> {
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
// Handled above (the manager's library import/export).
|
||||
FileAction::ImportProject | FileAction::ExportProject => Ok(()),
|
||||
});
|
||||
if let Err(err) = result {
|
||||
println!("[file] {action:?} failed: {err}");
|
||||
@@ -963,6 +1275,21 @@ impl<E: AppEngine> OakApp<E> {
|
||||
}
|
||||
}
|
||||
modal_ids::PREFERENCES => self.close_modal(cx),
|
||||
modal_ids::MANAGER => self.close_modal(cx),
|
||||
modal_ids::MANAGER_RENAME => {
|
||||
if *button == 0 {
|
||||
self.confirm_manager_rename(cx);
|
||||
} else {
|
||||
self.back_to_manager(cx);
|
||||
}
|
||||
}
|
||||
modal_ids::MANAGER_DELETE => {
|
||||
if *button == 0 {
|
||||
self.confirm_manager_delete(cx);
|
||||
} else {
|
||||
self.back_to_manager(cx);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
ModalEvent::Dismissed { control } => match *control {
|
||||
@@ -971,6 +1298,10 @@ impl<E: AppEngine> OakApp<E> {
|
||||
self.cancel_export(cx);
|
||||
self.close_modal(cx);
|
||||
}
|
||||
// Escape from a manager sub-dialog returns to the manager.
|
||||
modal_ids::MANAGER_RENAME | modal_ids::MANAGER_DELETE => {
|
||||
self.back_to_manager(cx);
|
||||
}
|
||||
_ => self.close_modal(cx),
|
||||
},
|
||||
}
|
||||
@@ -1020,11 +1351,12 @@ fn make_menus(dark: bool) -> Vec<MenuBarEntry> {
|
||||
tr("menu.file"),
|
||||
Menu::new(vec![
|
||||
MenuItem::new(NEW_PROJECT, tr("menu.file.new_project")).with_shortcut("⌘N"),
|
||||
MenuItem::new(OPEN_FROM_LIBRARY, tr("menu.file.open_library")),
|
||||
MenuItem::new(OPEN_PROJECT, tr("menu.file.open_project")).with_shortcut("⌘O"),
|
||||
MenuItem::new(PROJECT_MANAGER, tr("menu.file.project_manager")).separated(),
|
||||
MenuItem::new(IMPORT_FOOTAGE, tr("menu.file.import_footage")),
|
||||
MenuItem::new(SAVE, tr("menu.file.save")).with_shortcut("⌘S"),
|
||||
MenuItem::new(SAVE_AS, tr("menu.file.save_as"))
|
||||
.with_shortcut("⇧⌘S")
|
||||
MenuItem::new(EXPORT_PROJECT, tr("menu.file.export_project"))
|
||||
.with_shortcut("⌘S")
|
||||
.separated(),
|
||||
MenuItem::new(CLOSE, tr("menu.file.close")),
|
||||
MenuItem::new(EXPORT, tr("menu.file.export"))
|
||||
@@ -1174,28 +1506,53 @@ fn run_with<E: AppEngine>(args: AppArgs) {
|
||||
// Restore the persisted UI language (config `Language` key) before the
|
||||
// first window renders.
|
||||
crate::i18n::init();
|
||||
// M13 D4: enable the write-through project library (SQLite at the
|
||||
// default location) unless the user configured the backend
|
||||
// explicitly.
|
||||
crate::oakui::real::configure_storage();
|
||||
cx.init_colors();
|
||||
let bounds = Bounds::centered(None, size(px(1600.0), px(900.0)), cx);
|
||||
let initial = initial.clone();
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
..Default::default()
|
||||
},
|
||||
|window, cx| {
|
||||
// Compact pro-app text metrics: gpui's default rem is
|
||||
// 16px (desktop-app large); 14px matches the design's
|
||||
// density. All rem-based text scales; px spacing is
|
||||
// unaffected.
|
||||
window.set_rem_size(px(14.0));
|
||||
build_root::<E>(window, initial, cx)
|
||||
},
|
||||
)
|
||||
.expect("failed to open the main window");
|
||||
let show_manager = initial.is_none();
|
||||
let mut root_slot = None;
|
||||
let window = cx
|
||||
.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
..Default::default()
|
||||
},
|
||||
|window, cx| {
|
||||
// Compact pro-app text metrics: gpui's default rem is
|
||||
// 16px (desktop-app large); 14px matches the design's
|
||||
// density. All rem-based text scales; px spacing is
|
||||
// unaffected.
|
||||
window.set_rem_size(px(14.0));
|
||||
let root = build_root::<E>(window, initial, cx);
|
||||
root_slot = Some(root.clone());
|
||||
root
|
||||
},
|
||||
)
|
||||
.expect("failed to open the main window");
|
||||
let _ = window;
|
||||
|
||||
// No project on the command line: the DaVinci-style project manager
|
||||
// greets instead of an empty shell. Drives the ROOT ENTITY (not the
|
||||
// window handle): a window update borrows the window, and building a
|
||||
// modal inside it would re-enter it (spawn_modal needs a free
|
||||
// `update_window`).
|
||||
if show_manager {
|
||||
if let Some(root) = &root_slot {
|
||||
root.update(cx, |app, cx| app.show_project_manager(cx));
|
||||
}
|
||||
}
|
||||
|
||||
cx.activate(true);
|
||||
cx.on_window_closed(|cx, _| {
|
||||
if cx.windows().is_empty() {
|
||||
// Exit path (plan M13 §2): drain the write-through backlog
|
||||
// (save + snapshot of every still-bound project) and stop
|
||||
// the facade's snapshot thread before quitting.
|
||||
crate::oakui::real::storage_flush();
|
||||
cx.quit();
|
||||
}
|
||||
})
|
||||
@@ -1206,6 +1563,7 @@ fn run_with<E: AppEngine>(args: AppArgs) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::oakui::EngineGateway as _;
|
||||
use gpui::{px, size, TestAppContext};
|
||||
|
||||
/// The 视图/View menu carries a 语言/Language submenu whose items are
|
||||
@@ -1299,9 +1657,10 @@ mod tests {
|
||||
assert_eq!(dark_item(false).checked, Some(false));
|
||||
}
|
||||
|
||||
/// The File menu exposes the full project lifecycle actions (open /
|
||||
/// save / save-as / close / export) and the Edit menu the undo stack
|
||||
/// plus the delete variants, across both languages.
|
||||
/// The File menu exposes the full project lifecycle actions (new /
|
||||
/// open-from-library / open-file / manager / export-project / close /
|
||||
/// export) and the Edit menu the undo stack plus the delete variants,
|
||||
/// across both languages.
|
||||
#[test]
|
||||
fn file_and_edit_menus_cover_the_project_lifecycle() {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
@@ -1317,9 +1676,10 @@ mod tests {
|
||||
let file = entry("File(F)");
|
||||
for id in [
|
||||
menu_ids::NEW_PROJECT,
|
||||
menu_ids::OPEN_FROM_LIBRARY,
|
||||
menu_ids::OPEN_PROJECT,
|
||||
menu_ids::SAVE,
|
||||
menu_ids::SAVE_AS,
|
||||
menu_ids::PROJECT_MANAGER,
|
||||
menu_ids::EXPORT_PROJECT,
|
||||
menu_ids::CLOSE,
|
||||
menu_ids::EXPORT,
|
||||
menu_ids::QUIT,
|
||||
@@ -1349,7 +1709,7 @@ mod tests {
|
||||
.menu
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| item.id == menu_ids::SAVE_AS));
|
||||
.any(|item| item.id == menu_ids::EXPORT_PROJECT));
|
||||
}
|
||||
|
||||
/// Opening 视图 → Preferences… must not crash: the dialog content and the
|
||||
@@ -1463,4 +1823,244 @@ mod tests {
|
||||
assert!(d.project.is_none());
|
||||
assert!(!d.mock);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Project manager (M13 D4)
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// A running app shell on the mock engine (en-US), plus its root. The
|
||||
/// caller holds the language lock (the tests flip the process-global
|
||||
/// language).
|
||||
fn mock_shell(
|
||||
cx: &mut TestAppContext,
|
||||
) -> (
|
||||
gpui::WindowHandle<OakApp<MockEngine>>,
|
||||
Entity<OakApp<MockEngine>>,
|
||||
) {
|
||||
crate::i18n::set_language(crate::i18n::Language::EnUs);
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(1600.0), px(900.0)), |window, cx| {
|
||||
OakApp::<MockEngine>::new(window, None, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let root = window.root(cx).expect("app root");
|
||||
(window, root)
|
||||
}
|
||||
|
||||
/// Opens the manager and returns its content entity.
|
||||
fn open_manager(
|
||||
cx: &mut TestAppContext,
|
||||
root: &Entity<OakApp<MockEngine>>,
|
||||
) -> Entity<crate::manager::ProjectManager<MockEngine>> {
|
||||
cx.update(|app| root.update(app, |app, cx| app.show_project_manager(cx)));
|
||||
cx.run_until_parked();
|
||||
let content = cx.read(|app| match &root.read(app).modal {
|
||||
ModalState::Manager { content, .. } => content.clone(),
|
||||
_ => panic!("the manager modal should be open"),
|
||||
});
|
||||
content
|
||||
}
|
||||
|
||||
/// The manager's listed rows.
|
||||
fn manager_rows(
|
||||
cx: &mut TestAppContext,
|
||||
content: &Entity<crate::manager::ProjectManager<MockEngine>>,
|
||||
) -> Vec<crate::oakui::LibraryProject> {
|
||||
cx.read(|app| content.read(app).rows().to_vec())
|
||||
}
|
||||
|
||||
/// The manager lists the mock library; opening a row (the double-click
|
||||
/// / 打开 route) drives the engine's library open and closes the dialog.
|
||||
#[gpui::test]
|
||||
async fn manager_lists_and_opens(cx: &mut TestAppContext) {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let (_window, root) = mock_shell(cx);
|
||||
let content = open_manager(cx, &root);
|
||||
let rows = manager_rows(cx, &content);
|
||||
assert_eq!(rows.len(), 3, "the mock library seeds three rows");
|
||||
assert_eq!(rows[0].name, "第一稿", "most recently modified first");
|
||||
assert!(rows[0].track_count > 0 && rows[0].footage_count > 0);
|
||||
|
||||
// Select the second row and open it.
|
||||
let uuid = rows[1].uuid.clone();
|
||||
cx.update(|app| content.update(app, |m, cx| m.select(&uuid, cx)));
|
||||
cx.update(|app| {
|
||||
root.update(app, |app, cx| {
|
||||
app.on_manager_event(&crate::manager::ManagerEvent::Open(uuid.clone()), cx)
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let opened = cx.read(|app| root.read(app).engine.read(app).library_opened().to_vec());
|
||||
assert_eq!(opened, vec![uuid], "the engine opened the selected row");
|
||||
let name = cx.read(|app| root.read(app).engine.read(app).project().unwrap().name.clone());
|
||||
assert_eq!(name, "宣传片 v3");
|
||||
let modal_none = cx.read(|app| matches!(root.read(app).modal, ModalState::None));
|
||||
assert!(modal_none, "a successful open closes the manager");
|
||||
}
|
||||
|
||||
/// Create / rename / duplicate / delete round-trip through the manager
|
||||
/// and its sub-dialogs.
|
||||
#[gpui::test]
|
||||
async fn manager_create_rename_duplicate_delete(cx: &mut TestAppContext) {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let (_window, root) = mock_shell(cx);
|
||||
let content = open_manager(cx, &root);
|
||||
|
||||
// Create: a new row appears and the project opens (dialog closes).
|
||||
cx.update(|app| {
|
||||
root.update(app, |app, cx| {
|
||||
app.on_manager_event(&crate::manager::ManagerEvent::Create, cx)
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let rows = cx.read(|app| root.read(app).engine.read(app).library_projects().unwrap());
|
||||
assert_eq!(rows.len(), 4);
|
||||
let created = rows
|
||||
.iter()
|
||||
.find(|row| row.name == "Untitled Project")
|
||||
.expect("the created row")
|
||||
.clone();
|
||||
let project_name = cx.read(|app| root.read(app).engine.read(app).project().unwrap().name.clone());
|
||||
assert_eq!(project_name, "Untitled Project", "create opens the new project");
|
||||
|
||||
// Reopen the manager, select the created row, rename it.
|
||||
let content = open_manager(cx, &root);
|
||||
cx.update(|app| content.update(app, |m, cx| m.select(&created.uuid, cx)));
|
||||
cx.update(|app| {
|
||||
root.update(app, |app, cx| {
|
||||
app.on_manager_event(&crate::manager::ManagerEvent::Rename(created.uuid.clone()), cx)
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let prompt = cx.read(|app| match &root.read(app).modal {
|
||||
ModalState::ManagerRename { content, uuid, .. } => {
|
||||
assert_eq!(uuid, &created.uuid);
|
||||
content.clone()
|
||||
}
|
||||
_ => panic!("the rename prompt should be open"),
|
||||
});
|
||||
cx.update(|app| prompt.update(app, |p, cx| p.set_value("改名为正稿", cx)));
|
||||
cx.update(|app| {
|
||||
root.update(app, |app, cx| {
|
||||
app.on_modal(
|
||||
&ModalEvent::ButtonClicked {
|
||||
control: modal_ids::MANAGER_RENAME,
|
||||
button: 0,
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
// The confirmation swaps in a FRESH manager (the captured content is
|
||||
// stale from here on); assert against the engine's library instead.
|
||||
let rows = cx.read(|app| root.read(app).engine.read(app).library_projects().unwrap());
|
||||
let renamed = rows.iter().find(|row| row.uuid == created.uuid).unwrap();
|
||||
assert_eq!(renamed.name, "改名为正稿");
|
||||
let back = cx.read(|app| matches!(root.read(app).modal, ModalState::Manager { .. }));
|
||||
assert!(back, "a confirmed rename returns to the manager");
|
||||
|
||||
// Duplicate the renamed row.
|
||||
cx.update(|app| {
|
||||
root.update(app, |app, cx| {
|
||||
app.on_manager_event(
|
||||
&crate::manager::ManagerEvent::Duplicate(created.uuid.clone()),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let rows = cx.read(|app| root.read(app).engine.read(app).library_projects().unwrap());
|
||||
assert_eq!(rows.len(), 5);
|
||||
assert!(
|
||||
rows.iter().any(|row| row.name == "改名为正稿 (copy)"),
|
||||
"the copy is named '<name> (copy)': {rows:?}"
|
||||
);
|
||||
|
||||
// Delete the original through the confirmation dialog.
|
||||
cx.update(|app| {
|
||||
root.update(app, |app, cx| {
|
||||
app.on_manager_event(&crate::manager::ManagerEvent::Delete(created.uuid.clone()), cx)
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let confirming = cx.read(|app| matches!(root.read(app).modal, ModalState::ManagerDelete { .. }));
|
||||
assert!(confirming, "the delete confirmation should be open");
|
||||
cx.update(|app| {
|
||||
root.update(app, |app, cx| {
|
||||
app.on_modal(
|
||||
&ModalEvent::ButtonClicked {
|
||||
control: modal_ids::MANAGER_DELETE,
|
||||
button: 0,
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let rows = cx.read(|app| root.read(app).engine.read(app).library_projects().unwrap());
|
||||
assert_eq!(rows.len(), 4);
|
||||
assert!(!rows.iter().any(|row| row.uuid == created.uuid));
|
||||
let back = cx.read(|app| matches!(root.read(app).modal, ModalState::Manager { .. }));
|
||||
assert!(back, "a confirmed delete returns to the manager");
|
||||
}
|
||||
|
||||
/// The manager's 导入 opens the platform path picker and lands the file
|
||||
/// as a new row; 导出 asks for a new path and routes it to the engine.
|
||||
#[gpui::test]
|
||||
async fn manager_import_and_export_route_through_the_platform_dialogs(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let (_window, root) = mock_shell(cx);
|
||||
let content = open_manager(cx, &root);
|
||||
|
||||
// Import.
|
||||
cx.update(|app| {
|
||||
root.update(app, |app, cx| {
|
||||
app.on_manager_event(&crate::manager::ManagerEvent::Import, cx)
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(cx.did_prompt_for_paths(), "import shows the path picker");
|
||||
cx.simulate_path_prompt_response(|options| {
|
||||
assert!(!options.multiple, "project import is single-file");
|
||||
Some(vec![PathBuf::from("/library/先导片.ove")])
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let rows = manager_rows(cx, &content);
|
||||
assert_eq!(rows.len(), 4);
|
||||
assert!(
|
||||
rows.iter().any(|row| row.name == "先导片"),
|
||||
"the imported file becomes a row named by its stem: {rows:?}"
|
||||
);
|
||||
|
||||
// Export the imported row.
|
||||
let uuid = rows
|
||||
.iter()
|
||||
.find(|row| row.name == "先导片")
|
||||
.unwrap()
|
||||
.uuid
|
||||
.clone();
|
||||
cx.update(|app| content.update(app, |m, cx| m.select(&uuid, cx)));
|
||||
cx.update(|app| {
|
||||
root.update(app, |app, cx| {
|
||||
app.on_manager_event(&crate::manager::ManagerEvent::Export(uuid.clone()), cx)
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(
|
||||
cx.did_prompt_for_new_path(),
|
||||
"export shows the save dialog"
|
||||
);
|
||||
cx.simulate_new_path_selection(|_dir| Some(PathBuf::from("/library/先导片.otio")));
|
||||
cx.run_until_parked();
|
||||
let exported = cx.read(|app| root.read(app).engine.read(app).library_exported().to_vec());
|
||||
assert_eq!(
|
||||
exported,
|
||||
vec![(uuid, PathBuf::from("/library/先导片.otio"))],
|
||||
"the picked path routes to the engine's library export"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+66
-11
@@ -180,10 +180,11 @@ const EN: &[(&str, &str)] = &[
|
||||
("menu.help", "Help(H)"),
|
||||
// --- File ---
|
||||
("menu.file.new_project", "New Project…"),
|
||||
("menu.file.open_project", "Open Project…"),
|
||||
("menu.file.open_project", "Open Project File…"),
|
||||
("menu.file.open_library", "Open from Library…"),
|
||||
("menu.file.project_manager", "Project Manager…"),
|
||||
("menu.file.import_footage", "Import Footage…"),
|
||||
("menu.file.save", "Save"),
|
||||
("menu.file.save_as", "Save As…"),
|
||||
("menu.file.export_project", "Export Project File…"),
|
||||
("menu.file.close", "Close Project"),
|
||||
("menu.file.export", "Export…"),
|
||||
("menu.file.quit", "Quit"),
|
||||
@@ -240,9 +241,34 @@ const EN: &[(&str, &str)] = &[
|
||||
("status.ready", "Ready"),
|
||||
("status.cache", "Cache: Enabled"),
|
||||
("status.proxy", "Proxy: Off"),
|
||||
("status.autosave", "Autosave: 3 min ago"),
|
||||
("status.storage.written", "Library: written"),
|
||||
("status.storage.unbound", "Library: off"),
|
||||
("status.storage.error", "Library: write failed"),
|
||||
("status.untitled", "Untitled Project"),
|
||||
("status.backend", "Engine:"),
|
||||
// --- project manager ---
|
||||
("manager.title", "Project Manager"),
|
||||
("manager.new", "New Project"),
|
||||
("manager.new.default_name", "Untitled Project"),
|
||||
("manager.open", "Open"),
|
||||
("manager.rename", "Rename…"),
|
||||
("manager.rename.title", "Rename Project"),
|
||||
("manager.rename.label", "New name"),
|
||||
("manager.duplicate", "Duplicate"),
|
||||
("manager.delete", "Delete"),
|
||||
("manager.delete.title", "Delete Project"),
|
||||
("manager.delete.confirm", "Delete project \"{name}\" from the library? This cannot be undone."),
|
||||
("manager.import", "Import…"),
|
||||
("manager.import.title", "Import Project"),
|
||||
("manager.export", "Export…"),
|
||||
("manager.export.title", "Export Project"),
|
||||
("manager.col.name", "Name"),
|
||||
("manager.col.modified", "Modified"),
|
||||
("manager.col.duration", "Duration"),
|
||||
("manager.col.tracks", "Tracks"),
|
||||
("manager.col.clips", "Clips"),
|
||||
("manager.col.footage", "Footage"),
|
||||
("manager.empty", "No projects in the library yet."),
|
||||
// --- timeline toolbar ---
|
||||
("timeline.tool.select", "Select"),
|
||||
("timeline.tool.razor", "Razor"),
|
||||
@@ -327,10 +353,11 @@ const ZH: &[(&str, &str)] = &[
|
||||
("menu.help", "帮助(H)"),
|
||||
// --- File ---
|
||||
("menu.file.new_project", "新建项目…"),
|
||||
("menu.file.open_project", "打开项目…"),
|
||||
("menu.file.open_project", "打开工程文件…"),
|
||||
("menu.file.open_library", "从库中打开…"),
|
||||
("menu.file.project_manager", "项目管理器…"),
|
||||
("menu.file.import_footage", "导入素材…"),
|
||||
("menu.file.save", "保存"),
|
||||
("menu.file.save_as", "另存为…"),
|
||||
("menu.file.export_project", "导出工程文件…"),
|
||||
("menu.file.close", "关闭项目"),
|
||||
("menu.file.export", "导出…"),
|
||||
("menu.file.quit", "退出"),
|
||||
@@ -387,9 +414,37 @@ const ZH: &[(&str, &str)] = &[
|
||||
("status.ready", "就绪"),
|
||||
("status.cache", "缓存:已启用"),
|
||||
("status.proxy", "代理:关"),
|
||||
("status.autosave", "自动保存:3分钟前"),
|
||||
("status.storage.written", "库:已写入"),
|
||||
("status.storage.unbound", "库:未启用"),
|
||||
("status.storage.error", "库:写入失败"),
|
||||
("status.untitled", "未命名项目"),
|
||||
("status.backend", "引擎:"),
|
||||
// --- project manager ---
|
||||
("manager.title", "项目管理器"),
|
||||
("manager.new", "新建项目"),
|
||||
("manager.new.default_name", "未命名项目"),
|
||||
("manager.open", "打开"),
|
||||
("manager.rename", "重命名…"),
|
||||
("manager.rename.title", "重命名工程"),
|
||||
("manager.rename.label", "新名称"),
|
||||
("manager.duplicate", "复制"),
|
||||
("manager.delete", "删除"),
|
||||
("manager.delete.title", "删除工程"),
|
||||
(
|
||||
"manager.delete.confirm",
|
||||
"从库中删除工程“{name}”?此操作不可撤销。",
|
||||
),
|
||||
("manager.import", "导入…"),
|
||||
("manager.import.title", "导入工程"),
|
||||
("manager.export", "导出…"),
|
||||
("manager.export.title", "导出工程"),
|
||||
("manager.col.name", "名称"),
|
||||
("manager.col.modified", "修改时间"),
|
||||
("manager.col.duration", "时长"),
|
||||
("manager.col.tracks", "轨道"),
|
||||
("manager.col.clips", "片段"),
|
||||
("manager.col.footage", "素材"),
|
||||
("manager.empty", "库中还没有工程。"),
|
||||
// --- timeline toolbar ---
|
||||
("timeline.tool.select", "选择"),
|
||||
("timeline.tool.razor", "剃刀"),
|
||||
@@ -594,11 +649,11 @@ mod tests {
|
||||
fn switching_flips_a_sample_string() {
|
||||
let _guard = lang_lock().lock().unwrap();
|
||||
set_language(Language::EnUs);
|
||||
assert_eq!(tr("menu.file.save"), "Save");
|
||||
assert_eq!(tr("menu.file.export_project"), "Export Project File…");
|
||||
set_language(Language::ZhCN);
|
||||
assert_eq!(tr("menu.file.save"), "保存");
|
||||
assert_eq!(tr("menu.file.export_project"), "导出工程文件…");
|
||||
set_language(Language::EnUs);
|
||||
assert_eq!(tr("menu.file.save"), "Save");
|
||||
assert_eq!(tr("menu.file.export_project"), "Export Project File…");
|
||||
}
|
||||
|
||||
/// `sync_widgets` installs the active language's strings into the widget
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
//! * [`app`] — the window shell: menu bar, dock layout, status bar, modal
|
||||
//! dialogs (file open/save-as, preferences, export), tick loop.
|
||||
//! * [`dialogs`] — the preferences and export dialog content views.
|
||||
//! * [`manager`] — the project manager window (M13 D4): the library browser
|
||||
//! with new / open / rename / duplicate / delete / import / export.
|
||||
//! * [`panels`] — the dockable panels (viewers, timeline, inspector, ...).
|
||||
//! * [`oakui`] — the engine gateway trait, the mock + real implementations,
|
||||
//! and the pure view-state logic (timecode, transport).
|
||||
@@ -51,6 +53,7 @@
|
||||
pub mod app;
|
||||
pub mod dialogs;
|
||||
pub mod i18n;
|
||||
pub mod manager;
|
||||
pub mod oakui;
|
||||
pub mod panels;
|
||||
|
||||
|
||||
+538
@@ -0,0 +1,538 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! The project manager (M13 D4): the DaVinci-style library browser the app
|
||||
//! shows at startup (no `--project` argument) and from 文件 → 项目管理器.
|
||||
//!
|
||||
//! The view is a modal content view ([`ProjectManager`]) hosted by
|
||||
//! `crate::app::OakApp` inside the standard `Modal` card: a toolbar
|
||||
//! (新建 / 导入), a column list of the library rows (name, modified time,
|
||||
//! duration, tracks, clips, footage — the backend-derived stats), and an
|
||||
//! action row (打开 / 重命名 / 复制 / 删除 / 导出). The view emits
|
||||
//! [`ManagerEvent`] requests; the app routes them through the engine
|
||||
//! ([`AppEngine`]'s library surface) and swaps in the rename / delete
|
||||
//! confirmation modals.
|
||||
//!
|
||||
//! Pure formatting helpers ([`format_modified`], [`format_duration_ms`])
|
||||
//! are unit tested here; the app-level flows are covered by the
|
||||
//! `OakApp<MockEngine>` tests in `crate::app`.
|
||||
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
div, App, ClickEvent, Context, ElementId, Entity, EventEmitter, Hsla, Render, SharedString,
|
||||
Window,
|
||||
};
|
||||
use gpui_elements::editable_text::{text_input, EditableTextState, StringStorage};
|
||||
|
||||
use crate::i18n;
|
||||
use crate::oakui::{AppEngine, LibraryProject};
|
||||
|
||||
/// A request the manager view emits for the host (the app shell) to route
|
||||
/// through the engine.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ManagerEvent {
|
||||
/// Open the project (double-click / the 打开 button).
|
||||
Open(String),
|
||||
/// Create a new blank project and open it.
|
||||
Create,
|
||||
/// Rename the project (the host prompts for the new name).
|
||||
Rename(String),
|
||||
/// Duplicate the project (history included).
|
||||
Duplicate(String),
|
||||
/// Delete the project (the host confirms first).
|
||||
Delete(String),
|
||||
/// Import a `.ove` / `.otio` / `.fcpxml` file as a new library row.
|
||||
Import,
|
||||
/// Export the project to a file.
|
||||
Export(String),
|
||||
}
|
||||
|
||||
/// The project manager content view: the library list plus its toolbars.
|
||||
pub struct ProjectManager<E: AppEngine> {
|
||||
engine: Entity<E>,
|
||||
/// The listed rows (most recently modified first, as the engine
|
||||
/// reports them).
|
||||
rows: Vec<LibraryProject>,
|
||||
/// The selected row index.
|
||||
selected: Option<usize>,
|
||||
/// The last operation error (shown under the list).
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
impl<E: AppEngine> ProjectManager<E> {
|
||||
/// Builds the view and loads the library.
|
||||
pub fn new(engine: Entity<E>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let mut this = Self {
|
||||
engine,
|
||||
rows: Vec::new(),
|
||||
selected: None,
|
||||
status: None,
|
||||
};
|
||||
this.reload(cx);
|
||||
this
|
||||
}
|
||||
|
||||
/// Reloads the library from the engine, keeping the selection on the
|
||||
/// same row (by uuid) when it still exists.
|
||||
pub fn reload(&mut self, cx: &mut Context<Self>) {
|
||||
let selected_uuid = self.selected_uuid();
|
||||
match self.engine.read(cx).library_projects() {
|
||||
Ok(rows) => {
|
||||
self.rows = rows;
|
||||
self.selected = selected_uuid
|
||||
.and_then(|uuid| self.rows.iter().position(|row| row.uuid == uuid));
|
||||
self.status = None;
|
||||
}
|
||||
Err(err) => {
|
||||
self.rows = Vec::new();
|
||||
self.selected = None;
|
||||
self.status = Some(err);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The selected row's uuid, if any.
|
||||
pub fn selected_uuid(&self) -> Option<String> {
|
||||
self.selected
|
||||
.and_then(|index| self.rows.get(index))
|
||||
.map(|row| row.uuid.clone())
|
||||
}
|
||||
|
||||
/// The selected row's display name, if any.
|
||||
pub fn selected_name(&self) -> Option<String> {
|
||||
self.selected
|
||||
.and_then(|index| self.rows.get(index))
|
||||
.map(|row| row.name.clone())
|
||||
}
|
||||
|
||||
/// Shows an operation error under the list (the host reports engine
|
||||
/// failures here so a failed action is visible in the dialog).
|
||||
pub fn set_status(&mut self, status: Option<String>, cx: &mut Context<Self>) {
|
||||
self.status = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The listed rows (tests).
|
||||
#[cfg(test)]
|
||||
pub fn rows(&self) -> &[LibraryProject] {
|
||||
&self.rows
|
||||
}
|
||||
|
||||
/// Selects the row with `uuid` (tests and the host's post-action
|
||||
/// reselection).
|
||||
pub fn select(&mut self, uuid: &str, cx: &mut Context<Self>) {
|
||||
self.selected = self.rows.iter().position(|row| row.uuid == uuid);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Emits the event through the view's subscribers.
|
||||
fn emit(&mut self, event: ManagerEvent, cx: &mut Context<Self>) {
|
||||
cx.emit(event);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// A click on row `index`: select, or open on a double click.
|
||||
fn row_clicked(&mut self, index: usize, clicks: usize, cx: &mut Context<Self>) {
|
||||
if index >= self.rows.len() {
|
||||
return;
|
||||
}
|
||||
self.selected = Some(index);
|
||||
if clicks >= 2 {
|
||||
let uuid = self.rows[index].uuid.clone();
|
||||
self.emit(ManagerEvent::Open(uuid), cx);
|
||||
} else {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// An action-row button targeting the selection.
|
||||
fn selected_action(&mut self, action: impl FnOnce(String) -> ManagerEvent, cx: &mut Context<Self>) {
|
||||
if let Some(uuid) = self.selected_uuid() {
|
||||
self.emit(action(uuid), cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: AppEngine> EventEmitter<ManagerEvent> for ProjectManager<E> {}
|
||||
|
||||
impl<E: AppEngine> Render for ProjectManager<E> {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
|
||||
// Toolbar buttons (新建 / 导入) and the selection-targeting action
|
||||
// row (打开 / 重命名 / 复制 / 删除 / 导出).
|
||||
let tool = |id: &'static str, label: &'static str| -> gpui::Stateful<gpui::Div> {
|
||||
div()
|
||||
.id(id)
|
||||
.px_3()
|
||||
.py_1()
|
||||
.rounded_md()
|
||||
.bg(colors.background)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.text_color(colors.text)
|
||||
.cursor_pointer()
|
||||
.child(label)
|
||||
};
|
||||
let has_selection = self.selected.is_some();
|
||||
let action = |id: &'static str, label: &'static str| -> gpui::Stateful<gpui::Div> {
|
||||
let mut b = div().id(id).px_3().py_1().rounded_md();
|
||||
if has_selection {
|
||||
b = b
|
||||
.bg(colors.background)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.text_color(colors.text)
|
||||
.cursor_pointer();
|
||||
} else {
|
||||
b = b.text_color(colors.disabled);
|
||||
}
|
||||
b.child(label)
|
||||
};
|
||||
|
||||
// The column header.
|
||||
let header_cell = |label: &'static str, width: f32| -> gpui::Div {
|
||||
let mut cell = div()
|
||||
.px_2()
|
||||
.text_xs()
|
||||
.text_color(colors.disabled)
|
||||
.whitespace_nowrap()
|
||||
.child(label);
|
||||
if width <= 0.0 {
|
||||
cell = cell.flex_1();
|
||||
} else {
|
||||
cell = cell.w(gpui::px(width)).text_right();
|
||||
}
|
||||
cell
|
||||
};
|
||||
|
||||
let list = div()
|
||||
.h(gpui::px(340.0))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.rounded_md()
|
||||
.bg(colors.background)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.py_1()
|
||||
.border_b_1()
|
||||
.border_color(colors.border)
|
||||
.child(header_cell(i18n::tr("manager.col.name"), 0.0))
|
||||
.child(header_cell(i18n::tr("manager.col.modified"), 128.0))
|
||||
.child(header_cell(i18n::tr("manager.col.duration"), 72.0))
|
||||
.child(header_cell(i18n::tr("manager.col.tracks"), 56.0))
|
||||
.child(header_cell(i18n::tr("manager.col.clips"), 56.0))
|
||||
.child(header_cell(i18n::tr("manager.col.footage"), 56.0)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("manager-list")
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.overflow_y_scroll()
|
||||
.children(if self.rows.is_empty() {
|
||||
vec![
|
||||
div()
|
||||
.p_4()
|
||||
.text_color(colors.disabled)
|
||||
.child(i18n::tr("manager.empty"))
|
||||
.into_any_element(),
|
||||
]
|
||||
} else {
|
||||
self.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, row)| {
|
||||
let selected = self.selected == Some(index);
|
||||
let mut line = div()
|
||||
.id(ElementId::Name(format!("manager-row-{index}").into()))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.py_1()
|
||||
.cursor_pointer()
|
||||
.on_click(cx.listener(move |this, event: &ClickEvent, _w, cx| {
|
||||
this.row_clicked(index, event.click_count(), cx);
|
||||
}));
|
||||
if selected {
|
||||
line = line.bg(colors.selected).text_color(colors.selected_text);
|
||||
} else {
|
||||
line = line.text_color(colors.text);
|
||||
}
|
||||
let cell = |text: String, width: f32| -> gpui::Div {
|
||||
let mut c = div().px_2().whitespace_nowrap().child(text);
|
||||
if width <= 0.0 {
|
||||
c = c.flex_1();
|
||||
} else {
|
||||
c = c.w(gpui::px(width)).text_right();
|
||||
}
|
||||
c
|
||||
};
|
||||
line.child(cell(row.name.clone(), 0.0))
|
||||
.child(cell(format_modified(row.modified_at), 128.0))
|
||||
.child(cell(format_duration_ms(row.duration_ms), 72.0))
|
||||
.child(cell(row.track_count.to_string(), 56.0))
|
||||
.child(cell(row.clip_count.to_string(), 56.0))
|
||||
.child(cell(row.footage_count.to_string(), 56.0))
|
||||
.into_any_element()
|
||||
})
|
||||
.collect()
|
||||
}),
|
||||
);
|
||||
|
||||
let mut root = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.w_full()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
tool("manager-new", i18n::tr("manager.new")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.emit(ManagerEvent::Create, cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
tool("manager-import", i18n::tr("manager.import")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.emit(ManagerEvent::Import, cx);
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(list)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_end()
|
||||
.gap_2()
|
||||
.child(
|
||||
action("manager-open", i18n::tr("manager.open")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.selected_action(ManagerEvent::Open, cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
action("manager-rename", i18n::tr("manager.rename")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.selected_action(ManagerEvent::Rename, cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
action("manager-duplicate", i18n::tr("manager.duplicate")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.selected_action(ManagerEvent::Duplicate, cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
action("manager-delete", i18n::tr("manager.delete")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.selected_action(ManagerEvent::Delete, cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
action("manager-export", i18n::tr("manager.export")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.selected_action(ManagerEvent::Export, cx);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
if let Some(status) = &self.status {
|
||||
root = root.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(Hsla {
|
||||
h: 0.0,
|
||||
s: 0.6,
|
||||
l: 0.55,
|
||||
a: 1.0,
|
||||
})
|
||||
.child(status.clone()),
|
||||
);
|
||||
}
|
||||
root
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rename prompt / delete confirmation contents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The rename prompt's content: one text field with the new name.
|
||||
pub struct NamePrompt {
|
||||
editor: Entity<EditableTextState>,
|
||||
}
|
||||
|
||||
impl NamePrompt {
|
||||
/// Builds the prompt seeded with `initial`.
|
||||
pub fn new(initial: &str, cx: &mut Context<Self>) -> Self {
|
||||
let editor = cx.new(|cx| {
|
||||
let editor = EditableTextState::new(StringStorage::default(), cx);
|
||||
editor
|
||||
});
|
||||
editor.update(cx, |editor, cx| editor.emplace(initial, cx));
|
||||
Self { editor }
|
||||
}
|
||||
|
||||
/// The name currently entered (trimmed).
|
||||
pub fn value(&self, app: &App) -> String {
|
||||
self.editor.read(app).as_str().trim().to_string()
|
||||
}
|
||||
|
||||
/// Replaces the entered name (tests / prefill).
|
||||
pub fn set_value(&mut self, value: &str, cx: &mut Context<Self>) {
|
||||
self.editor.update(cx, |editor, cx| editor.emplace(value, cx));
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for NamePrompt {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
let weak = self.editor.downgrade();
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.child(
|
||||
div()
|
||||
.text_color(colors.text)
|
||||
.child(i18n::tr("manager.rename.label")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.background)
|
||||
.px_2()
|
||||
.py_1()
|
||||
.child(
|
||||
text_input("gpui-widgets-rename-field")
|
||||
.state(weak)
|
||||
.accepts_input(true),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The delete confirmation's content: the warning text.
|
||||
pub struct ConfirmContent {
|
||||
text: SharedString,
|
||||
}
|
||||
|
||||
impl ConfirmContent {
|
||||
/// Builds the confirmation with `text`.
|
||||
pub fn new(text: impl Into<SharedString>) -> Self {
|
||||
Self { text: text.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ConfirmContent {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
div().text_color(colors.text).child(self.text.clone())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Formats a unix timestamp (UTC) as `YYYY-MM-DD HH:MM` for the modified
|
||||
/// column.
|
||||
pub fn format_modified(unix_secs: i64) -> String {
|
||||
if unix_secs <= 0 {
|
||||
return "—".to_string();
|
||||
}
|
||||
let days = unix_secs.div_euclid(86_400);
|
||||
let secs = unix_secs.rem_euclid(86_400);
|
||||
let (year, month, day) = civil_from_days(days);
|
||||
format!(
|
||||
"{year:04}-{month:02}-{day:02} {:02}:{:02}",
|
||||
secs / 3600,
|
||||
(secs % 3600) / 60
|
||||
)
|
||||
}
|
||||
|
||||
/// Days since the unix epoch → (year, month, day), UTC (Howard Hinnant's
|
||||
/// civil-from-days algorithm).
|
||||
fn civil_from_days(days: i64) -> (i64, u32, u32) {
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
|
||||
let year = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
|
||||
let year = if month <= 2 { year + 1 } else { year };
|
||||
(year, month, day)
|
||||
}
|
||||
|
||||
/// Formats a duration in milliseconds as `H:MM:SS` for the duration
|
||||
/// column.
|
||||
pub fn format_duration_ms(ms: i64) -> String {
|
||||
if ms <= 0 {
|
||||
return "—".to_string();
|
||||
}
|
||||
let secs = ms / 1000;
|
||||
format!("{}:{:02}:{:02}", secs / 3600, (secs % 3600) / 60, secs % 60)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The civil-date conversion matches known dates.
|
||||
#[test]
|
||||
fn format_modified_known_dates() {
|
||||
assert_eq!(format_modified(0), "—");
|
||||
assert_eq!(format_modified(-5), "—");
|
||||
// 2026-08-16 00:54:01 UTC.
|
||||
assert_eq!(format_modified(1_786_841_641), "2026-08-16 00:54");
|
||||
// 1970-01-01 00:00 UTC.
|
||||
assert_eq!(format_modified(1), "1970-01-01 00:00");
|
||||
// 2000-02-29 (a leap day) 12:34 UTC.
|
||||
assert_eq!(format_modified(951_827_640), "2000-02-29 12:34");
|
||||
}
|
||||
|
||||
/// Durations format as H:MM:SS with a dash for empty projects.
|
||||
#[test]
|
||||
fn format_duration_ms_shapes() {
|
||||
assert_eq!(format_duration_ms(0), "—");
|
||||
assert_eq!(format_duration_ms(61_500), "0:01:01");
|
||||
assert_eq!(format_duration_ms(3_725_000), "1:02:05");
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,30 @@ pub struct Project {
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// A project-library row, as the project manager lists it (M13 D4). The
|
||||
/// stats are derived from the row's head state by the backend (they are
|
||||
/// never stored in the library).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LibraryProject {
|
||||
/// The library row uuid (the open / rename / duplicate / delete /
|
||||
/// export selector).
|
||||
pub uuid: String,
|
||||
/// The row's display name.
|
||||
pub name: String,
|
||||
/// Row creation time (unix seconds, UTC).
|
||||
pub created_at: i64,
|
||||
/// Last-write time (unix seconds, UTC; the manager sort key).
|
||||
pub modified_at: i64,
|
||||
/// Longest sequence duration in milliseconds.
|
||||
pub duration_ms: i64,
|
||||
/// Total tracks across all sequences.
|
||||
pub track_count: i32,
|
||||
/// Total clip blocks.
|
||||
pub clip_count: i32,
|
||||
/// Total footage nodes.
|
||||
pub footage_count: i32,
|
||||
}
|
||||
|
||||
/// The sequence currently open in the project.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Sequence {
|
||||
@@ -293,6 +317,73 @@ pub trait AppEngine:
|
||||
|
||||
/// Closes the current project, leaving the app with no sequence.
|
||||
fn close_project(&mut self, cx: &mut Context<Self>);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Project library (M13 D4: the write-through database the manager
|
||||
// window browses). Default: unsupported (empty list / error strings).
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// Whether the open project is bound to the library write-through
|
||||
/// session (the status bar's write state).
|
||||
fn storage_bound(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The last write-through / snapshot error of the open project, if any.
|
||||
fn storage_last_error(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Lists the project library, most recently modified first (the
|
||||
/// project manager's data source).
|
||||
fn library_projects(&self) -> Result<Vec<LibraryProject>, String> {
|
||||
Err("project library not supported".into())
|
||||
}
|
||||
|
||||
/// Creates a blank project named `name` in the library and opens it.
|
||||
fn library_create_project(&mut self, name: &str, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
let _ = (name, cx);
|
||||
Err("project library not supported".into())
|
||||
}
|
||||
|
||||
/// Opens the library project `uuid` (closing the current project).
|
||||
fn library_open_project(&mut self, uuid: &str, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
let _ = (uuid, cx);
|
||||
Err("project library not supported".into())
|
||||
}
|
||||
|
||||
/// Deletes the library project `uuid` (the manager confirms first).
|
||||
fn library_delete_project(&mut self, uuid: &str) -> Result<(), String> {
|
||||
let _ = uuid;
|
||||
Err("project library not supported".into())
|
||||
}
|
||||
|
||||
/// Renames the library project `uuid` (the manager's list name).
|
||||
fn library_rename_project(&mut self, uuid: &str, name: &str) -> Result<(), String> {
|
||||
let _ = (uuid, name);
|
||||
Err("project library not supported".into())
|
||||
}
|
||||
|
||||
/// Duplicates the library project `uuid` (history included) under a
|
||||
/// fresh uuid.
|
||||
fn library_duplicate_project(&mut self, uuid: &str) -> Result<(), String> {
|
||||
let _ = uuid;
|
||||
Err("project library not supported".into())
|
||||
}
|
||||
|
||||
/// Imports a `.ove` / `.otio` / `.fcpxml` project file into the library
|
||||
/// as a new row; returns the new row's uuid.
|
||||
fn library_import_project(&mut self, path: PathBuf) -> Result<String, String> {
|
||||
let _ = path;
|
||||
Err("project library not supported".into())
|
||||
}
|
||||
|
||||
/// Exports the library project `uuid` to `path`; the format is
|
||||
/// dispatched by extension (`.ove` / `.otio` / `.fcpxml`).
|
||||
fn library_export_project(&mut self, uuid: &str, path: PathBuf) -> Result<(), String> {
|
||||
let _ = (uuid, path);
|
||||
Err("project library not supported".into())
|
||||
}
|
||||
/// The timeline waveform cache (M12 P4); `None` when the backend
|
||||
/// does not provide waveforms.
|
||||
fn waveform_cache(&self) -> Option<std::sync::Arc<crate::oakui::waveform::WaveformCache>> {
|
||||
|
||||
@@ -281,6 +281,67 @@ unsafe extern "C" {
|
||||
/// `oakengine_config_set_string` — write a string value.
|
||||
pub fn oakengine_config_set_string(key: *const c_char, value: *const c_char) -> c_int;
|
||||
|
||||
// -- oakengine::storage (write-through session state) --
|
||||
|
||||
/// `oakengine_storage_flush` — flush every bound project and stop the
|
||||
/// snapshot thread (the app calls it on exit).
|
||||
pub fn oakengine_storage_flush() -> c_int;
|
||||
/// `oakengine_storage_is_bound` — 1 when the project is bound to a
|
||||
/// library session.
|
||||
pub fn oakengine_storage_is_bound(project: *mut OakEngineProject) -> c_int;
|
||||
/// `oakengine_storage_last_error` — the last write-through / snapshot
|
||||
/// error (buf/size; empty when none or not bound).
|
||||
pub fn oakengine_storage_last_error(
|
||||
project: *mut OakEngineProject,
|
||||
buf: *mut c_char,
|
||||
buf_size: c_int,
|
||||
) -> c_int;
|
||||
|
||||
// -- oakengine::library (project manager, M13 D4) --
|
||||
|
||||
/// `oakengine_library_list` — the library rows as a JSON array
|
||||
/// (buf/size), most recently modified first; `"[]"` with storage off.
|
||||
pub fn oakengine_library_list(buf: *mut c_char, buf_size: c_int) -> c_int;
|
||||
/// `oakengine_library_create` — create a blank project row; reports its
|
||||
/// uuid (buf/size on `out_uuid`; the return value is the uuid length,
|
||||
/// negative on error).
|
||||
pub fn oakengine_library_create(
|
||||
name: *const c_char,
|
||||
out_uuid: *mut c_char,
|
||||
out_size: c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_library_delete` — delete a row by uuid.
|
||||
pub fn oakengine_library_delete(uuid: *const c_char) -> c_int;
|
||||
/// `oakengine_library_rename` — rename a row.
|
||||
pub fn oakengine_library_rename(uuid: *const c_char, name: *const c_char) -> c_int;
|
||||
/// `oakengine_library_duplicate` — copy a row (history included);
|
||||
/// reports the new uuid like `oakengine_library_create`.
|
||||
pub fn oakengine_library_duplicate(
|
||||
uuid: *const c_char,
|
||||
name: *const c_char,
|
||||
out_uuid: *mut c_char,
|
||||
out_size: c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_library_import` — import a `.ove`/`.otio`/`.fcpxml` file
|
||||
/// as a new row; reports the new uuid like `oakengine_library_create`.
|
||||
pub fn oakengine_library_import(
|
||||
path: *const c_char,
|
||||
out_uuid: *mut c_char,
|
||||
out_size: c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_library_export` — export a row to `path` (format by
|
||||
/// extension).
|
||||
pub fn oakengine_library_export(uuid: *const c_char, path: *const c_char) -> c_int;
|
||||
/// `oakengine_project_load_library` — load a library row into a fresh
|
||||
/// project shell (same contract as `oakengine_project_load`); binds the
|
||||
/// project to the library session.
|
||||
pub fn oakengine_project_load_library(
|
||||
self_: *mut OakEngineProject,
|
||||
uuid: *const c_char,
|
||||
err: *mut c_char,
|
||||
err_size: c_int,
|
||||
) -> c_int;
|
||||
|
||||
// -- oakengine::node (project) --
|
||||
|
||||
/// `oakengine_project_create` — owned project box (no content yet).
|
||||
|
||||
+185
-2
@@ -62,14 +62,60 @@ use gpui_widgets::project_explorer::{ProjectDataSource, ProjectEntry};
|
||||
use gpui_widgets::viewer::PlaybackClock;
|
||||
|
||||
use super::engine::{
|
||||
AppEngine, EngineGateway, ExportEvent, ExportSession, Monitor, Project, ScopeData, Sequence,
|
||||
VideoFormat,
|
||||
AppEngine, EngineGateway, ExportEvent, ExportSession, LibraryProject, Monitor, Project,
|
||||
ScopeData, Sequence, VideoFormat,
|
||||
};
|
||||
use super::transport::TransportState;
|
||||
|
||||
/// The demo sequence length: 00:04:18:18 at 25 fps.
|
||||
const SEQUENCE_LENGTH: i64 = 6468;
|
||||
|
||||
/// The current unix time in seconds (0 on clock failure).
|
||||
fn now_unix() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The demo library rows the project manager opens with (M13 D4): three
|
||||
/// projects with plausible stats, most recently modified first.
|
||||
fn demo_library() -> Vec<LibraryProject> {
|
||||
let now = now_unix();
|
||||
vec![
|
||||
LibraryProject {
|
||||
uuid: "mock-1".into(),
|
||||
name: "第一稿".into(),
|
||||
created_at: now - 86400,
|
||||
modified_at: now - 300,
|
||||
duration_ms: SEQUENCE_LENGTH * 1000 / 25,
|
||||
track_count: 4,
|
||||
clip_count: 5,
|
||||
footage_count: 3,
|
||||
},
|
||||
LibraryProject {
|
||||
uuid: "mock-2".into(),
|
||||
name: "宣传片 v3".into(),
|
||||
created_at: now - 3 * 86400,
|
||||
modified_at: now - 86400,
|
||||
duration_ms: 95_000,
|
||||
track_count: 6,
|
||||
clip_count: 14,
|
||||
footage_count: 8,
|
||||
},
|
||||
LibraryProject {
|
||||
uuid: "mock-3".into(),
|
||||
name: "采访粗剪".into(),
|
||||
created_at: now - 9 * 86400,
|
||||
modified_at: now - 7 * 86400,
|
||||
duration_ms: 612_000,
|
||||
track_count: 3,
|
||||
clip_count: 22,
|
||||
footage_count: 5,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clocks
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -431,6 +477,19 @@ pub struct MockEngine {
|
||||
/// has no media pipeline, so it just records them (drives app-level tests
|
||||
/// of the import flow).
|
||||
imported_footage: Vec<PathBuf>,
|
||||
/// The fake project library the project manager browses (M13 D4): an
|
||||
/// in-memory row set the library trait methods operate on, so the app
|
||||
/// flow (list / open / create / rename / duplicate / delete / import /
|
||||
/// export) is testable without a database.
|
||||
library: Vec<LibraryProject>,
|
||||
/// Id allocator for library rows created at runtime.
|
||||
next_library_id: u64,
|
||||
/// The uuids handed to [`AppEngine::library_open_project`] (test
|
||||
/// observability).
|
||||
library_opened: Vec<String>,
|
||||
/// (uuid, path) pairs handed to [`AppEngine::library_export_project`]
|
||||
/// (test observability).
|
||||
library_exported: Vec<(String, PathBuf)>,
|
||||
}
|
||||
|
||||
impl MockEngine {
|
||||
@@ -674,6 +733,10 @@ impl MockEngine {
|
||||
node_selection: BTreeSet::new(),
|
||||
cpu_frame_cache: Mutex::new(HashMap::new()),
|
||||
imported_footage: Vec::new(),
|
||||
library: demo_library(),
|
||||
next_library_id: 100,
|
||||
library_opened: Vec::new(),
|
||||
library_exported: Vec::new(),
|
||||
};
|
||||
// The demo graph is born connected: derive every port's `connected`
|
||||
// flag from the edge list.
|
||||
@@ -1274,6 +1337,114 @@ impl AppEngine for MockEngine {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
// --- project library (M13 D4, in-memory fake) -------------------------
|
||||
|
||||
fn storage_bound(&self) -> bool {
|
||||
// The mock pretends the demo project lives in the library.
|
||||
true
|
||||
}
|
||||
|
||||
fn library_projects(&self) -> Result<Vec<LibraryProject>, String> {
|
||||
let mut rows = self.library.clone();
|
||||
rows.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn library_create_project(&mut self, name: &str, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
let uuid = format!("mock-{}", self.next_library_id);
|
||||
self.next_library_id += 1;
|
||||
let now = now_unix();
|
||||
self.library.push(LibraryProject {
|
||||
uuid,
|
||||
name: name.to_string(),
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
duration_ms: 0,
|
||||
track_count: 0,
|
||||
clip_count: 0,
|
||||
footage_count: 0,
|
||||
});
|
||||
self.project.name = name.to_string();
|
||||
self.project.path = PathBuf::new();
|
||||
cx.notify();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn library_open_project(&mut self, uuid: &str, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
let Some(row) = self.library.iter().find(|row| row.uuid == uuid) else {
|
||||
return Err(format!("library project {uuid} not found"));
|
||||
};
|
||||
self.project.name = row.name.clone();
|
||||
self.project.path = PathBuf::new();
|
||||
self.library_opened.push(uuid.to_string());
|
||||
cx.notify();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn library_delete_project(&mut self, uuid: &str) -> Result<(), String> {
|
||||
let before = self.library.len();
|
||||
self.library.retain(|row| row.uuid != uuid);
|
||||
if self.library.len() == before {
|
||||
return Err(format!("library project {uuid} not found"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn library_rename_project(&mut self, uuid: &str, name: &str) -> Result<(), String> {
|
||||
let Some(row) = self.library.iter_mut().find(|row| row.uuid == uuid) else {
|
||||
return Err(format!("library project {uuid} not found"));
|
||||
};
|
||||
row.name = name.to_string();
|
||||
row.modified_at = now_unix();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn library_duplicate_project(&mut self, uuid: &str) -> Result<(), String> {
|
||||
let Some(row) = self.library.iter().find(|row| row.uuid == uuid).cloned() else {
|
||||
return Err(format!("library project {uuid} not found"));
|
||||
};
|
||||
let now = now_unix();
|
||||
self.library.push(LibraryProject {
|
||||
uuid: format!("mock-{}", self.next_library_id),
|
||||
name: format!("{} (copy)", row.name),
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
..row
|
||||
});
|
||||
self.next_library_id += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn library_import_project(&mut self, path: PathBuf) -> Result<String, String> {
|
||||
let name = path
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| format!("invalid project file \"{}\"", path.display()))?;
|
||||
let uuid = format!("mock-{}", self.next_library_id);
|
||||
self.next_library_id += 1;
|
||||
let now = now_unix();
|
||||
self.library.push(LibraryProject {
|
||||
uuid: uuid.clone(),
|
||||
name,
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
duration_ms: 0,
|
||||
track_count: 0,
|
||||
clip_count: 0,
|
||||
footage_count: 0,
|
||||
});
|
||||
Ok(uuid)
|
||||
}
|
||||
|
||||
fn library_export_project(&mut self, uuid: &str, path: PathBuf) -> Result<(), String> {
|
||||
if !self.library.iter().any(|row| row.uuid == uuid) {
|
||||
return Err(format!("library project {uuid} not found"));
|
||||
}
|
||||
self.library_exported.push((uuid.to_string(), path));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_export(&mut self, _format: i32, _path: PathBuf) -> Result<ExportSession, String> {
|
||||
// Mock export: fake progress on a background thread, no file.
|
||||
let (tx, rx) = mpsc::channel::<ExportEvent>();
|
||||
@@ -1421,6 +1592,18 @@ impl MockEngine {
|
||||
&self.imported_footage
|
||||
}
|
||||
|
||||
/// The uuids opened via [`AppEngine::library_open_project`] so far
|
||||
/// (mock state; drives app-level tests of the manager's open flow).
|
||||
pub fn library_opened(&self) -> &[String] {
|
||||
&self.library_opened
|
||||
}
|
||||
|
||||
/// The (uuid, path) pairs exported via
|
||||
/// [`AppEngine::library_export_project`] so far (mock state).
|
||||
pub fn library_exported(&self) -> &[(String, PathBuf)] {
|
||||
&self.library_exported
|
||||
}
|
||||
|
||||
/// The selected material-bin entry id (demo state).
|
||||
pub fn selected_item(&self) -> Option<u64> {
|
||||
self.selected_item
|
||||
|
||||
+2
-2
@@ -53,8 +53,8 @@ pub mod transport;
|
||||
pub mod waveform;
|
||||
|
||||
pub use engine::{
|
||||
AppEngine, EngineClock, EngineGateway, ExportEvent, ExportSession, Monitor, Project, ScopeData,
|
||||
Sequence, VideoFormat,
|
||||
AppEngine, EngineClock, EngineGateway, ExportEvent, ExportSession, LibraryProject, Monitor,
|
||||
Project, ScopeData, Sequence, VideoFormat,
|
||||
};
|
||||
pub use mock::{MockClock, MockEngine};
|
||||
pub use real::{RealClock, RealEngine};
|
||||
|
||||
+223
-2
@@ -101,8 +101,8 @@ use gpui_widgets::project_explorer::{ProjectDataSource, ProjectEntry};
|
||||
use gpui_widgets::viewer::PlaybackClock;
|
||||
|
||||
use super::engine::{
|
||||
AppEngine, EngineGateway, ExportEvent, ExportSession, Monitor, Project, ScopeData, Sequence,
|
||||
VideoFormat,
|
||||
AppEngine, EngineGateway, ExportEvent, ExportSession, LibraryProject, Monitor, Project,
|
||||
ScopeData, Sequence, VideoFormat,
|
||||
};
|
||||
use super::ffi::*;
|
||||
use super::frames::{f32_rgba_to_bgra_image, synthetic_frame_samples};
|
||||
@@ -2230,6 +2230,112 @@ impl AppEngine for RealEngine {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
// --- project library (M13 D4) --------------------------------------
|
||||
|
||||
fn storage_bound(&self) -> bool {
|
||||
self.project_ptr()
|
||||
.map(|p| unsafe { oakengine_storage_is_bound(p) } != 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn storage_last_error(&self) -> Option<String> {
|
||||
let project = self.project_ptr()?;
|
||||
let message = read_string(|buf, size| unsafe {
|
||||
oakengine_storage_last_error(project, buf, size)
|
||||
});
|
||||
if message.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(message)
|
||||
}
|
||||
}
|
||||
|
||||
fn library_projects(&self) -> Result<Vec<LibraryProject>, String> {
|
||||
library_list()
|
||||
}
|
||||
|
||||
fn library_create_project(&mut self, name: &str, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
let uuid = library_create(name)?;
|
||||
self.open_library_project(&uuid, cx)
|
||||
}
|
||||
|
||||
fn library_open_project(&mut self, uuid: &str, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
self.open_library_project(uuid, cx)
|
||||
}
|
||||
|
||||
fn library_delete_project(&mut self, uuid: &str) -> Result<(), String> {
|
||||
let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?;
|
||||
let rc = unsafe { oakengine_library_delete(uuid_c.as_ptr()) };
|
||||
if rc != 0 {
|
||||
return Err(format!("failed to delete the project (error {rc})"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn library_rename_project(&mut self, uuid: &str, name: &str) -> Result<(), String> {
|
||||
let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?;
|
||||
let name_c = CString::new(name).map_err(|_| "invalid name".to_string())?;
|
||||
let rc = unsafe { oakengine_library_rename(uuid_c.as_ptr(), name_c.as_ptr()) };
|
||||
if rc != 0 {
|
||||
return Err(format!("failed to rename the project (error {rc})"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn library_duplicate_project(&mut self, uuid: &str) -> Result<(), String> {
|
||||
let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?;
|
||||
let mut buf = [0 as c_char; 256];
|
||||
// Single call with a stack buffer: the duplicate has a side effect,
|
||||
// so the two-stage (measure-then-read) pattern must not be used.
|
||||
let rc = unsafe {
|
||||
oakengine_library_duplicate(
|
||||
uuid_c.as_ptr(),
|
||||
std::ptr::null(),
|
||||
buf.as_mut_ptr(),
|
||||
buf.len() as c_int,
|
||||
)
|
||||
};
|
||||
if rc < 0 {
|
||||
return Err(format!("failed to duplicate the project (error {rc})"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn library_import_project(&mut self, path: PathBuf) -> Result<String, String> {
|
||||
let path_c = cstr_path(&path).ok_or("invalid import path")?;
|
||||
let mut buf = [0 as c_char; 256];
|
||||
// Single call with a stack buffer (side effect; see duplicate).
|
||||
let rc = unsafe {
|
||||
oakengine_library_import(path_c.as_ptr(), buf.as_mut_ptr(), buf.len() as c_int)
|
||||
};
|
||||
if rc < 0 {
|
||||
return Err(format!(
|
||||
"failed to import \"{}\" (error {rc})",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
||||
Ok(
|
||||
String::from_utf8_lossy(unsafe {
|
||||
std::slice::from_raw_parts(buf.as_ptr() as *const u8, len)
|
||||
})
|
||||
.into_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
fn library_export_project(&mut self, uuid: &str, path: PathBuf) -> Result<(), String> {
|
||||
let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?;
|
||||
let path_c = cstr_path(&path).ok_or("invalid export path")?;
|
||||
let rc = unsafe { oakengine_library_export(uuid_c.as_ptr(), path_c.as_ptr()) };
|
||||
if rc != 0 {
|
||||
return Err(format!(
|
||||
"failed to export the project to \"{}\" (error {rc})",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_export(&mut self, format: i32, path: PathBuf) -> Result<ExportSession, String> {
|
||||
let Some(seq) = self.seq_ptr() else {
|
||||
return Err("no sequence open".into());
|
||||
@@ -2589,6 +2695,121 @@ pub fn renderer_backends() -> Vec<&'static str> {
|
||||
vec!["opengl", "metal", "vulkan", "none"]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project library (M13 D4: the write-through database the manager browses)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The config key selecting the storage backend (see
|
||||
/// `crates/oakengine/src/storage.rs`).
|
||||
pub const CONFIG_KEY_STORAGE_BACKEND: &str = "Storage/Backend";
|
||||
|
||||
/// Enables the SQLite write-through library unless the user configured the
|
||||
/// backend explicitly (any existing value — including "off" — wins over the
|
||||
/// app's default). The library path defaults facade-side to
|
||||
/// `<system data directory>/library.db`.
|
||||
pub fn configure_storage() {
|
||||
if config_get_string(CONFIG_KEY_STORAGE_BACKEND).is_empty() {
|
||||
config_set_string(CONFIG_KEY_STORAGE_BACKEND, "sqlite");
|
||||
}
|
||||
}
|
||||
|
||||
/// Flushes every bound project (write-through + snapshot) and stops the
|
||||
/// facade's snapshot thread. The app calls this on exit.
|
||||
pub fn storage_flush() {
|
||||
unsafe {
|
||||
oakengine_storage_flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// The library rows, most recently modified first (the project manager's
|
||||
/// data source; JSON over the facade's `oakengine_library_list`).
|
||||
pub fn library_list() -> Result<Vec<LibraryProject>, String> {
|
||||
let needed = unsafe { oakengine_library_list(std::ptr::null_mut(), 0) };
|
||||
if needed < 0 {
|
||||
return Err(format!("failed to list the library (error {needed})"));
|
||||
}
|
||||
let mut buf = vec![0 as c_char; needed as usize + 1];
|
||||
unsafe { oakengine_library_list(buf.as_mut_ptr(), needed + 1) };
|
||||
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
||||
let json =
|
||||
String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) })
|
||||
.into_owned();
|
||||
let rows: serde_json::Value =
|
||||
serde_json::from_str(&json).map_err(|e| format!("malformed library list: {e}"))?;
|
||||
let Some(rows) = rows.as_array() else {
|
||||
return Err("malformed library list (not an array)".into());
|
||||
};
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let s = |key: &str| row.get(key).and_then(|v| v.as_str()).unwrap_or_default().to_string();
|
||||
let n = |key: &str| row.get(key).and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
LibraryProject {
|
||||
uuid: s("uuid"),
|
||||
name: s("name"),
|
||||
created_at: n("created_at"),
|
||||
modified_at: n("modified_at"),
|
||||
duration_ms: n("duration_ms"),
|
||||
track_count: n("track_count") as i32,
|
||||
clip_count: n("clip_count") as i32,
|
||||
footage_count: n("footage_count") as i32,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Creates a blank project row in the library; returns its uuid. Single
|
||||
/// call with a stack buffer: the create has a side effect, so the
|
||||
/// two-stage (measure-then-read) pattern must not be used.
|
||||
fn library_create(name: &str) -> Result<String, String> {
|
||||
let name_c = CString::new(name).map_err(|_| "invalid name".to_string())?;
|
||||
let mut buf = [0 as c_char; 256];
|
||||
let rc = unsafe { oakengine_library_create(name_c.as_ptr(), buf.as_mut_ptr(), buf.len() as c_int) };
|
||||
if rc < 0 {
|
||||
return Err(format!("failed to create the project (error {rc})"));
|
||||
}
|
||||
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
||||
Ok(
|
||||
String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) })
|
||||
.into_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
impl RealEngine {
|
||||
/// Opens the library row `uuid` through the facade's library-load path
|
||||
/// (which binds the project to the write-through session) and adopts it.
|
||||
fn open_library_project(&mut self, uuid: &str, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
let project = unsafe { oakengine_project_create() };
|
||||
if project.is_null() {
|
||||
return Err("failed to create a project".into());
|
||||
}
|
||||
let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?;
|
||||
let mut err = [0 as c_char; 4096];
|
||||
let rc = unsafe {
|
||||
oakengine_project_load_library(
|
||||
project,
|
||||
uuid_c.as_ptr(),
|
||||
err.as_mut_ptr(),
|
||||
err.len() as c_int,
|
||||
)
|
||||
};
|
||||
if rc != 0 {
|
||||
let message = load_error(&mut err);
|
||||
unsafe { oakengine_project_free(project) };
|
||||
return Err(format!("failed to open the library project: {message}"));
|
||||
}
|
||||
self.adopt_project(project, cx);
|
||||
// The facade's project name is filename-derived ("(untitled)" for a
|
||||
// library row); display the library row name instead.
|
||||
if let Ok(rows) = library_list() {
|
||||
if let Some(row) = rows.iter().find(|row| row.uuid == uuid) {
|
||||
self.project_info.name = row.name.clone();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! The global status bar (状态栏): ready state, cache, proxy and autosave
|
||||
//! info on the left; current timecode / duration, frame rate and resolution
|
||||
//! on the right.
|
||||
//! The global status bar (状态栏): ready state, cache, proxy and the
|
||||
//! library write state (M13 D4: the write-through replaces the manual save,
|
||||
//! so the old autosave hint becomes "written to the library / library off /
|
||||
//! write failed") on the left; current timecode / duration, frame rate and
|
||||
//! resolution on the right.
|
||||
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::timeline::Frame;
|
||||
@@ -66,6 +68,25 @@ impl<E: AppEngine> Render for StatusBar<E> {
|
||||
div().px_2().py_1().text_color(colors.text).child(text)
|
||||
};
|
||||
|
||||
// The write-through state (M13 D4): a bound project with no recorded
|
||||
// error is written through; an error turns the segment red.
|
||||
let (storage_text, storage_color) = if engine.storage_last_error().is_some() {
|
||||
(
|
||||
crate::i18n::tr("status.storage.error"),
|
||||
gpui::rgba(0xcc6666ff),
|
||||
)
|
||||
} else if engine.storage_bound() {
|
||||
(
|
||||
crate::i18n::tr("status.storage.written"),
|
||||
colors.disabled,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
crate::i18n::tr("status.storage.unbound"),
|
||||
colors.disabled,
|
||||
)
|
||||
};
|
||||
|
||||
div()
|
||||
.h_6()
|
||||
.flex()
|
||||
@@ -77,7 +98,13 @@ impl<E: AppEngine> Render for StatusBar<E> {
|
||||
.child(segment(&colors, crate::i18n::tr("status.ready").into()))
|
||||
.child(segment(&colors, crate::i18n::tr("status.cache").into()))
|
||||
.child(segment(&colors, crate::i18n::tr("status.proxy").into()))
|
||||
.child(segment(&colors, crate::i18n::tr("status.autosave").into()))
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.text_color(storage_color)
|
||||
.child(storage_text),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(segment(
|
||||
&colors,
|
||||
|
||||
Reference in New Issue
Block a user