feat(engine): clip move, clip effect_input, mandatory static FFmpeg
- oakengine_sequence_move_clip implemented for real (oaktimeline TrackMoveBlockCommand; fixes the graph-ownership/gap-anchor/ripple trim bugs the stub was hiding); same-track via the frozen C ABI, cross-track supported by the module command - oaknode clip blocks now declare a tex_in texture input and set effect_input to it, so timeline clips can host effect chains; facade test covers effect insert/remove on a real clip - oakffmpeg-link: FFMPEG_DIR is now mandatory with a clear panic (a Homebrew upgrade left the system ffmpeg .pc pointing at a deleted dav1d Cellar path, breaking links); reads a git-ignored workspace .env for IDEs that cannot inject env vars (RustRover); links the C++ stdlib for C++ codec libs (svt-av1) - oakengine re-exports oaknode so tests share one crate instance; it_node uses the direct instance's value type where it calls the module FFI (the --workspace dev-dependency feature split builds oaknode twice)
This commit is contained in:
+47
-49
@@ -47,12 +47,12 @@ use gpui::{
|
||||
WindowBounds, WindowOptions,
|
||||
};
|
||||
use gpui_widgets::audio_meter::AudioLevelMeter;
|
||||
use gpui_widgets::viewer::PlaybackClock;
|
||||
use gpui_widgets::dialog::file_dialog::FileDialogContent;
|
||||
use gpui_widgets::dialog::progress::{ProgressContent, progress_dialog};
|
||||
use gpui_widgets::dialog::progress::{progress_dialog, ProgressContent};
|
||||
use gpui_widgets::dialog::{DialogButton, Modal, ModalEvent, ModalOptions};
|
||||
use gpui_widgets::menu::{Menu, MenuBar, MenuBarEntry, MenuBarEvent, MenuItem};
|
||||
use gpui_widgets::theme::{apply_theme, OakTheme};
|
||||
use gpui_widgets::viewer::PlaybackClock;
|
||||
|
||||
use crate::dialogs::{ExportDialogContent, PreferencesContent};
|
||||
use crate::oakui::{AppEngine, ExportSession, MockEngine, Monitor, RealEngine};
|
||||
@@ -268,11 +268,7 @@ pub struct OakApp<E: AppEngine> {
|
||||
impl<E: AppEngine> OakApp<E> {
|
||||
/// Builds the whole shell. `initial_path` (a CLI argument) is opened
|
||||
/// after the layout is up.
|
||||
pub fn new(
|
||||
window: &mut Window,
|
||||
initial_path: Option<PathBuf>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
pub fn new(window: &mut Window, initial_path: Option<PathBuf>, cx: &mut Context<Self>) -> Self {
|
||||
apply_theme(cx, &OakTheme::olive_dark());
|
||||
crate::oakui::icons::init(cx);
|
||||
|
||||
@@ -400,20 +396,15 @@ impl<E: AppEngine> OakApp<E> {
|
||||
// `SelectionChanged` carries no payload — the selection is read from
|
||||
// the view and forwarded to the engine so the inspector's effect
|
||||
// stack can target the selected clip.
|
||||
cx.subscribe(
|
||||
&timeline,
|
||||
|this, timeline, event: &TimelineEvent, cx| {
|
||||
if matches!(event, TimelineEvent::SelectionChanged) {
|
||||
let clips: Vec<ClipId> =
|
||||
timeline.read(cx).selection().iter().copied().collect();
|
||||
this.engine.update(cx, |engine, cx| {
|
||||
engine.set_selected_clips(clips, cx)
|
||||
});
|
||||
}
|
||||
cx.subscribe(&timeline, |this, timeline, event: &TimelineEvent, cx| {
|
||||
if matches!(event, TimelineEvent::SelectionChanged) {
|
||||
let clips: Vec<ClipId> = timeline.read(cx).selection().iter().copied().collect();
|
||||
this.engine
|
||||
.update(cx, |engine, cx| engine.apply_timeline_event(event, cx));
|
||||
},
|
||||
)
|
||||
.update(cx, |engine, cx| engine.set_selected_clips(clips, cx));
|
||||
}
|
||||
this.engine
|
||||
.update(cx, |engine, cx| engine.apply_timeline_event(event, cx));
|
||||
})
|
||||
.detach();
|
||||
|
||||
// --- tick loop -----------------------------------------------------
|
||||
@@ -480,7 +471,9 @@ impl<E: AppEngine> OakApp<E> {
|
||||
OPEN_PROJECT => self.open_file_dialog(FileAction::Open, cx),
|
||||
SAVE => self.save_project(None, cx),
|
||||
SAVE_AS => self.open_file_dialog(FileAction::SaveAs, cx),
|
||||
CLOSE => self.engine.update(cx, |engine, cx| engine.close_project(cx)),
|
||||
CLOSE => self
|
||||
.engine
|
||||
.update(cx, |engine, cx| engine.close_project(cx)),
|
||||
EXPORT => self.open_export_dialog(cx),
|
||||
QUIT => cx.quit(),
|
||||
// --- Edit ------------------------------------------------------
|
||||
@@ -547,9 +540,9 @@ impl<E: AppEngine> OakApp<E> {
|
||||
.update(cx, |engine, cx| engine.add_track(kind, cx));
|
||||
}
|
||||
REMOVE_TRACK => self.remove_selected_track(cx),
|
||||
SPLIT_AT_PLAYHEAD => {
|
||||
self.engine.update(cx, |engine, cx| engine.split_at_playhead(cx))
|
||||
}
|
||||
SPLIT_AT_PLAYHEAD => self
|
||||
.engine
|
||||
.update(cx, |engine, cx| engine.split_at_playhead(cx)),
|
||||
// --- Window ----------------------------------------------------
|
||||
FOCUS_PROJECT => self.focus_panel(PROJECT, cx),
|
||||
FOCUS_SOURCE_VIEWER => self.focus_panel(SOURCE_VIEWER, cx),
|
||||
@@ -575,13 +568,8 @@ impl<E: AppEngine> OakApp<E> {
|
||||
/// 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>) {
|
||||
let ids: Vec<gpui::timeline::ClipId> = self
|
||||
.timeline
|
||||
.read(cx)
|
||||
.selection()
|
||||
.iter()
|
||||
.copied()
|
||||
.collect();
|
||||
let ids: Vec<gpui::timeline::ClipId> =
|
||||
self.timeline.read(cx).selection().iter().copied().collect();
|
||||
if ids.is_empty() {
|
||||
println!("[timeline] delete: nothing selected");
|
||||
return;
|
||||
@@ -692,10 +680,7 @@ impl<E: AppEngine> OakApp<E> {
|
||||
/// Opens the file open / save-as dialog.
|
||||
fn open_file_dialog(&mut self, action: FileAction, cx: &mut Context<Self>) {
|
||||
let (title, control) = match action {
|
||||
FileAction::Open => (
|
||||
crate::i18n::tr("file.open.title"),
|
||||
modal_ids::FILE_OPEN,
|
||||
),
|
||||
FileAction::Open => (crate::i18n::tr("file.open.title"), modal_ids::FILE_OPEN),
|
||||
FileAction::SaveAs => (
|
||||
crate::i18n::tr("file.save_as.title"),
|
||||
modal_ids::FILE_SAVE_AS,
|
||||
@@ -712,7 +697,9 @@ impl<E: AppEngine> OakApp<E> {
|
||||
gpui_widgets::dialog::file_dialog::file_dialog(control, title, window, app);
|
||||
if action == FileAction::SaveAs {
|
||||
if let Some(path) = ¤t_path {
|
||||
content.update(app, |content, cx| content.set_path(path.to_string_lossy().into_owned(), cx));
|
||||
content.update(app, |content, cx| {
|
||||
content.set_path(path.to_string_lossy().into_owned(), cx)
|
||||
});
|
||||
}
|
||||
}
|
||||
ModalState::FileDialog {
|
||||
@@ -983,21 +970,31 @@ fn make_menus(dark: bool) -> Vec<MenuBarEntry> {
|
||||
MenuItem::new(NEW_PROJECT, tr("menu.file.new_project")).with_shortcut("⌘N"),
|
||||
MenuItem::new(OPEN_PROJECT, tr("menu.file.open_project")).with_shortcut("⌘O"),
|
||||
MenuItem::new(SAVE, tr("menu.file.save")).with_shortcut("⌘S"),
|
||||
MenuItem::new(SAVE_AS, tr("menu.file.save_as")).with_shortcut("⇧⌘S").separated(),
|
||||
MenuItem::new(SAVE_AS, tr("menu.file.save_as"))
|
||||
.with_shortcut("⇧⌘S")
|
||||
.separated(),
|
||||
MenuItem::new(CLOSE, tr("menu.file.close")),
|
||||
MenuItem::new(EXPORT, tr("menu.file.export")).with_shortcut("⌘E").separated(),
|
||||
MenuItem::new(QUIT, tr("menu.file.quit")).with_shortcut("⌘Q").separated(),
|
||||
MenuItem::new(EXPORT, tr("menu.file.export"))
|
||||
.with_shortcut("⌘E")
|
||||
.separated(),
|
||||
MenuItem::new(QUIT, tr("menu.file.quit"))
|
||||
.with_shortcut("⌘Q")
|
||||
.separated(),
|
||||
]),
|
||||
),
|
||||
MenuBarEntry::new(
|
||||
tr("menu.edit"),
|
||||
Menu::new(vec![
|
||||
MenuItem::new(UNDO, tr("menu.edit.undo")).with_shortcut("⌘Z"),
|
||||
MenuItem::new(REDO, tr("menu.edit.redo")).with_shortcut("⇧⌘Z").separated(),
|
||||
MenuItem::new(REDO, tr("menu.edit.redo"))
|
||||
.with_shortcut("⇧⌘Z")
|
||||
.separated(),
|
||||
MenuItem::new(CUT, tr("menu.edit.cut")).with_shortcut("⌘X"),
|
||||
MenuItem::new(COPY, tr("menu.edit.copy")).with_shortcut("⌘C"),
|
||||
MenuItem::new(PASTE, tr("menu.edit.paste")).with_shortcut("⌘V"),
|
||||
MenuItem::new(DELETE, tr("menu.edit.delete")).with_shortcut("⌫").separated(),
|
||||
MenuItem::new(DELETE, tr("menu.edit.delete"))
|
||||
.with_shortcut("⌫")
|
||||
.separated(),
|
||||
MenuItem::new(RIPPLE_DELETE, tr("menu.edit.ripple_delete")),
|
||||
]),
|
||||
),
|
||||
@@ -1149,7 +1146,7 @@ fn run_with<E: AppEngine>(args: AppArgs) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::{TestAppContext, px, size};
|
||||
use gpui::{px, size, TestAppContext};
|
||||
|
||||
/// The 视图/View menu carries a 语言/Language submenu whose items are
|
||||
/// labeled in their own language and whose checkmark follows the active
|
||||
@@ -1288,7 +1285,11 @@ mod tests {
|
||||
// The same ids exist in the zh-CN menu bar.
|
||||
crate::i18n::set_language(crate::i18n::Language::ZhCN);
|
||||
let file = entry("文件(F)");
|
||||
assert!(file.menu.items.iter().any(|item| item.id == menu_ids::SAVE_AS));
|
||||
assert!(file
|
||||
.menu
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| item.id == menu_ids::SAVE_AS));
|
||||
}
|
||||
|
||||
/// Opening 视图 → Preferences… must not crash: the dialog content and the
|
||||
@@ -1306,9 +1307,7 @@ mod tests {
|
||||
cx.run_until_parked();
|
||||
let root = window.root(cx).expect("app root");
|
||||
|
||||
cx.update(|app| {
|
||||
root.update(app, |app, cx| app.on_menu(menu_ids::PREFERENCES, cx))
|
||||
});
|
||||
cx.update(|app| root.update(app, |app, cx| app.on_menu(menu_ids::PREFERENCES, cx)));
|
||||
cx.run_until_parked();
|
||||
// Force a draw so render-time panics in the dialog content surface.
|
||||
cx.update_window(window.into(), |_root, window, cx| {
|
||||
@@ -1316,9 +1315,8 @@ mod tests {
|
||||
})
|
||||
.expect("window is still open");
|
||||
|
||||
let has_modal = cx.read(|app| {
|
||||
matches!(root.read(app).modal, ModalState::Preferences { .. })
|
||||
});
|
||||
let has_modal =
|
||||
cx.read(|app| matches!(root.read(app).modal, ModalState::Preferences { .. }));
|
||||
assert!(
|
||||
has_modal,
|
||||
"preferences modal should be shown after the menu action"
|
||||
|
||||
+21
-10
@@ -24,8 +24,8 @@
|
||||
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Context, Entity, Render, SharedString, Window, div};
|
||||
use gpui_elements::editable_text::{EditableTextState, StringStorage, text_input};
|
||||
use gpui::{div, App, Context, Entity, Render, SharedString, Window};
|
||||
use gpui_elements::editable_text::{text_input, EditableTextState, StringStorage};
|
||||
use gpui_widgets::combo_box::{ComboBox, ComboBoxEvent, ComboBoxOption};
|
||||
|
||||
use crate::i18n;
|
||||
@@ -77,7 +77,9 @@ impl PreferencesContent {
|
||||
let _ = cx;
|
||||
})
|
||||
.detach();
|
||||
backend.update(cx, |combo, cx| combo.set_selected(Some(backend_selected), cx));
|
||||
backend.update(cx, |combo, cx| {
|
||||
combo.set_selected(Some(backend_selected), cx)
|
||||
});
|
||||
|
||||
let language_options = vec![
|
||||
ComboBoxOption::new(0, "English (en-US)"),
|
||||
@@ -102,7 +104,9 @@ impl PreferencesContent {
|
||||
let _ = cx;
|
||||
})
|
||||
.detach();
|
||||
language.update(cx, |combo, cx| combo.set_selected(Some(language_selected), cx));
|
||||
language.update(cx, |combo, cx| {
|
||||
combo.set_selected(Some(language_selected), cx)
|
||||
});
|
||||
|
||||
Self {
|
||||
backend,
|
||||
@@ -201,7 +205,11 @@ impl Render for PathField {
|
||||
.bg(colors.background)
|
||||
.px_2()
|
||||
.py_1()
|
||||
.child(text_input("gpui-widgets-export-path").state(weak).accepts_input(true))
|
||||
.child(
|
||||
text_input("gpui-widgets-export-path")
|
||||
.state(weak)
|
||||
.accepts_input(true),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,9 +233,7 @@ impl ExportDialogContent {
|
||||
let options = formats
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (_, name, ext))| {
|
||||
ComboBoxOption::new(i, format!("{name} (.{ext})"))
|
||||
})
|
||||
.map(|(i, (_, name, ext))| ComboBoxOption::new(i, format!("{name} (.{ext})")))
|
||||
.collect();
|
||||
let format = cx.new(|cx| {
|
||||
ComboBox::new(3, options, window, cx)
|
||||
@@ -281,7 +287,8 @@ impl ExportDialogContent {
|
||||
/// Pre-fills the output path.
|
||||
pub fn set_path(&mut self, path: impl Into<SharedString>, cx: &mut Context<Self>) {
|
||||
let path = path.into();
|
||||
self.path.update(cx, |content, cx| content.set_path(path, cx));
|
||||
self.path
|
||||
.update(cx, |content, cx| content.set_path(path, cx));
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -299,7 +306,11 @@ impl Render for ExportDialogContent {
|
||||
i18n::tr("export.format").into(),
|
||||
self.format.clone(),
|
||||
))
|
||||
.child(form_row(&colors, i18n::tr("export.path").into(), self.path.clone()))
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("export.path").into(),
|
||||
self.path.clone(),
|
||||
))
|
||||
.child(
|
||||
div()
|
||||
.text_color(colors.disabled)
|
||||
|
||||
+22
-17
@@ -83,10 +83,13 @@ pub fn language() -> Language {
|
||||
/// Switches the active language live and persists the choice through the
|
||||
/// oakengine config C ABI.
|
||||
pub fn set_language(language: Language) {
|
||||
CURRENT.store(match language {
|
||||
Language::EnUs => 0,
|
||||
Language::ZhCN => 1,
|
||||
}, Ordering::Relaxed);
|
||||
CURRENT.store(
|
||||
match language {
|
||||
Language::EnUs => 0,
|
||||
Language::ZhCN => 1,
|
||||
},
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
persist_language(language);
|
||||
sync_widgets();
|
||||
}
|
||||
@@ -150,8 +153,7 @@ pub fn sync_widgets() {
|
||||
|
||||
/// Looks `key` up in the en-US table.
|
||||
fn en(key: &'static str) -> &'static str {
|
||||
EN
|
||||
.iter()
|
||||
EN.iter()
|
||||
.find(|(k, _)| *k == key)
|
||||
.map(|(_, v)| *v)
|
||||
.unwrap_or(key)
|
||||
@@ -159,10 +161,7 @@ fn en(key: &'static str) -> &'static str {
|
||||
|
||||
/// Looks `key` up in the zh-CN table.
|
||||
fn zh(key: &'static str) -> Option<&'static str> {
|
||||
ZH
|
||||
.iter()
|
||||
.find(|(k, _)| *k == key)
|
||||
.map(|(_, v)| *v)
|
||||
ZH.iter().find(|(k, _)| *k == key).map(|(_, v)| *v)
|
||||
}
|
||||
|
||||
/// The en-US table. Every key must also exist in [`ZH`]; [`tr`] tolerates
|
||||
@@ -447,13 +446,19 @@ const ZH: &[(&str, &str)] = &[
|
||||
("preferences.backend.placeholder", "选择一个后端…"),
|
||||
("preferences.language", "语言"),
|
||||
("preferences.language.placeholder", "选择语言…"),
|
||||
("preferences.hint", "渲染后端在下次启动渲染工作进程时生效;语言立即切换。"),
|
||||
(
|
||||
"preferences.hint",
|
||||
"渲染后端在下次启动渲染工作进程时生效;语言立即切换。",
|
||||
),
|
||||
("export.title", "导出序列"),
|
||||
("export.format", "格式"),
|
||||
("export.format.placeholder", "选择格式…"),
|
||||
("export.path", "输出路径"),
|
||||
("export.run", "导出"),
|
||||
("export.hint", "序列通过 oaktask 导出路径导出;进度显示在对话框中。"),
|
||||
(
|
||||
"export.hint",
|
||||
"序列通过 oaktask 导出路径导出;进度显示在对话框中。",
|
||||
),
|
||||
("export.progress.title", "正在导出"),
|
||||
("export.progress.label", "正在渲染帧…"),
|
||||
];
|
||||
@@ -495,10 +500,7 @@ mod tests {
|
||||
.iter()
|
||||
.find(|(k, _)| *k == *key)
|
||||
.unwrap_or_else(|| panic!("zh-CN table is missing key {key}"));
|
||||
assert!(
|
||||
!zh_value.1.is_empty(),
|
||||
"empty zh-CN value for {key}"
|
||||
);
|
||||
assert!(!zh_value.1.is_empty(), "empty zh-CN value for {key}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -561,7 +563,10 @@ mod tests {
|
||||
#[test]
|
||||
fn tr_never_panics_on_missing_key() {
|
||||
let _guard = lang_lock().lock().unwrap();
|
||||
for (language, sample) in [(Language::EnUs, "missing.key"), (Language::ZhCN, "missing.key")] {
|
||||
for (language, sample) in [
|
||||
(Language::EnUs, "missing.key"),
|
||||
(Language::ZhCN, "missing.key"),
|
||||
] {
|
||||
set_language(language);
|
||||
assert_eq!(tr(sample), sample);
|
||||
}
|
||||
|
||||
+8
-2
@@ -226,7 +226,12 @@ pub trait AppEngine:
|
||||
/// [`EffectStackDataSource::effects`] (0 = closest to the source);
|
||||
/// the panel passes the position carried by the `AddRequested` event.
|
||||
/// Returns a user-facing error message on failure. Default: unsupported.
|
||||
fn add_effect(&mut self, index: usize, type_id: &str, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
fn add_effect(
|
||||
&mut self,
|
||||
index: usize,
|
||||
type_id: &str,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
let _ = (index, type_id, cx);
|
||||
Err("add effect not supported".into())
|
||||
}
|
||||
@@ -276,7 +281,8 @@ pub trait AppEngine:
|
||||
/// Saves the project to `path` (or its own filename when `None`). The
|
||||
/// format is dispatched by extension like [`open_project_path`]
|
||||
/// (AppEngine::open_project_path).
|
||||
fn save_project(&mut self, path: Option<PathBuf>, cx: &mut Context<Self>) -> Result<(), String>;
|
||||
fn save_project(&mut self, path: Option<PathBuf>, cx: &mut Context<Self>)
|
||||
-> Result<(), String>;
|
||||
|
||||
/// Closes the current project, leaving the app with no sequence.
|
||||
fn close_project(&mut self, cx: &mut Context<Self>);
|
||||
|
||||
+34
-14
@@ -222,7 +222,11 @@ unsafe extern "C" {
|
||||
/// `oakengine_encoding_format_count` — number of export formats.
|
||||
pub fn oakengine_encoding_format_count() -> c_int;
|
||||
/// `oakengine_encoding_format_name` (buf/size; -1 invalid).
|
||||
pub fn oakengine_encoding_format_name(format: c_int, buf: *mut c_char, buf_size: c_int) -> c_int;
|
||||
pub fn oakengine_encoding_format_name(
|
||||
format: c_int,
|
||||
buf: *mut c_char,
|
||||
buf_size: c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_encoding_format_extension` (buf/size).
|
||||
pub fn oakengine_encoding_format_extension(
|
||||
format: c_int,
|
||||
@@ -273,7 +277,11 @@ unsafe extern "C" {
|
||||
|
||||
/// `oakengine_config_get_string` — read a config string; a missing key
|
||||
/// reads as an empty string.
|
||||
pub fn oakengine_config_get_string(key: *const c_char, buf: *mut c_char, buf_size: c_int) -> c_int;
|
||||
pub fn oakengine_config_get_string(
|
||||
key: *const c_char,
|
||||
buf: *mut c_char,
|
||||
buf_size: c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_config_set_string` — write a string value.
|
||||
pub fn oakengine_config_set_string(key: *const c_char, value: *const c_char) -> c_int;
|
||||
|
||||
@@ -336,14 +344,21 @@ unsafe extern "C" {
|
||||
index: c_int,
|
||||
) -> *mut OakEngineSequence;
|
||||
/// `oakengine_project_set_filename`.
|
||||
pub fn oakengine_project_set_filename(self_: *mut OakEngineProject, path: *const c_char) -> c_int;
|
||||
pub fn oakengine_project_set_filename(
|
||||
self_: *mut OakEngineProject,
|
||||
path: *const c_char,
|
||||
) -> c_int;
|
||||
/// `oakengine_project_node_count` — parseable content check.
|
||||
pub fn oakengine_project_node_count(self_: *const OakEngineProject) -> c_int;
|
||||
|
||||
// -- oakengine::task --
|
||||
|
||||
/// `oakengine_task_error` (buf/size).
|
||||
pub fn oakengine_task_error(task: *mut OakEngineTask, buf: *mut c_char, buf_size: c_int) -> c_int;
|
||||
pub fn oakengine_task_error(
|
||||
task: *mut OakEngineTask,
|
||||
buf: *mut c_char,
|
||||
buf_size: c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_task_cancel` — set the task's cancel atom.
|
||||
pub fn oakengine_task_cancel(task: *mut OakEngineTask) -> c_int;
|
||||
/// `oakengine_task_start_sync` — run the task to completion.
|
||||
@@ -379,7 +394,10 @@ unsafe extern "C" {
|
||||
buf_size: c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_sequence_get_length` — length in seconds.
|
||||
pub fn oakengine_sequence_get_length(self_: *const OakEngineSequence, seconds: *mut f64) -> c_int;
|
||||
pub fn oakengine_sequence_get_length(
|
||||
self_: *const OakEngineSequence,
|
||||
seconds: *mut f64,
|
||||
) -> c_int;
|
||||
/// `oakengine_sequence_get_frame_rate` — num/den rational.
|
||||
pub fn oakengine_sequence_get_frame_rate(
|
||||
self_: *const OakEngineSequence,
|
||||
@@ -435,6 +453,15 @@ unsafe extern "C" {
|
||||
clip_index: c_int,
|
||||
time: i64,
|
||||
) -> c_int;
|
||||
/// `oakengine_sequence_move_clip` — move the clip so its in point becomes
|
||||
/// `new_in` on the same track.
|
||||
pub fn oakengine_sequence_move_clip(
|
||||
seq: *mut OakEngineSequence,
|
||||
track_type: c_int,
|
||||
track_index: c_int,
|
||||
clip_index: c_int,
|
||||
new_in: i64,
|
||||
) -> c_int;
|
||||
/// `oakengine_sequence_ripple_delete_clip`.
|
||||
pub fn oakengine_sequence_ripple_delete_clip(
|
||||
seq: *mut OakEngineSequence,
|
||||
@@ -508,10 +535,7 @@ unsafe extern "C" {
|
||||
/// `oakengine_node_is_enabled` — 1/0 (the stack's enable switch).
|
||||
pub fn oakengine_node_is_enabled(self_: *const OakEngineNode) -> c_int;
|
||||
/// `oakengine_node_effect_set_enabled` — undoable enable toggle.
|
||||
pub fn oakengine_node_effect_set_enabled(
|
||||
self_: *mut OakEngineNode,
|
||||
enabled: c_int,
|
||||
) -> c_int;
|
||||
pub fn oakengine_node_effect_set_enabled(self_: *mut OakEngineNode, enabled: c_int) -> c_int;
|
||||
/// `oakengine_node_effect_insert` — undoable insert at `index`
|
||||
/// (0 = closest to the source; clamped to the ends).
|
||||
pub fn oakengine_node_effect_insert(
|
||||
@@ -557,11 +581,7 @@ unsafe extern "C" {
|
||||
pub fn oakengine_node_factory_id_count() -> c_int;
|
||||
/// `oakengine_node_factory_id_at` — type id at `index` (buf/size;
|
||||
/// negative error out of range).
|
||||
pub fn oakengine_node_factory_id_at(
|
||||
index: c_int,
|
||||
buf: *mut c_char,
|
||||
buf_size: c_int,
|
||||
) -> c_int;
|
||||
pub fn oakengine_node_factory_id_at(index: c_int, buf: *mut c_char, buf_size: c_int) -> c_int;
|
||||
/// `oakengine_node_factory_name_from_id` — display name (buf/size).
|
||||
pub fn oakengine_node_factory_name_from_id(
|
||||
type_id: *const c_char,
|
||||
|
||||
@@ -220,7 +220,10 @@ pub extern "C" fn oakcore_rational_free(rational: *mut c_void) {
|
||||
if rational.is_null() {
|
||||
return;
|
||||
}
|
||||
rational_store().lock().unwrap().remove(&(rational as usize));
|
||||
rational_store()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&(rational as usize));
|
||||
// SAFETY: produced by `oakcore_audioparams_time_base`; we hold the only
|
||||
// reference after removal.
|
||||
unsafe { drop(Box::from_raw(rational as *mut (i32, i32))) };
|
||||
@@ -231,10 +234,7 @@ pub extern "C" fn oakcore_rational_free(rational: *mut c_void) {
|
||||
/// implementation lives in ffmpeg_bridge). Stub: exact matches win,
|
||||
/// otherwise the first (most desirable) candidate.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn fb_find_best_pix_fmt_of_list(
|
||||
list: *const c_int,
|
||||
pix_fmt: c_int,
|
||||
) -> c_int {
|
||||
pub extern "C" fn fb_find_best_pix_fmt_of_list(list: *const c_int, pix_fmt: c_int) -> c_int {
|
||||
if list.is_null() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
+69
-65
@@ -52,82 +52,86 @@ pub const ICON_FF: &str = "ff";
|
||||
/// The theme-dependent filesystem path of an icon (`assets/icons/{dark,light}`
|
||||
/// under the crate root). Absolute, so it works from any working directory.
|
||||
pub fn icon_path(name: &str, cx: &App) -> PathBuf {
|
||||
let theme = gpui_widgets::theme::current_theme(cx);
|
||||
let family = if theme.name == "Olive Dark" { "dark" } else { "light" };
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("assets/icons")
|
||||
.join(family)
|
||||
.join(format!("{name}.png"))
|
||||
let theme = gpui_widgets::theme::current_theme(cx);
|
||||
let family = if theme.name == "Olive Dark" {
|
||||
"dark"
|
||||
} else {
|
||||
"light"
|
||||
};
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("assets/icons")
|
||||
.join(family)
|
||||
.join(format!("{name}.png"))
|
||||
}
|
||||
|
||||
/// Registers the app's icon resolver, so widget-crate consumers (the viewer
|
||||
/// transport bar) resolve the same theme-aware paths. Call at startup before
|
||||
/// any window renders; re-registering after a theme switch is harmless.
|
||||
pub fn init(cx: &mut App) {
|
||||
gpui_widgets::icons::set_resolver(
|
||||
std::sync::Arc::new(|name, cx| Some(icon_path(name, cx))),
|
||||
cx,
|
||||
);
|
||||
gpui_widgets::icons::set_resolver(
|
||||
std::sync::Arc::new(|name, cx| Some(icon_path(name, cx))),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::*;
|
||||
|
||||
/// Every icon name resolves to an existing file for both themes — a
|
||||
/// missing PNG would render as a blank toolbar button.
|
||||
#[test]
|
||||
fn every_icon_file_exists_for_both_themes() {
|
||||
for name in [
|
||||
ICON_ARROW,
|
||||
ICON_RAZOR,
|
||||
ICON_RIPPLE,
|
||||
ICON_SLIP,
|
||||
ICON_ROLLING,
|
||||
ICON_ZOOM,
|
||||
ICON_ZOOM_IN,
|
||||
ICON_ZOOM_OUT,
|
||||
ICON_SLIDE,
|
||||
ICON_TRACK_SELECT,
|
||||
ICON_SNAP,
|
||||
ICON_PLAY,
|
||||
ICON_PAUSE,
|
||||
ICON_PREV,
|
||||
ICON_NEXT,
|
||||
ICON_REW,
|
||||
ICON_FF,
|
||||
] {
|
||||
for family in ["dark", "light"] {
|
||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("assets/icons")
|
||||
.join(family)
|
||||
.join(format!("{name}.png"));
|
||||
assert!(
|
||||
path.exists(),
|
||||
"missing icon asset {path:?} (referenced as {name})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Every icon name resolves to an existing file for both themes — a
|
||||
/// missing PNG would render as a blank toolbar button.
|
||||
#[test]
|
||||
fn every_icon_file_exists_for_both_themes() {
|
||||
for name in [
|
||||
ICON_ARROW,
|
||||
ICON_RAZOR,
|
||||
ICON_RIPPLE,
|
||||
ICON_SLIP,
|
||||
ICON_ROLLING,
|
||||
ICON_ZOOM,
|
||||
ICON_ZOOM_IN,
|
||||
ICON_ZOOM_OUT,
|
||||
ICON_SLIDE,
|
||||
ICON_TRACK_SELECT,
|
||||
ICON_SNAP,
|
||||
ICON_PLAY,
|
||||
ICON_PAUSE,
|
||||
ICON_PREV,
|
||||
ICON_NEXT,
|
||||
ICON_REW,
|
||||
ICON_FF,
|
||||
] {
|
||||
for family in ["dark", "light"] {
|
||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("assets/icons")
|
||||
.join(family)
|
||||
.join(format!("{name}.png"));
|
||||
assert!(
|
||||
path.exists(),
|
||||
"missing icon asset {path:?} (referenced as {name})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The theme-aware path picks the dark family for the default dark theme
|
||||
/// and the light family once a light theme is applied.
|
||||
#[gpui::test]
|
||||
fn icon_path_follows_the_active_theme(cx: &mut gpui::TestAppContext) {
|
||||
cx.update(|app| {
|
||||
gpui_widgets::theme::apply_theme(app, &gpui_widgets::theme::OakTheme::olive_dark());
|
||||
let dark = icon_path(ICON_PLAY, app);
|
||||
assert!(
|
||||
dark.ends_with("dark/play.png"),
|
||||
"dark theme → dark family, got {dark:?}"
|
||||
);
|
||||
/// The theme-aware path picks the dark family for the default dark theme
|
||||
/// and the light family once a light theme is applied.
|
||||
#[gpui::test]
|
||||
fn icon_path_follows_the_active_theme(cx: &mut gpui::TestAppContext) {
|
||||
cx.update(|app| {
|
||||
gpui_widgets::theme::apply_theme(app, &gpui_widgets::theme::OakTheme::olive_dark());
|
||||
let dark = icon_path(ICON_PLAY, app);
|
||||
assert!(
|
||||
dark.ends_with("dark/play.png"),
|
||||
"dark theme → dark family, got {dark:?}"
|
||||
);
|
||||
|
||||
gpui_widgets::theme::apply_theme(app, &gpui_widgets::theme::OakTheme::olive_light());
|
||||
let light = icon_path(ICON_PLAY, app);
|
||||
assert!(
|
||||
light.ends_with("light/play.png"),
|
||||
"light theme → light family, got {light:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
gpui_widgets::theme::apply_theme(app, &gpui_widgets::theme::OakTheme::olive_light());
|
||||
let light = icon_path(ICON_PLAY, app);
|
||||
assert!(
|
||||
light.ends_with("light/play.png"),
|
||||
"light theme → light family, got {light:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+64
-32
@@ -465,10 +465,7 @@ impl MockEngine {
|
||||
// Port ids are globally unique.
|
||||
let video_type = PortDataType::new("video", hsla(0.55, 0.75, 0.6, 1.0));
|
||||
let audio_type = PortDataType::new("audio", hsla(0.1, 0.7, 0.55, 1.0));
|
||||
let port = |id: u64,
|
||||
kind: PortKind,
|
||||
label: &str,
|
||||
data_type: &PortDataType| MockPort {
|
||||
let port = |id: u64, kind: PortKind, label: &str, data_type: &PortDataType| MockPort {
|
||||
id: PortId(id),
|
||||
kind,
|
||||
label: label.into(),
|
||||
@@ -549,11 +546,7 @@ impl MockEngine {
|
||||
vec![],
|
||||
),
|
||||
];
|
||||
let edge = |id: u64,
|
||||
from_node: u64,
|
||||
from_port: u64,
|
||||
to_node: u64,
|
||||
to_port: u64| MockEdge {
|
||||
let edge = |id: u64, from_node: u64, from_port: u64, to_node: u64, to_port: u64| MockEdge {
|
||||
id: EdgeId(id),
|
||||
from_node: NodeId(from_node),
|
||||
from_port: PortId(from_port),
|
||||
@@ -824,8 +817,7 @@ impl MockEngine {
|
||||
/// Returns the node that owns `port`.
|
||||
fn node_with_port(&self, port: PortId) -> Option<&MockNode> {
|
||||
self.nodes.iter().find(|n| {
|
||||
n.inputs.iter().any(|p| p.id() == port)
|
||||
|| n.outputs.iter().any(|p| p.id() == port)
|
||||
n.inputs.iter().any(|p| p.id() == port) || n.outputs.iter().any(|p| p.id() == port)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1111,7 +1103,11 @@ impl AppEngine for MockEngine {
|
||||
self.request_frame(Monitor::Program, *frame, cx);
|
||||
}
|
||||
}
|
||||
TimelineEvent::ClipTrimRequested { clip, edge, new_frame } => {
|
||||
TimelineEvent::ClipTrimRequested {
|
||||
clip,
|
||||
edge,
|
||||
new_frame,
|
||||
} => {
|
||||
if let Some((track, index)) = self.mock_clip_position(*clip) {
|
||||
let clip = &mut self.tracks[track].clips[index];
|
||||
match edge {
|
||||
@@ -1127,7 +1123,11 @@ impl AppEngine for MockEngine {
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
TimelineEvent::ClipMoveRequested { clip, new_track, new_start } => {
|
||||
TimelineEvent::ClipMoveRequested {
|
||||
clip,
|
||||
new_track,
|
||||
new_start,
|
||||
} => {
|
||||
let Some((track, index)) = self.mock_clip_position(*clip) else {
|
||||
return;
|
||||
};
|
||||
@@ -1171,9 +1171,7 @@ impl AppEngine for MockEngine {
|
||||
.clips
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, clip)| {
|
||||
clip.range.start.0 < frame.0 && frame.0 < clip.range.end.0
|
||||
})
|
||||
.filter(|(_, clip)| clip.range.start.0 < frame.0 && frame.0 < clip.range.end.0)
|
||||
.map(|(clip_index, _)| (track_index, clip_index))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
@@ -1238,7 +1236,11 @@ impl AppEngine for MockEngine {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_project(&mut self, _path: Option<PathBuf>, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
fn save_project(
|
||||
&mut self,
|
||||
_path: Option<PathBuf>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
println!("[mock engine] save: no persistence in mock mode");
|
||||
cx.notify();
|
||||
Ok(())
|
||||
@@ -1422,7 +1424,9 @@ impl MockEngine {
|
||||
let (width, height, samples) = crate::oakui::frames::synthetic_frame_samples(frame);
|
||||
// Analyze the scopes from the same F32 samples the viewer displays.
|
||||
let scope = crate::oakui::scopes::analyze_f32_rgba(width, height, &samples);
|
||||
let image = Arc::new(crate::oakui::frames::f32_rgba_to_bgra_image(width, height, &samples));
|
||||
let image = Arc::new(crate::oakui::frames::f32_rgba_to_bgra_image(
|
||||
width, height, &samples,
|
||||
));
|
||||
cache.insert(monitor, (frame.0, image.clone(), scope));
|
||||
image
|
||||
}
|
||||
@@ -1565,11 +1569,19 @@ mod tests {
|
||||
});
|
||||
let v2 = engine.read(app).track(0).expect("V2");
|
||||
assert!(
|
||||
v2.clips().iter().any(|c| c.id() == ClipId(11) && c.range().start == Frame(300)),
|
||||
v2.clips()
|
||||
.iter()
|
||||
.any(|c| c.id() == ClipId(11) && c.range().start == Frame(300)),
|
||||
"开场 moved to V2 at frame 300"
|
||||
);
|
||||
assert!(
|
||||
!engine.read(app).track(1).expect("V1").clips().iter().any(|c| c.id() == ClipId(11)),
|
||||
!engine
|
||||
.read(app)
|
||||
.track(1)
|
||||
.expect("V1")
|
||||
.clips()
|
||||
.iter()
|
||||
.any(|c| c.id() == ClipId(11)),
|
||||
"开场 left V1"
|
||||
);
|
||||
});
|
||||
@@ -1613,7 +1625,11 @@ mod tests {
|
||||
.iter()
|
||||
.find(|c| c.id() == ClipId(12))
|
||||
.expect("B-roll clip");
|
||||
assert_eq!(b_roll.range().start, Frame(120), "ripple shifted B-roll left");
|
||||
assert_eq!(
|
||||
b_roll.range().start,
|
||||
Frame(120),
|
||||
"ripple shifted B-roll left"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1663,9 +1679,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn can_connect_enforces_direction_type_duplicates_and_cycles(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
async fn can_connect_enforces_direction_type_duplicates_and_cycles(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
let engine = engine.read(app);
|
||||
@@ -1675,11 +1689,20 @@ mod tests {
|
||||
// second input).
|
||||
assert!(engine.can_connect(PortId(3), PortId(20)));
|
||||
// The existing connection is not offered again.
|
||||
assert!(!engine.can_connect(PortId(21), PortId(40)), "duplicate edge");
|
||||
assert!(
|
||||
!engine.can_connect(PortId(21), PortId(40)),
|
||||
"duplicate edge"
|
||||
);
|
||||
// Input → output is rejected (wrong direction).
|
||||
assert!(!engine.can_connect(PortId(20), PortId(1)), "wrong direction");
|
||||
assert!(
|
||||
!engine.can_connect(PortId(20), PortId(1)),
|
||||
"wrong direction"
|
||||
);
|
||||
// A port cannot connect to itself.
|
||||
assert!(!engine.can_connect(PortId(42), PortId(40)), "self connection");
|
||||
assert!(
|
||||
!engine.can_connect(PortId(42), PortId(40)),
|
||||
"self connection"
|
||||
);
|
||||
// Type mismatch: the clip's audio output is not a video signal.
|
||||
assert!(
|
||||
!engine.can_connect(PortId(4), PortId(20)),
|
||||
@@ -1728,7 +1751,9 @@ mod tests {
|
||||
});
|
||||
let engine_read = engine.read(app);
|
||||
assert_eq!(engine_read.edges().len(), 6);
|
||||
assert!(engine_read.port(PortId(22)).is_some_and(|p| p.is_connected()));
|
||||
assert!(engine_read
|
||||
.port(PortId(22))
|
||||
.is_some_and(|p| p.is_connected()));
|
||||
let edges = engine_read.edges();
|
||||
let extra = edges
|
||||
.iter()
|
||||
@@ -1743,7 +1768,10 @@ mod tests {
|
||||
});
|
||||
assert_eq!(engine.read(app).edges().len(), 5);
|
||||
assert!(
|
||||
!engine.read(app).port(PortId(22)).is_some_and(|p| p.is_connected()),
|
||||
!engine
|
||||
.read(app)
|
||||
.port(PortId(22))
|
||||
.is_some_and(|p| p.is_connected()),
|
||||
"mask input freed again"
|
||||
);
|
||||
|
||||
@@ -1760,8 +1788,10 @@ mod tests {
|
||||
let engine_read = engine.read(app);
|
||||
assert_eq!(engine_read.nodes().len(), 5);
|
||||
assert!(
|
||||
!engine_read.edges().iter().any(|e| e.to_node() == NodeId(3)
|
||||
|| e.from_node() == NodeId(3)),
|
||||
!engine_read
|
||||
.edges()
|
||||
.iter()
|
||||
.any(|e| e.to_node() == NodeId(3) || e.from_node() == NodeId(3)),
|
||||
"edges incident to the deleted node are removed"
|
||||
);
|
||||
});
|
||||
@@ -1793,7 +1823,9 @@ mod tests {
|
||||
let bytes = c.as_bytes(0).expect("single frame");
|
||||
assert_eq!(
|
||||
bytes.len(),
|
||||
(crate::oakui::frames::SYNTH_FRAME_WIDTH * crate::oakui::frames::SYNTH_FRAME_HEIGHT * 4) as usize
|
||||
(crate::oakui::frames::SYNTH_FRAME_WIDTH
|
||||
* crate::oakui::frames::SYNTH_FRAME_HEIGHT
|
||||
* 4) as usize
|
||||
);
|
||||
assert!(bytes.chunks_exact(4).all(|px| px[3] == 255), "opaque alpha");
|
||||
});
|
||||
|
||||
+223
-62
@@ -54,8 +54,9 @@
|
||||
//! and edits go through the facade's undoable effect commands. Node
|
||||
//! graph and audio meter still feed empty/silent data (their facade
|
||||
//! surfaces are not bound in this increment).
|
||||
//! * `oakengine_sequence_move_clip` is a documented facade stub (module gap),
|
||||
//! so clip moves report the facade error instead of applying.
|
||||
//! * Clip moves go through `oakengine_sequence_move_clip` (same-track only;
|
||||
//! the facade's capi signature has no target-track parameter, so a
|
||||
//! cross-track drag reports "not supported" instead of applying).
|
||||
//!
|
||||
//! # Threading note
|
||||
//!
|
||||
@@ -76,23 +77,25 @@ use gpui::effect_stack::{
|
||||
EffectCardKind, EffectData, EffectId, EffectStackDataSource, EffectStackEvent,
|
||||
};
|
||||
use gpui::node_graph::{
|
||||
EdgeData, EdgeId, NodeData, NodeGraphDataSource, NodeGraphEvent, NodeId, PortData, PortId,
|
||||
PortDataType, PortKind,
|
||||
EdgeData, EdgeId, NodeData, NodeGraphDataSource, NodeGraphEvent, NodeId, PortData,
|
||||
PortDataType, PortId, PortKind,
|
||||
};
|
||||
use gpui::timeline::{
|
||||
ClipData, ClipId, Frame, FrameRange, FrameRate, TimelineDataSource, TimelineEvent, TrackData,
|
||||
TrackKind, TrimEdge,
|
||||
};
|
||||
use gpui::{hsla, point, prelude::*, px, App, Context, Entity, Hsla, Pixels, RenderImage, SharedString};
|
||||
use gpui::{
|
||||
hsla, point, prelude::*, px, App, Context, Entity, Hsla, Pixels, RenderImage, SharedString,
|
||||
};
|
||||
use gpui_widgets::audio_meter::AudioMeterDataSource;
|
||||
use gpui_widgets::project_explorer::{ProjectDataSource, ProjectEntry};
|
||||
use gpui_widgets::viewer::PlaybackClock;
|
||||
|
||||
use super::ffi::*;
|
||||
use super::engine::{
|
||||
AppEngine, EngineGateway, ExportEvent, ExportSession, Monitor, Project, ScopeData, Sequence,
|
||||
VideoFormat,
|
||||
};
|
||||
use super::ffi::*;
|
||||
use super::frames::{f32_rgba_to_bgra_image, synthetic_frame_samples};
|
||||
use super::scopes::analyze_f32_rgba;
|
||||
use super::transport::TransportState;
|
||||
@@ -283,9 +286,10 @@ fn read_string(f: impl Fn(*mut c_char, c_int) -> c_int) -> String {
|
||||
/// Reads the error buffer the OVE load/save serializer fills.
|
||||
fn load_error(err: &mut [c_char]) -> String {
|
||||
let len = err.iter().position(|&c| c == 0).unwrap_or(err.len());
|
||||
let text =
|
||||
String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(err.as_ptr() as *const u8, len) })
|
||||
.into_owned();
|
||||
let text = String::from_utf8_lossy(unsafe {
|
||||
std::slice::from_raw_parts(err.as_ptr() as *const u8, len)
|
||||
})
|
||||
.into_owned();
|
||||
if text.is_empty() {
|
||||
"the operation failed".to_string()
|
||||
} else {
|
||||
@@ -340,7 +344,8 @@ impl RealClock {
|
||||
let elapsed = started.elapsed();
|
||||
let mut frame = anchored
|
||||
+ Frame(
|
||||
(elapsed.as_secs_f64() * self.rate.num as f64 / self.rate.den as f64).round() as i64,
|
||||
(elapsed.as_secs_f64() * self.rate.num as f64 / self.rate.den as f64).round()
|
||||
as i64,
|
||||
);
|
||||
if length.0 > 0 && frame.0 >= length.0 {
|
||||
frame = Frame(frame.0 % length.0);
|
||||
@@ -711,7 +716,10 @@ impl RealEngine {
|
||||
|
||||
/// Current sequence length (0 without a sequence).
|
||||
fn sequence_length(&self) -> Frame {
|
||||
self.sequence_info.as_ref().map(|s| s.length).unwrap_or(Frame(0))
|
||||
self.sequence_info
|
||||
.as_ref()
|
||||
.map(|s| s.length)
|
||||
.unwrap_or(Frame(0))
|
||||
}
|
||||
|
||||
/// Mirrors the program playhead into the facade sequence (best effort).
|
||||
@@ -817,7 +825,10 @@ impl RealEngine {
|
||||
}
|
||||
// The scopes read the same F32 samples the viewer displays.
|
||||
let scope = analyze_f32_rgba(width as u32, height as u32, &samples);
|
||||
image = Some((f32_rgba_to_bgra_image(width as u32, height as u32, &samples), scope));
|
||||
image = Some((
|
||||
f32_rgba_to_bgra_image(width as u32, height as u32, &samples),
|
||||
scope,
|
||||
));
|
||||
}
|
||||
unsafe {
|
||||
oakengine_frame_free(frame_ptr);
|
||||
@@ -860,9 +871,15 @@ impl RealEngine {
|
||||
|
||||
// Cached display info.
|
||||
let name = read_string(|buf, size| unsafe { oakengine_project_name(project, buf, size) });
|
||||
let path = PathBuf::from(read_string(|buf, size| unsafe { oakengine_project_filename(project, buf, size) }));
|
||||
let path = PathBuf::from(read_string(|buf, size| unsafe {
|
||||
oakengine_project_filename(project, buf, size)
|
||||
}));
|
||||
self.project_info = Project {
|
||||
name: if name.is_empty() { UNTITLED.into() } else { name },
|
||||
name: if name.is_empty() {
|
||||
UNTITLED.into()
|
||||
} else {
|
||||
name
|
||||
},
|
||||
path,
|
||||
};
|
||||
self.modified = unsafe { oakengine_project_is_modified(project) != 0 };
|
||||
@@ -919,7 +936,13 @@ impl RealEngine {
|
||||
let mut seconds: f64 = 0.0;
|
||||
unsafe {
|
||||
oakengine_sequence_get_frame_rate(seq, &mut num, &mut den);
|
||||
oakengine_sequence_get_video_params(seq, &mut width, &mut height, std::ptr::null_mut(), std::ptr::null_mut());
|
||||
oakengine_sequence_get_video_params(
|
||||
seq,
|
||||
&mut width,
|
||||
&mut height,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
oakengine_sequence_get_length(seq, &mut seconds);
|
||||
}
|
||||
let rate = if num > 0 && den > 0 {
|
||||
@@ -929,7 +952,11 @@ impl RealEngine {
|
||||
};
|
||||
let length = Frame((seconds * rate.num as f64 / rate.den as f64).round() as i64);
|
||||
self.sequence_info = Some(Sequence {
|
||||
name: if name.is_empty() { "Sequence 1".into() } else { name },
|
||||
name: if name.is_empty() {
|
||||
"Sequence 1".into()
|
||||
} else {
|
||||
name
|
||||
},
|
||||
format: VideoFormat {
|
||||
width: width.max(1) as u32,
|
||||
height: height.max(1) as u32,
|
||||
@@ -989,17 +1016,21 @@ impl RealEngine {
|
||||
// Height in internal units → pixels.
|
||||
let mut internal: f64 = 0.0;
|
||||
let height = unsafe {
|
||||
if oakengine_track_get_height(seq, track_type, track_index as c_int, &mut internal) == 0 {
|
||||
if oakengine_track_get_height(seq, track_type, track_index as c_int, &mut internal) == 0
|
||||
{
|
||||
px(oakengine_track_height_internal_to_pixels(internal).max(24) as f32)
|
||||
} else {
|
||||
px(64.0)
|
||||
}
|
||||
};
|
||||
|
||||
let clip_count = unsafe { oakengine_sequence_clip_count(seq, track_type, track_index as c_int) };
|
||||
let clip_count =
|
||||
unsafe { oakengine_sequence_clip_count(seq, track_type, track_index as c_int) };
|
||||
let mut clips = Vec::with_capacity(clip_count.max(0) as usize);
|
||||
for clip_index in 0..clip_count.max(0) {
|
||||
let clip = unsafe { oakengine_sequence_clip_at(seq, track_type, track_index as c_int, clip_index) };
|
||||
let clip = unsafe {
|
||||
oakengine_sequence_clip_at(seq, track_type, track_index as c_int, clip_index)
|
||||
};
|
||||
if clip.is_null() {
|
||||
continue;
|
||||
}
|
||||
@@ -1011,7 +1042,11 @@ impl RealEngine {
|
||||
free_box(clip);
|
||||
}
|
||||
clips.push(RealClip {
|
||||
id: ClipId((track_type as u64) * 1_000_000 + (track_index as u64 + 1) * 1000 + clip_index as u64),
|
||||
id: ClipId(
|
||||
(track_type as u64) * 1_000_000
|
||||
+ (track_index as u64 + 1) * 1000
|
||||
+ clip_index as u64,
|
||||
),
|
||||
range: FrameRange::new(Frame(in_ts), Frame(out_ts)),
|
||||
media_in: Frame(media_in),
|
||||
label: format!("Clip {}", clip_index + 1).into(),
|
||||
@@ -1050,8 +1085,9 @@ impl RealEngine {
|
||||
let count = unsafe { oakengine_project_footage_count(project) };
|
||||
let mut children = Vec::new();
|
||||
for i in 0..count.max(0) {
|
||||
let filename =
|
||||
read_string(|buf, size| unsafe { oakengine_project_footage_filename(project, i, buf, size) });
|
||||
let filename = read_string(|buf, size| unsafe {
|
||||
oakengine_project_footage_filename(project, i, buf, size)
|
||||
});
|
||||
let label = Path::new(&filename)
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().into_owned())
|
||||
@@ -1110,8 +1146,12 @@ impl RealEngine {
|
||||
let mut buf = [0 as c_char; 64];
|
||||
let mut element: c_int = 0;
|
||||
unsafe {
|
||||
oakengine_node_get_effect_input(node, buf.as_mut_ptr(), buf.len() as c_int, &mut element)
|
||||
>= 0
|
||||
oakengine_node_get_effect_input(
|
||||
node,
|
||||
buf.as_mut_ptr(),
|
||||
buf.len() as c_int,
|
||||
&mut element,
|
||||
) >= 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1236,7 +1276,6 @@ impl RealEngine {
|
||||
text
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1501,7 +1540,8 @@ impl AppEngine for RealEngine {
|
||||
let Some(seq) = self.seq_ptr() else {
|
||||
return;
|
||||
};
|
||||
let internal = unsafe { oakengine_track_height_pixels_to_internal(f32::from(height) as c_int) };
|
||||
let internal =
|
||||
unsafe { oakengine_track_height_pixels_to_internal(f32::from(height) as c_int) };
|
||||
let mut video: c_int = 0;
|
||||
let mut audio: c_int = 0;
|
||||
let mut subtitle: c_int = 0;
|
||||
@@ -1561,14 +1601,23 @@ impl AppEngine for RealEngine {
|
||||
// facade two-stage convention.
|
||||
unsafe { oakengine_node_factory_name_from_id(c_id.as_ptr(), buf, size) }
|
||||
});
|
||||
let name = if name.is_empty() { type_id.clone() } else { name };
|
||||
let name = if name.is_empty() {
|
||||
type_id.clone()
|
||||
} else {
|
||||
name
|
||||
};
|
||||
out.push((type_id, name));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn add_effect(&mut self, index: usize, type_id: &str, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
fn add_effect(
|
||||
&mut self,
|
||||
index: usize,
|
||||
type_id: &str,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
let Some(host) = self.selected_clip_node() else {
|
||||
return Err("no selected clip".into());
|
||||
};
|
||||
@@ -1688,7 +1737,11 @@ impl AppEngine for RealEngine {
|
||||
self.request_frame(Monitor::Program, *frame, cx);
|
||||
}
|
||||
}
|
||||
TimelineEvent::ClipTrimRequested { clip, edge, new_frame } => {
|
||||
TimelineEvent::ClipTrimRequested {
|
||||
clip,
|
||||
edge,
|
||||
new_frame,
|
||||
} => {
|
||||
let Some((track_type, track_index, clip_index)) = self.clip_coords(*clip) else {
|
||||
return;
|
||||
};
|
||||
@@ -1722,13 +1775,39 @@ impl AppEngine for RealEngine {
|
||||
unsafe { free_box(clip_ptr) };
|
||||
self.apply_edit(rc, "trim clip", cx);
|
||||
}
|
||||
TimelineEvent::ClipMoveRequested { clip, .. } => {
|
||||
// `oakengine_sequence_move_clip` is a documented facade stub
|
||||
// (module gap); report the facade error.
|
||||
let coords = self.clip_coords(*clip);
|
||||
let what = format!("move clip ({coords:?})");
|
||||
println!("[real engine] {what}: not supported by the facade (stub)");
|
||||
cx.notify();
|
||||
TimelineEvent::ClipMoveRequested {
|
||||
clip,
|
||||
new_track,
|
||||
new_start,
|
||||
} => {
|
||||
// `oakengine_sequence_move_clip` moves the clip on its own
|
||||
// track (the capi passes the source track straight through to
|
||||
// the place command); a cross-track drag is not expressible
|
||||
// through that signature.
|
||||
let Some((track_type, track_index, clip_index)) = self.clip_coords(*clip) else {
|
||||
return;
|
||||
};
|
||||
let Some(seq) = self.seq_ptr() else {
|
||||
return;
|
||||
};
|
||||
if *new_track as c_int != track_index as c_int {
|
||||
println!(
|
||||
"[real engine] move clip ({track_type:?},{track_index},{clip_index}): \
|
||||
cross-track moves are not supported by the facade"
|
||||
);
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
let rc = unsafe {
|
||||
oakengine_sequence_move_clip(
|
||||
seq,
|
||||
Self::track_type_of(track_type),
|
||||
track_index as c_int,
|
||||
clip_index as c_int,
|
||||
new_start.0,
|
||||
)
|
||||
};
|
||||
self.apply_edit(rc, "move clip", cx);
|
||||
}
|
||||
TimelineEvent::TrackHeightChanged { track, height } => {
|
||||
if let Some(t) = self.tracks.get(*track) {
|
||||
@@ -1853,7 +1932,15 @@ impl AppEngine for RealEngine {
|
||||
unsafe { free_box(clip_ptr) };
|
||||
rc
|
||||
};
|
||||
self.apply_edit(rc, if ripple { "ripple delete clip" } else { "delete clip" }, cx);
|
||||
self.apply_edit(
|
||||
rc,
|
||||
if ripple {
|
||||
"ripple delete clip"
|
||||
} else {
|
||||
"delete clip"
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn can_undo(&self) -> bool {
|
||||
@@ -1923,7 +2010,11 @@ impl AppEngine for RealEngine {
|
||||
}
|
||||
}
|
||||
|
||||
fn save_project(&mut self, path: Option<PathBuf>, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
fn save_project(
|
||||
&mut self,
|
||||
path: Option<PathBuf>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
if self.project_ptr().is_none() {
|
||||
return Err("no project open".into());
|
||||
}
|
||||
@@ -1977,7 +2068,13 @@ impl AppEngine for RealEngine {
|
||||
let mut rate_num: c_int = 25;
|
||||
let mut rate_den: c_int = 1;
|
||||
unsafe {
|
||||
oakengine_sequence_get_video_params(seq, &mut width, &mut height, &mut par_num, &mut par_den);
|
||||
oakengine_sequence_get_video_params(
|
||||
seq,
|
||||
&mut width,
|
||||
&mut height,
|
||||
&mut par_num,
|
||||
&mut par_den,
|
||||
);
|
||||
oakengine_sequence_get_frame_rate(seq, &mut rate_num, &mut rate_den);
|
||||
}
|
||||
let video_codec = unsafe { oakengine_encoding_format_video_codec_at(format, 0) };
|
||||
@@ -2037,7 +2134,8 @@ impl AppEngine for RealEngine {
|
||||
}
|
||||
|
||||
// Progress events through the module task callback.
|
||||
let module_handle = unsafe { unbox(task) }.ok_or_else(|| "invalid export task handle".to_string())?;
|
||||
let module_handle =
|
||||
unsafe { unbox(task) }.ok_or_else(|| "invalid export task handle".to_string())?;
|
||||
let (tx, rx) = mpsc::channel::<ExportEvent>();
|
||||
let cb_userdata = SendPtr(Box::into_raw(Box::new(tx.clone())));
|
||||
unsafe {
|
||||
@@ -2131,7 +2229,14 @@ impl RealEngine {
|
||||
return Err("invalid project path".into());
|
||||
};
|
||||
let mut err = [0 as c_char; 4096];
|
||||
let rc = unsafe { oakengine_project_load(project, cpath.as_ptr(), err.as_mut_ptr(), err.len() as c_int) };
|
||||
let rc = unsafe {
|
||||
oakengine_project_load(
|
||||
project,
|
||||
cpath.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) };
|
||||
@@ -2184,7 +2289,10 @@ impl RealEngine {
|
||||
self.adopt_project(project, cx);
|
||||
Ok(())
|
||||
}
|
||||
None => Err(format!("loaded \"{}\" but the loader returned no project", path.display())),
|
||||
None => Err(format!(
|
||||
"loaded \"{}\" but the loader returned no project",
|
||||
path.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2195,7 +2303,10 @@ impl RealEngine {
|
||||
return Err("no project open".into());
|
||||
};
|
||||
let cpath = path.and_then(cstr_path);
|
||||
let ptr = cpath.as_ref().map(|c| c.as_ptr()).unwrap_or(std::ptr::null());
|
||||
let ptr = cpath
|
||||
.as_ref()
|
||||
.map(|c| c.as_ptr())
|
||||
.unwrap_or(std::ptr::null());
|
||||
let rc = unsafe { oakengine_project_save(project, ptr) };
|
||||
if rc != 0 {
|
||||
return Err(format!("failed to save the project (error {rc})"));
|
||||
@@ -2205,7 +2316,8 @@ impl RealEngine {
|
||||
if !name.is_empty() {
|
||||
self.project_info.name = name;
|
||||
}
|
||||
let filename = read_string(|buf, size| unsafe { oakengine_project_filename(project, buf, size) }) ;
|
||||
let filename =
|
||||
read_string(|buf, size| unsafe { oakengine_project_filename(project, buf, size) });
|
||||
if !filename.is_empty() {
|
||||
self.project_info.path = PathBuf::from(filename);
|
||||
}
|
||||
@@ -2250,7 +2362,8 @@ pub fn encoding_formats() -> Vec<(c_int, String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for i in 0..count.max(0) {
|
||||
let name = read_string(|buf, size| unsafe { oakengine_encoding_format_name(i, buf, size) });
|
||||
let ext = read_string(|buf, size| unsafe { oakengine_encoding_format_extension(i, buf, size) });
|
||||
let ext =
|
||||
read_string(|buf, size| unsafe { oakengine_encoding_format_extension(i, buf, size) });
|
||||
out.push((i, name, ext));
|
||||
}
|
||||
out
|
||||
@@ -2298,20 +2411,41 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn project_format_dispatches_by_extension() {
|
||||
assert_eq!(ProjectFormat::of(&PathBuf::from("/tmp/x.ove")), ProjectFormat::Ove);
|
||||
assert_eq!(ProjectFormat::of(&PathBuf::from("/tmp/x.ovexml")), ProjectFormat::Ove);
|
||||
assert_eq!(ProjectFormat::of(&PathBuf::from("/tmp/x.OTIO")), ProjectFormat::Otio);
|
||||
assert_eq!(ProjectFormat::of(&PathBuf::from("/tmp/x.fcpxml")), ProjectFormat::Fcpxml);
|
||||
assert_eq!(
|
||||
ProjectFormat::of(&PathBuf::from("/tmp/x.ove")),
|
||||
ProjectFormat::Ove
|
||||
);
|
||||
assert_eq!(
|
||||
ProjectFormat::of(&PathBuf::from("/tmp/x.ovexml")),
|
||||
ProjectFormat::Ove
|
||||
);
|
||||
assert_eq!(
|
||||
ProjectFormat::of(&PathBuf::from("/tmp/x.OTIO")),
|
||||
ProjectFormat::Otio
|
||||
);
|
||||
assert_eq!(
|
||||
ProjectFormat::of(&PathBuf::from("/tmp/x.fcpxml")),
|
||||
ProjectFormat::Fcpxml
|
||||
);
|
||||
// Unknown extensions fall back to the OVE serializer.
|
||||
assert_eq!(ProjectFormat::of(&PathBuf::from("/tmp/x.xml")), ProjectFormat::Ove);
|
||||
assert_eq!(ProjectFormat::of(&PathBuf::from("/tmp/x")), ProjectFormat::Ove);
|
||||
assert_eq!(
|
||||
ProjectFormat::of(&PathBuf::from("/tmp/x.xml")),
|
||||
ProjectFormat::Ove
|
||||
);
|
||||
assert_eq!(
|
||||
ProjectFormat::of(&PathBuf::from("/tmp/x")),
|
||||
ProjectFormat::Ove
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renderer_backends_list_is_stable() {
|
||||
let backends = renderer_backends();
|
||||
assert!(backends.len() >= 2);
|
||||
assert!(backends.contains(&"opengl"), "the default backend is offered");
|
||||
assert!(
|
||||
backends.contains(&"opengl"),
|
||||
"the default backend is offered"
|
||||
);
|
||||
}
|
||||
|
||||
/// End-to-end through the facade: a project the engine itself writes
|
||||
@@ -2336,8 +2470,14 @@ mod tests {
|
||||
let name = CString::new("Round Trip").unwrap();
|
||||
let sequence = unsafe { oakengine_sequence_new(project, name.as_ptr()) };
|
||||
assert!(!sequence.is_null());
|
||||
assert_eq!(unsafe { oakengine_sequence_add_track(sequence, TRACK_TYPE_VIDEO) }, 0);
|
||||
assert_eq!(unsafe { oakengine_sequence_add_track(sequence, TRACK_TYPE_AUDIO) }, 0);
|
||||
assert_eq!(
|
||||
unsafe { oakengine_sequence_add_track(sequence, TRACK_TYPE_VIDEO) },
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oakengine_sequence_add_track(sequence, TRACK_TYPE_AUDIO) },
|
||||
0
|
||||
);
|
||||
let mut video: c_int = -1;
|
||||
let mut audio: c_int = -1;
|
||||
let mut subtitle: c_int = -1;
|
||||
@@ -2351,8 +2491,14 @@ mod tests {
|
||||
let save_path =
|
||||
std::env::temp_dir().join(format!("oakapp_roundtrip_{}.ovexml", std::process::id()));
|
||||
let cpath = CString::new(save_path.to_string_lossy().into_owned()).unwrap();
|
||||
assert_eq!(unsafe { oakengine_project_set_filename(project, cpath.as_ptr()) }, 0);
|
||||
assert_eq!(unsafe { oakengine_project_save(project, cpath.as_ptr()) }, 0);
|
||||
assert_eq!(
|
||||
unsafe { oakengine_project_set_filename(project, cpath.as_ptr()) },
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oakengine_project_save(project, cpath.as_ptr()) },
|
||||
0
|
||||
);
|
||||
assert!(save_path.exists());
|
||||
unsafe { free_box(sequence) };
|
||||
unsafe { oakengine_project_free(project) };
|
||||
@@ -2363,12 +2509,16 @@ mod tests {
|
||||
assert!(!project2.is_null());
|
||||
let mut err = [0 as c_char; 4096];
|
||||
let rc = unsafe {
|
||||
oakengine_project_load(project2, cpath.as_ptr(), err.as_mut_ptr(), err.len() as c_int)
|
||||
oakengine_project_load(
|
||||
project2,
|
||||
cpath.as_ptr(),
|
||||
err.as_mut_ptr(),
|
||||
err.len() as c_int,
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, 0, "project loads: {}", load_error(&mut err));
|
||||
let loaded_name = read_string(|buf, size| unsafe {
|
||||
oakengine_project_name(project2, buf, size)
|
||||
});
|
||||
let loaded_name =
|
||||
read_string(|buf, size| unsafe { oakengine_project_name(project2, buf, size) });
|
||||
assert!(!loaded_name.is_empty(), "the loaded project has a name");
|
||||
|
||||
unsafe { oakengine_project_free(project2) };
|
||||
@@ -2400,7 +2550,15 @@ mod tests {
|
||||
// The app's proxy size: sequence aspect (default 1920x1080) scaled
|
||||
// to a 480px long edge, F32 at 25 fps.
|
||||
let renderer = unsafe {
|
||||
oakengine_renderer_create(sequence, 480, 270, PIXEL_FORMAT_F32, 25, 1, std::ptr::null())
|
||||
oakengine_renderer_create(
|
||||
sequence,
|
||||
480,
|
||||
270,
|
||||
PIXEL_FORMAT_F32,
|
||||
25,
|
||||
1,
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
assert!(!renderer.is_null(), "renderer_create must succeed");
|
||||
|
||||
@@ -2421,7 +2579,10 @@ mod tests {
|
||||
for &(x, y) in &[(0usize, 0usize), (240, 135), (479, 269)] {
|
||||
let base = y * stride + x * 4;
|
||||
let px = unsafe { std::slice::from_raw_parts(data.add(base), 4) };
|
||||
assert!(px.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)), "samples in range: {px:?}");
|
||||
assert!(
|
||||
px.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)),
|
||||
"samples in range: {px:?}"
|
||||
);
|
||||
nonzero += px.iter().filter(|&&v| v != 0.0).count();
|
||||
}
|
||||
// Document the current empty-sequence behavior: a transparent-black
|
||||
|
||||
+3
-1
@@ -151,6 +151,8 @@ mod tests {
|
||||
|
||||
// Neutral gray chroma projects to the vectorscope's center.
|
||||
let points = gpui_widgets::scopes::vectorscope_points(&data.chroma);
|
||||
assert!(points.iter().all(|&(u, v)| u.abs() < 1e-6 && v.abs() < 1e-6));
|
||||
assert!(points
|
||||
.iter()
|
||||
.all(|&(u, v)| u.abs() < 1e-6 && v.abs() < 1e-6));
|
||||
}
|
||||
}
|
||||
|
||||
+21
-28
@@ -27,11 +27,11 @@
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::dock::{DockPanel, PanelEvent};
|
||||
use gpui::node_graph::{
|
||||
MAX_ZOOM, MIN_ZOOM, NodeData, NodeElement, NodeGraphEvent, NodeGraphView, NodeVisualState,
|
||||
NodeData, NodeElement, NodeGraphEvent, NodeGraphView, NodeVisualState, MAX_ZOOM, MIN_ZOOM,
|
||||
};
|
||||
use gpui::{
|
||||
div, point, prelude::*, px, AnyElement, App, Bounds, ClickEvent, Context, Entity,
|
||||
EventEmitter, Pixels, Render, SharedString, Window,
|
||||
div, point, prelude::*, px, AnyElement, App, Bounds, ClickEvent, Context, Entity, EventEmitter,
|
||||
Pixels, Render, SharedString, Window,
|
||||
};
|
||||
|
||||
use crate::oakui::AppEngine;
|
||||
@@ -50,11 +50,7 @@ pub struct NodeEditorPanel<E: AppEngine> {
|
||||
impl<E: AppEngine> NodeEditorPanel<E> {
|
||||
/// Builds the graph canvas over `engine` and routes its edit requests back
|
||||
/// to the engine.
|
||||
pub fn new(
|
||||
engine: Entity<E>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
pub fn new(engine: Entity<E>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let graph = cx.new(|cx| NodeGraphView::new(engine.clone(), window, cx));
|
||||
// The "edits are requests" loop: every graph gesture goes to the
|
||||
// engine, which applies it to its model and notifies.
|
||||
@@ -189,16 +185,12 @@ impl<E: AppEngine> Render for NodeEditorPanel<E> {
|
||||
|this, window, cx| this.fit_graph(window, cx),
|
||||
))
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
div()
|
||||
.text_color(colors.disabled)
|
||||
.child(format!(
|
||||
"{}% · {}–{}",
|
||||
(self.graph.read(cx).state().zoom() * 100.0).round(),
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM,
|
||||
)),
|
||||
),
|
||||
.child(div().text_color(colors.disabled).child(format!(
|
||||
"{}% · {}–{}",
|
||||
(self.graph.read(cx).state().zoom() * 100.0).round(),
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM,
|
||||
))),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
@@ -243,16 +235,13 @@ fn zoom_button<E: AppEngine>(
|
||||
action(this, window, cx);
|
||||
}));
|
||||
if let Some(name) = icon_name {
|
||||
el = el.child(
|
||||
gpui::img(crate::oakui::icons::icon_path(name, cx)).size(px(16.0)),
|
||||
);
|
||||
el = el.child(gpui::img(crate::oakui::icons::icon_path(name, cx)).size(px(16.0)));
|
||||
} else {
|
||||
el = el.child(label);
|
||||
}
|
||||
el
|
||||
}
|
||||
|
||||
|
||||
impl<E: AppEngine> EventEmitter<PanelEvent> for NodeEditorPanel<E> {}
|
||||
|
||||
impl<E: AppEngine> DockPanel for NodeEditorPanel<E> {
|
||||
@@ -275,13 +264,16 @@ impl<E: AppEngine> DockPanel for NodeEditorPanel<E> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::oakui::MockEngine;
|
||||
use gpui::{TestAppContext, VisualTestContext, size};
|
||||
use gpui::{size, TestAppContext, VisualTestContext};
|
||||
|
||||
/// Builds the panel in a window and returns a `VisualTestContext` for
|
||||
/// bounds assertions.
|
||||
fn panel_window(
|
||||
cx: &mut TestAppContext,
|
||||
) -> (&'static mut VisualTestContext, Entity<NodeEditorPanel<MockEngine>>) {
|
||||
) -> (
|
||||
&'static mut VisualTestContext,
|
||||
Entity<NodeEditorPanel<MockEngine>>,
|
||||
) {
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(640.0), px(480.0)), |window, cx| {
|
||||
let engine = cx.new(|cx| crate::oakui::MockEngine::demo(cx));
|
||||
@@ -316,10 +308,11 @@ mod tests {
|
||||
|
||||
// The initial fit moved the viewport off the default origin, so the
|
||||
// graph is framed rather than clipped at the corner.
|
||||
let state = cx.read(|app| {
|
||||
panel.read(app).graph.read(app).state().clone()
|
||||
});
|
||||
assert_ne!(state.offset(), gpui::node_graph::GraphViewState::new().offset());
|
||||
let state = cx.read(|app| panel.read(app).graph.read(app).state().clone());
|
||||
assert_ne!(
|
||||
state.offset(),
|
||||
gpui::node_graph::GraphViewState::new().offset()
|
||||
);
|
||||
assert!(state.zoom() > 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +138,10 @@ impl<E: AppEngine> ProgramViewerPanel<E> {
|
||||
/// playhead frame, with the scope samples analyzed in the same pass).
|
||||
fn sync_frame(&mut self, cx: &mut Context<Self>) {
|
||||
let frame = self.engine.read(cx).cpu_frame(Monitor::Program, cx);
|
||||
if self.last_cpu_frame.as_ref().is_none_or(|last| !std::sync::Arc::ptr_eq(last, &frame))
|
||||
if self
|
||||
.last_cpu_frame
|
||||
.as_ref()
|
||||
.is_none_or(|last| !std::sync::Arc::ptr_eq(last, &frame))
|
||||
{
|
||||
self.last_cpu_frame = Some(frame.clone());
|
||||
let scope = self.engine.read(cx).scope_data(Monitor::Program, cx);
|
||||
@@ -170,7 +173,11 @@ impl<E: AppEngine> ProgramViewerPanel<E> {
|
||||
.rounded_sm()
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(if active { colors.selected } else { colors.container })
|
||||
.bg(if active {
|
||||
colors.selected
|
||||
} else {
|
||||
colors.container
|
||||
})
|
||||
.text_color(colors.text)
|
||||
.cursor_pointer()
|
||||
.child(label)
|
||||
|
||||
@@ -20,8 +20,7 @@
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::dock::{DockPanel, PanelEvent};
|
||||
use gpui::{
|
||||
div, prelude::*, AnyElement, App, Context, Entity, EventEmitter, Render, SharedString,
|
||||
Window,
|
||||
div, prelude::*, AnyElement, App, Context, Entity, EventEmitter, Render, SharedString, Window,
|
||||
};
|
||||
use gpui_widgets::viewer::{ViewerEvent, ViewerWidget};
|
||||
|
||||
@@ -71,7 +70,10 @@ impl<E: AppEngine> SourceViewerPanel<E> {
|
||||
/// it actually changed (the engine caches one image per playhead frame).
|
||||
fn sync_frame(&mut self, cx: &mut Context<Self>) {
|
||||
let frame = self.engine.read(cx).cpu_frame(Monitor::Source, cx);
|
||||
if self.last_cpu_frame.as_ref().is_none_or(|last| !std::sync::Arc::ptr_eq(last, &frame))
|
||||
if self
|
||||
.last_cpu_frame
|
||||
.as_ref()
|
||||
.is_none_or(|last| !std::sync::Arc::ptr_eq(last, &frame))
|
||||
{
|
||||
self.last_cpu_frame = Some(frame.clone());
|
||||
let frame = frame.clone();
|
||||
|
||||
@@ -75,18 +75,9 @@ impl<E: AppEngine> Render for StatusBar<E> {
|
||||
.bg(colors.container)
|
||||
.text_xs()
|
||||
.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(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().flex_1())
|
||||
.child(segment(
|
||||
&colors,
|
||||
@@ -102,15 +93,10 @@ impl<E: AppEngine> Render for StatusBar<E> {
|
||||
format_resolution(format.width, format.height),
|
||||
))
|
||||
.child(div().px_2().text_color(colors.disabled).child(project))
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
.text_color(colors.disabled)
|
||||
.child(format!(
|
||||
"{} · {}",
|
||||
crate::i18n::tr("status.backend"),
|
||||
self.engine.read(cx).backend_name(),
|
||||
)),
|
||||
)
|
||||
.child(div().px_2().text_color(colors.disabled).child(format!(
|
||||
"{} · {}",
|
||||
crate::i18n::tr("status.backend"),
|
||||
self.engine.read(cx).backend_name(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
+38
-40
@@ -72,7 +72,10 @@ const TOOLS: [(&str, &str); 8] = [
|
||||
("timeline.tool.roll", crate::oakui::icons::ICON_ROLLING),
|
||||
("timeline.tool.zoom", crate::oakui::icons::ICON_ZOOM),
|
||||
("timeline.tool.slide", crate::oakui::icons::ICON_SLIDE),
|
||||
("timeline.tool.track_select", crate::oakui::icons::ICON_TRACK_SELECT),
|
||||
(
|
||||
"timeline.tool.track_select",
|
||||
crate::oakui::icons::ICON_TRACK_SELECT,
|
||||
),
|
||||
];
|
||||
|
||||
/// The timeline panel.
|
||||
@@ -111,8 +114,7 @@ impl<E: AppEngine> TimelinePanel<E> {
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let snap =
|
||||
cx.new(|cx| CheckBox::new(12, CheckState::Checked, window, cx));
|
||||
let snap = cx.new(|cx| CheckBox::new(12, CheckState::Checked, window, cx));
|
||||
|
||||
// Zoom slider → timeline zoom (pixels per frame).
|
||||
cx.subscribe(&zoom, |this, _zoom, event: &SliderEvent, cx| {
|
||||
@@ -191,38 +193,34 @@ impl<E: AppEngine> Render for TimelinePanel<E> {
|
||||
|
||||
// A tool button: a 16px icon on a 24px hit target with a localized
|
||||
// tooltip; the selected tool is highlighted.
|
||||
let tool_button = |index: usize,
|
||||
icon_name: &'static str,
|
||||
key: &'static str,
|
||||
cx: &mut Context<Self>| {
|
||||
let tool = i18n::tr(key);
|
||||
let selected = self.selected_tool == index;
|
||||
let background = if selected {
|
||||
colors.selected
|
||||
} else {
|
||||
colors.background
|
||||
};
|
||||
let hover_bg = colors.selected;
|
||||
let path = icons::icon_path(icon_name, cx);
|
||||
div()
|
||||
.id(SharedString::from(format!("tool-{index}")))
|
||||
.size(px(24.0))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_sm()
|
||||
.cursor_pointer()
|
||||
.bg(background)
|
||||
.hover(move |style| style.bg(hover_bg))
|
||||
.tooltip(move |window, cx| tooltip_view(tool.into(), window, cx))
|
||||
.on_click(
|
||||
cx.listener(move |this, _event: &ClickEvent, _window, _cx| {
|
||||
let tool_button =
|
||||
|index: usize, icon_name: &'static str, key: &'static str, cx: &mut Context<Self>| {
|
||||
let tool = i18n::tr(key);
|
||||
let selected = self.selected_tool == index;
|
||||
let background = if selected {
|
||||
colors.selected
|
||||
} else {
|
||||
colors.background
|
||||
};
|
||||
let hover_bg = colors.selected;
|
||||
let path = icons::icon_path(icon_name, cx);
|
||||
div()
|
||||
.id(SharedString::from(format!("tool-{index}")))
|
||||
.size(px(24.0))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_sm()
|
||||
.cursor_pointer()
|
||||
.bg(background)
|
||||
.hover(move |style| style.bg(hover_bg))
|
||||
.tooltip(move |window, cx| tooltip_view(tool.into(), window, cx))
|
||||
.on_click(cx.listener(move |this, _event: &ClickEvent, _window, _cx| {
|
||||
println!("[timeline] tool: {tool} (placeholder)");
|
||||
this.selected_tool = index;
|
||||
}),
|
||||
)
|
||||
.child(img(path).size(px(16.0)))
|
||||
};
|
||||
}))
|
||||
.child(img(path).size(px(16.0)))
|
||||
};
|
||||
|
||||
for (index, (tool_key, icon_name)) in TOOLS.iter().enumerate() {
|
||||
toolbar = toolbar.child(tool_button(index, icon_name, tool_key, cx));
|
||||
@@ -269,9 +267,7 @@ impl<E: AppEngine> Render for TimelinePanel<E> {
|
||||
.tooltip(move |window, cx| {
|
||||
tooltip_view(i18n::tr("timeline.snap").into(), window, cx)
|
||||
})
|
||||
.child(
|
||||
img(icons::icon_path(icons::ICON_SNAP, cx)).size(px(16.0)),
|
||||
),
|
||||
.child(img(icons::icon_path(icons::ICON_SNAP, cx)).size(px(16.0))),
|
||||
)
|
||||
.child(self.snap.clone());
|
||||
|
||||
@@ -377,7 +373,7 @@ impl<E: AppEngine> DockPanel for TimelinePanel<E> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::oakui::MockEngine;
|
||||
use gpui::{TestAppContext, VisualTestContext, px, size};
|
||||
use gpui::{px, size, TestAppContext, VisualTestContext};
|
||||
|
||||
/// Builds a `TimelinePanel` in a window of the given logical size and
|
||||
/// returns a `VisualTestContext` for bounds assertions.
|
||||
@@ -385,12 +381,14 @@ mod tests {
|
||||
cx: &mut TestAppContext,
|
||||
width: f32,
|
||||
height: f32,
|
||||
) -> (&'static mut VisualTestContext, Entity<TimelinePanel<MockEngine>>) {
|
||||
) -> (
|
||||
&'static mut VisualTestContext,
|
||||
Entity<TimelinePanel<MockEngine>>,
|
||||
) {
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(width), px(height)), |window, cx| {
|
||||
let engine = cx.new(|cx| crate::oakui::MockEngine::demo(cx));
|
||||
let timeline =
|
||||
cx.new(|cx| TimelineView::new(engine.clone(), window, cx).zoom(2.0));
|
||||
let timeline = cx.new(|cx| TimelineView::new(engine.clone(), window, cx).zoom(2.0));
|
||||
TimelinePanel::new(engine, timeline, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
Reference in New Issue
Block a user