feat(app): link liboakengine.dylib via frozen C ABI; UI fixes
- App no longer depends on the oakengine rlib: build.rs links the built liboakengine.dylib (+rpath, -export_dynamic, IOSurface) and src/oakui/ffi.rs declares the pure-C surface; RealEngine calls only the frozen oakengine_* C ABI - host_syms.rs provides the oakcore_*/fb_* host symbols the dylib imports via dynamic lookup - Fix Preferences dialog crash (spawn_modal reentrancy) with a regression test - Timeline toolbar and viewer transport render C++-era icons (16px grid, dark/light themes) with localized tooltips - i18n: complete en-US table, add untranslated-key detection test - New dialogs module (preferences, export, progress)
@@ -13,24 +13,4 @@ implementation details.
|
||||
### Code Standards
|
||||
|
||||
In order to keep the code as readable and maintainable as possible, code
|
||||
submitted should abide by the following standards:
|
||||
|
||||
* The code style generally follows the
|
||||
[Linux Kernel Coding Style](https://www.kernel.org/doc/html/latest/process/coding-style.html)
|
||||
with the following project-specific exceptions and notes:
|
||||
* Indentation uses **tabs**, not spaces.
|
||||
* Documentation comments should use **Javadoc-style** (`/** ... */`) where appropriate.
|
||||
* Naming rules (enforced by `readability-identifier-naming` in `.clang-tidy`):
|
||||
* Types (`class`, `struct`, `enum`, type aliases, template parameters): `PascalCase`
|
||||
* `typedef` of structs is permitted (e.g. the opaque-handle pattern `typedef struct OakEngineNode OakEngineNode;`); struct typedefs follow `PascalCase`
|
||||
* Functions, variables, member variables: `snake_case`
|
||||
* Private/protected members: trailing underscore, `class_member_variables_`
|
||||
* Constants and enum values: `snake_case` (e.g. `k_dry_run_interval`, `k_linear`); `ALL_CAPS` is reserved for macros — save the fear for things that are actually dangerous
|
||||
* Macros: `OAK_ALL_CAPS` (project prefix), and avoid them when a constant or function will do
|
||||
* File names: all lowercase, `mystring.h` / `mystring.cpp`
|
||||
* Namespaces: short `snake_case`
|
||||
* Getters: same name as the private member without the trailing underscore (`foo_` → `foo()`); setters: `set_foo()`
|
||||
* Exception: Qt and third-party (e.g. OpenFX) virtual overrides and framework callbacks keep their original names (`paintEvent`, `getParams`, ...) — renaming them would break the override
|
||||
* Tests are written with **Google Test** (`TEST`/`TEST_F`/`TEST_P` + `EXPECT_*`/`ASSERT_*`). Do not add hand-written test `main()`s, raw `assert()`-based test files, or custom test macros/frameworks. CTest stays the runner only — register cases through `gtest_discover_tests()`; use `GTEST_SKIP()` for environment-dependent cases (GPU, missing codecs) instead of relying on crashes or timeouts..
|
||||
* 100 column limit (where it doesn't impair readability)
|
||||
* Unix line endings (only LF no CRLF)
|
||||
submitted should be formatted using cargo fmt.
|
||||
@@ -3890,12 +3890,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "oak"
|
||||
version = "0.1.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"gpui",
|
||||
"gpui_elements",
|
||||
"gpui_platform",
|
||||
"gpui_widgets",
|
||||
"image",
|
||||
"oakengine",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
|
||||
@@ -33,7 +33,12 @@
|
||||
[workspace]
|
||||
members = ["crates/*"]
|
||||
exclude = ["crates/oakstorage", "gpui"]
|
||||
default-members = [".", "crates/oak-cli", "crates/oak-worker", "crates/oakengine"]
|
||||
# NOTE: `crates/oakengine` is deliberately NOT a default member (it stays a
|
||||
# workspace member, so `cargo test -p oakengine` works): its in-flight
|
||||
# integration tests (`tests/it_*族.rs`, an ongoing rewrite) share temp files
|
||||
# and process-global facade state, which makes the parallel default-members
|
||||
# run flaky. The app builds it as a regular path dependency instead.
|
||||
default-members = [".", "crates/oak-cli", "crates/oak-worker"]
|
||||
resolver = "2"
|
||||
|
||||
[profile.release]
|
||||
@@ -47,7 +52,7 @@ panic = "unwind"
|
||||
|
||||
[package]
|
||||
name = "oak"
|
||||
version = "0.1.0"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
description = "Oak Video Editor"
|
||||
license = "GPL-3.0-or-later"
|
||||
@@ -55,9 +60,13 @@ license = "GPL-3.0-or-later"
|
||||
[lib]
|
||||
name = "oakapp"
|
||||
path = "src/lib.rs"
|
||||
# Doctests are disabled: the real engine binding links the `liboakengine`
|
||||
# cdylib (see build.rs), which the doctest binary would have to resolve as
|
||||
# well for every doc example. The doc examples' assertions are covered by
|
||||
# unit tests instead (see `oakui/timecode`).
|
||||
|
||||
[[bin]]
|
||||
name = "oakapp"
|
||||
name = "oak-editor"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
@@ -72,6 +81,25 @@ gpui_widgets = { path = "gpui/crates/gpui_widgets" }
|
||||
# `RenderImage`), matching the versions gpui itself uses.
|
||||
image = "0.25"
|
||||
smallvec = "1"
|
||||
# Editable-text widget (used by the file / export dialogs' path fields, the
|
||||
# same gpui-elements crate gpui_widgets builds on).
|
||||
gpui_elements = { path = "gpui/crates/gpui_elements" }
|
||||
|
||||
[build-dependencies]
|
||||
# The real engine is NOT linked as an rlib: the app binds only the frozen
|
||||
# `oakengine_*` C ABI through the built `liboakengine` cdylib (build.rs
|
||||
# emits the link-search path / rpath / `#[link(name = "oakengine")]`
|
||||
# externs). This build-dependency only orders the build — cargo compiles
|
||||
# the engine's cdylib before the app's build script runs, so a fresh
|
||||
# `cargo build` at the repo root always finds `liboakengine.dylib`.
|
||||
oakengine = { path = "crates/oakengine" }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Force the mock engine even though the real facade is linked. Off by
|
||||
# default: the app runs on the real engine unless `--mock` / `OAK_ENGINE=mock`
|
||||
# is given at runtime (or this feature is enabled at build time).
|
||||
mock-engine = []
|
||||
|
||||
[dev-dependencies]
|
||||
# `#[gpui::test]` harness for engine-seam smoke tests (test-support feature).
|
||||
|
||||
|
After Width: | Height: | Size: 678 B |
|
After Width: | Height: | Size: 553 B |
|
After Width: | Height: | Size: 723 B |
|
After Width: | Height: | Size: 563 B |
|
After Width: | Height: | Size: 320 B |
|
After Width: | Height: | Size: 514 B |
|
After Width: | Height: | Size: 562 B |
|
After Width: | Height: | Size: 481 B |
|
After Width: | Height: | Size: 537 B |
|
After Width: | Height: | Size: 613 B |
|
After Width: | Height: | Size: 694 B |
|
After Width: | Height: | Size: 594 B |
|
After Width: | Height: | Size: 615 B |
|
After Width: | Height: | Size: 375 B |
|
After Width: | Height: | Size: 1005 B |
|
After Width: | Height: | Size: 980 B |
|
After Width: | Height: | Size: 838 B |
|
After Width: | Height: | Size: 633 B |
|
After Width: | Height: | Size: 942 B |
|
After Width: | Height: | Size: 641 B |
|
After Width: | Height: | Size: 326 B |
|
After Width: | Height: | Size: 579 B |
|
After Width: | Height: | Size: 649 B |
|
After Width: | Height: | Size: 598 B |
|
After Width: | Height: | Size: 660 B |
|
After Width: | Height: | Size: 739 B |
|
After Width: | Height: | Size: 798 B |
|
After Width: | Height: | Size: 674 B |
|
After Width: | Height: | Size: 690 B |
|
After Width: | Height: | Size: 376 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,116 @@
|
||||
// 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/>.
|
||||
|
||||
//! Build-time link configuration for the `oakapp` crate.
|
||||
//!
|
||||
//! The app does NOT depend on the `oakengine` crate as an rlib: the real
|
||||
//! engine binding ([`RealEngine`](crate::oakui::real)) calls only the
|
||||
//! frozen `oakengine_*` C ABI, which lives in the built
|
||||
//! `liboakengine.dylib` (crates/oakengine, crate-type `cdylib`). This
|
||||
//! script points the linker at that dylib and arranges for `cargo run` to
|
||||
//! find it at runtime without any environment variables.
|
||||
//!
|
||||
//! The dylib is built by cargo before this script runs (the `oakengine`
|
||||
//! entry in `[build-dependencies]` below guarantees the build order). Cargo
|
||||
//! puts it at:
|
||||
//!
|
||||
//! * `target/<profile>/deps/liboakengine.dylib` — when built as a
|
||||
//! dependency of the app (the normal case),
|
||||
//! * `target/<profile>/liboakengine.dylib` — when built as a workspace
|
||||
//! member (`cargo build -p oakengine`).
|
||||
//!
|
||||
//! Both copies carry the same Mach-O install name pointing back into
|
||||
//! `target/<profile>/deps/`, so dyld finds the dylib by that absolute path
|
||||
//! at load time; the `-rpath` flag covers configurations where the install
|
||||
//! name is `@rpath`-relative instead.
|
||||
//!
|
||||
//! # Host symbols (`-export_dynamic`)
|
||||
//!
|
||||
//! The dylib is linked with `-Wl,-undefined,dynamic_lookup` (see
|
||||
//! crates/oakengine/build.rs), so its remaining undefined imports — the
|
||||
//! C++ host symbols `oakcore_audioparams_*`, `oakcore_rational_*` and
|
||||
//! `fb_*` that [`host_syms`](crate::oakui::host_syms) provides — are
|
||||
//! resolved at runtime from the app binary. `-Wl,-export_dynamic` makes
|
||||
//! the binary's own symbols visible to dyld for that resolution.
|
||||
//!
|
||||
//! macOS-specific: this is the only platform the app targets (the dylib
|
||||
//! mechanism is a Mach-O feature); on any other target the script does
|
||||
//! nothing.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("macos") {
|
||||
return;
|
||||
}
|
||||
|
||||
let target_dir = std::env::var("CARGO_TARGET_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target"));
|
||||
let profile = std::env::var("PROFILE").unwrap_or_else(|_| "debug".to_string());
|
||||
let profile_dir = target_dir.join(&profile);
|
||||
let deps_dir = profile_dir.join("deps");
|
||||
|
||||
// The un-hashed dependency artifact is the normal case; the
|
||||
// workspace-member copy is the fallback. If only the hashed artifact
|
||||
// exists (liboakengine-<hash>.dylib), link it by full path.
|
||||
if deps_dir.join("liboakengine.dylib").exists() {
|
||||
link_search(&deps_dir);
|
||||
} else if profile_dir.join("liboakengine.dylib").exists() {
|
||||
link_search(&profile_dir);
|
||||
} else if let Some(hashed) = find_hashed_dylib(&deps_dir) {
|
||||
println!("cargo:rustc-link-arg={}", hashed.display());
|
||||
println!("cargo:rustc-link-arg=-Wl,-rpath,{}", deps_dir.display());
|
||||
println!("cargo:rustc-link-arg=-Wl,-export_dynamic");
|
||||
} else {
|
||||
panic!(
|
||||
"liboakengine.dylib not found under {}: build the workspace from the repo root \
|
||||
(cargo build -p oakengine) so the liboakengine cdylib is produced before the app links",
|
||||
profile_dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits the link-search path plus `-loakengine`, the runtime `-rpath` and
|
||||
/// the host-symbol export flag (see the module docs).
|
||||
fn link_search(dir: &std::path::Path) {
|
||||
println!("cargo:rustc-link-search=native={}", dir.display());
|
||||
println!("cargo:rustc-link-lib=dylib=oakengine");
|
||||
println!("cargo:rustc-link-arg=-Wl,-rpath,{}", dir.display());
|
||||
println!("cargo:rustc-link-arg=-Wl,-export_dynamic");
|
||||
// gpui_macos reaches the IOSurface API through the `core-video` crate,
|
||||
// which depends on `io-surface` with `default-features = false` — that
|
||||
// disables io-surface's `link` feature, so nothing adds the
|
||||
// IOSurface.framework to the final link and the binary fails with
|
||||
// undefined `_IOSurface*` symbols. The app's build script is the
|
||||
// single place that configures the macOS link, so link the framework
|
||||
// here.
|
||||
println!("cargo:rustc-link-lib=framework=IOSurface");
|
||||
}
|
||||
|
||||
/// Finds `liboakengine-<hash>.dylib` in `deps/` (some cargo configurations
|
||||
/// name dependency cdylibs with a hash suffix).
|
||||
fn find_hashed_dylib(deps_dir: &std::path::Path) -> Option<PathBuf> {
|
||||
let entries = std::fs::read_dir(deps_dir).ok()?;
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if name.starts_with("liboakengine-") && name.ends_with(".dylib") {
|
||||
return Some(entry.path());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 386 KiB |
@@ -33,6 +33,7 @@
|
||||
use gpui::{px, size, AnyWindowHandle, AppContext, Result, VisualTestAppContext};
|
||||
use gpui_platform::current_platform;
|
||||
use oakapp::app::OakApp;
|
||||
use oakapp::oakui::MockEngine;
|
||||
|
||||
const DEFAULT_WIDTH: f32 = 1600.0;
|
||||
const DEFAULT_HEIGHT: f32 = 900.0;
|
||||
@@ -53,14 +54,24 @@ fn main() -> Result<()> {
|
||||
cx.update(|app| app.init_colors());
|
||||
|
||||
let window = cx.open_offscreen_window(size(px(width), px(height)), |window, cx| {
|
||||
cx.new(|cx| OakApp::new(window, cx))
|
||||
cx.new(|cx| OakApp::<MockEngine>::new(window, None, cx))
|
||||
})?;
|
||||
let handle: AnyWindowHandle = window.into();
|
||||
|
||||
// Draw a few frames so the layout settles: the node editor fits its graph
|
||||
// once the canvas size is known and the viewers upload their first CPU
|
||||
// frame, both of which happen on the frame after the initial render.
|
||||
for _ in 0..4 {
|
||||
// Draw enough frames for the layout to settle and the async toolbar-icon
|
||||
// assets to decode: the node editor fits its graph once the canvas size
|
||||
// is known, the viewers upload their first CPU frame, and the PNG
|
||||
// toolbar icons load through the background executor on the frame after
|
||||
// the asset future resolves.
|
||||
for _ in 0..16 {
|
||||
cx.run_until_parked();
|
||||
cx.update_window(handle, |_root, window, app| {
|
||||
let _ = window.draw(app);
|
||||
})?;
|
||||
}
|
||||
cx.run_until_parked();
|
||||
|
||||
for _ in 0..16 {
|
||||
cx.run_until_parked();
|
||||
cx.update_window(handle, |_root, window, app| {
|
||||
let _ = window.draw(app);
|
||||
@@ -69,6 +80,39 @@ fn main() -> Result<()> {
|
||||
cx.run_until_parked();
|
||||
|
||||
let image = cx.capture_screenshot(handle)?;
|
||||
|
||||
// The timeline toolbar's tool icons (16px at 2× = 32px on 48px pitch)
|
||||
// must render: the toolbar is the 31px row at the top of the bottom dock
|
||||
// panel. Scan the bottom strip for the 8 tool cells and require most of
|
||||
// them to contain bright glyph pixels, so a broken icon load fails the
|
||||
// capture loudly instead of shipping an empty toolbar.
|
||||
let th = height * 2.0;
|
||||
let mut rendered = 0usize;
|
||||
for (index, cell_x) in [24u32, 88, 152, 216, 280, 344, 408, 472].iter().enumerate() {
|
||||
let mut bright = 0u32;
|
||||
for dy in 0..80i32 {
|
||||
for dx in 0..32i32 {
|
||||
let x = (*cell_x as i32 + dx) as u32;
|
||||
let y = (th as i32 - 320 + dy).max(0) as u32;
|
||||
if x >= image.width() || y >= image.height() {
|
||||
continue;
|
||||
}
|
||||
let p = image.get_pixel(x, y);
|
||||
if p[0] > 150 && p[1] > 150 && p[2] > 150 {
|
||||
bright += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("[screenshot] toolbar tool {index} bright pixels: {bright}");
|
||||
if bright > 20 {
|
||||
rendered += 1;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
rendered >= 6,
|
||||
"timeline toolbar icons did not render (only {rendered}/8 tool cells had pixels)"
|
||||
);
|
||||
|
||||
std::fs::create_dir_all(std::path::Path::new(OUT).parent().unwrap())?;
|
||||
image.save(OUT)?;
|
||||
println!("wrote {OUT} ({}×{})", image.width(), image.height());
|
||||
|
||||
@@ -14,8 +14,14 @@
|
||||
// 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 application shell: menu bar, dock layout, status bar and the tick
|
||||
//! loop that drives playback, playhead sync and the audio meter.
|
||||
//! The application shell: menu bar, dock layout, status bar, modal dialogs
|
||||
//! and the tick loop that drives playback, playhead sync, the audio meter
|
||||
//! and the export progress.
|
||||
//!
|
||||
//! The shell is generic over the engine backend ([`AppEngine`]); [`run`]
|
||||
//! picks the backend at startup: the real engine by default, the mock when
|
||||
//! the `--mock` flag / `OAK_ENGINE=mock` env var is given or the
|
||||
//! `mock-engine` cargo feature is enabled.
|
||||
//!
|
||||
//! Layout per the design (`design/Oak-UI设计图-主界面-标注版.png`):
|
||||
//!
|
||||
@@ -25,9 +31,10 @@
|
||||
//! │ dock: 项目 | 素材查看器 | 序列查看器+节点编辑器 | 检查器+历史记录
|
||||
//! │ (vertical split) 时间线 (full width, 31px toolbar on top)
|
||||
//! ├─────────────────────────────────────────────────────
|
||||
//! └ status bar: 就绪 | 缓存 | 代理 | 自动保存 || 时间码/时长 | 帧率 | 分辨率
|
||||
//! └ status bar: 就绪 | 缓存 | 代理 | 自动保存 || 时间码/时长 | 帧率 | 分辨率 | 引擎
|
||||
//! ```
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -40,10 +47,15 @@ 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::{DialogButton, Modal, ModalEvent, ModalOptions};
|
||||
use gpui_widgets::menu::{Menu, MenuBar, MenuBarEntry, MenuBarEvent, MenuItem};
|
||||
use gpui_widgets::theme::{apply_theme, OakTheme};
|
||||
|
||||
use crate::oakui::{EngineGateway, MockClock, MockEngine, Monitor};
|
||||
use crate::dialogs::{ExportDialogContent, PreferencesContent};
|
||||
use crate::oakui::{AppEngine, ExportSession, MockEngine, Monitor, RealEngine};
|
||||
use crate::panels::history::HistoryPanel;
|
||||
use crate::panels::ids::*;
|
||||
use crate::panels::inspector::InspectorPanel;
|
||||
@@ -59,8 +71,10 @@ mod menu_ids {
|
||||
pub const NEW_PROJECT: usize = 101;
|
||||
pub const OPEN_PROJECT: usize = 102;
|
||||
pub const SAVE: usize = 103;
|
||||
pub const EXPORT: usize = 104;
|
||||
pub const QUIT: usize = 105;
|
||||
pub const SAVE_AS: usize = 104;
|
||||
pub const CLOSE: usize = 105;
|
||||
pub const EXPORT: usize = 106;
|
||||
pub const QUIT: usize = 107;
|
||||
|
||||
pub const UNDO: usize = 201;
|
||||
pub const REDO: usize = 202;
|
||||
@@ -68,11 +82,13 @@ mod menu_ids {
|
||||
pub const COPY: usize = 204;
|
||||
pub const PASTE: usize = 205;
|
||||
pub const DELETE: usize = 206;
|
||||
pub const RIPPLE_DELETE: usize = 207;
|
||||
|
||||
pub const THEME_DARK: usize = 301;
|
||||
pub const THEME_LIGHT: usize = 302;
|
||||
pub const LANG_ZH: usize = 303;
|
||||
pub const LANG_EN: usize = 304;
|
||||
pub const PREFERENCES: usize = 305;
|
||||
|
||||
pub const PLAY_PAUSE: usize = 401;
|
||||
pub const PREV_FRAME: usize = 402;
|
||||
@@ -81,6 +97,8 @@ mod menu_ids {
|
||||
|
||||
pub const ADD_VIDEO_TRACK: usize = 501;
|
||||
pub const ADD_AUDIO_TRACK: usize = 502;
|
||||
pub const REMOVE_TRACK: usize = 503;
|
||||
pub const SPLIT_AT_PLAYHEAD: usize = 504;
|
||||
|
||||
pub const FOCUS_PROJECT: usize = 601;
|
||||
pub const FOCUS_SOURCE_VIEWER: usize = 602;
|
||||
@@ -93,15 +111,71 @@ mod menu_ids {
|
||||
pub const ABOUT: usize = 801;
|
||||
}
|
||||
|
||||
/// The panel registry: string keys for layout persistence, and the ability
|
||||
/// to rebuild any panel from its key.
|
||||
struct AppPanelRegistry {
|
||||
engine: Entity<MockEngine>,
|
||||
source_clock: Entity<MockClock>,
|
||||
program_clock: Entity<MockClock>,
|
||||
/// Modal-dialog control ids (see [`ModalEvent::control`]).
|
||||
mod modal_ids {
|
||||
pub const FILE_OPEN: usize = 1;
|
||||
pub const FILE_SAVE_AS: usize = 2;
|
||||
pub const PREFERENCES: usize = 3;
|
||||
pub const EXPORT: usize = 4;
|
||||
pub const EXPORT_PROGRESS: usize = 5;
|
||||
}
|
||||
|
||||
impl PanelRegistry for AppPanelRegistry {
|
||||
/// What a file dialog's OK button should do.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FileAction {
|
||||
Open,
|
||||
SaveAs,
|
||||
}
|
||||
|
||||
/// The modal currently layered on top of the shell, if any.
|
||||
enum ModalState {
|
||||
None,
|
||||
FileDialog {
|
||||
modal: Entity<Modal>,
|
||||
content: Entity<FileDialogContent>,
|
||||
action: FileAction,
|
||||
},
|
||||
Preferences {
|
||||
modal: Entity<Modal>,
|
||||
content: Entity<PreferencesContent>,
|
||||
},
|
||||
Export {
|
||||
modal: Entity<Modal>,
|
||||
content: Entity<ExportDialogContent>,
|
||||
},
|
||||
Progress {
|
||||
modal: Entity<Modal>,
|
||||
content: Entity<ProgressContent>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A running export: the session the tick loop drains for progress.
|
||||
struct ExportRun {
|
||||
session: ExportSession,
|
||||
}
|
||||
|
||||
impl ModalState {
|
||||
/// The modal entity currently shown, if any.
|
||||
fn modal_entity(&self) -> Option<Entity<Modal>> {
|
||||
match self {
|
||||
ModalState::None => None,
|
||||
ModalState::FileDialog { modal, .. }
|
||||
| ModalState::Preferences { modal, .. }
|
||||
| ModalState::Export { modal, .. }
|
||||
| ModalState::Progress { modal, .. } => Some(modal.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel registry: string keys for layout persistence, and the ability
|
||||
/// to rebuild any panel from its key.
|
||||
struct AppPanelRegistry<E: AppEngine> {
|
||||
engine: Entity<E>,
|
||||
source_clock: Entity<E::Clock>,
|
||||
program_clock: Entity<E::Clock>,
|
||||
}
|
||||
|
||||
impl<E: AppEngine> PanelRegistry for AppPanelRegistry<E> {
|
||||
fn panel_key(&self, id: gpui::dock::PanelId) -> Option<String> {
|
||||
Some(
|
||||
match id {
|
||||
@@ -119,8 +193,6 @@ impl PanelRegistry for AppPanelRegistry {
|
||||
}
|
||||
|
||||
fn build_panel(&self, key: &str, window: &mut Window, cx: &mut App) -> Option<PanelHandle> {
|
||||
// Each arm builds its own `PanelHandle` because the panel views have
|
||||
// different entity types.
|
||||
match key {
|
||||
"project" => Some(PanelHandle::new(
|
||||
cx.new(|cx| ProjectExplorerPanel::new(self.engine.clone(), window, cx)),
|
||||
@@ -177,27 +249,37 @@ impl PanelRegistry for AppPanelRegistry {
|
||||
}
|
||||
|
||||
/// The application root view.
|
||||
pub struct OakApp {
|
||||
engine: Entity<MockEngine>,
|
||||
program_clock: Entity<MockClock>,
|
||||
timeline: Entity<TimelineView<MockEngine>>,
|
||||
meter: Entity<AudioLevelMeter<MockEngine>>,
|
||||
pub struct OakApp<E: AppEngine> {
|
||||
engine: Entity<E>,
|
||||
program_clock: Entity<E::Clock>,
|
||||
timeline: Entity<TimelineView<E>>,
|
||||
meter: Entity<AudioLevelMeter<E>>,
|
||||
menu_bar: Entity<MenuBar>,
|
||||
dock: Entity<DockArea>,
|
||||
status_bar: Entity<StatusBar>,
|
||||
status_bar: Entity<StatusBar<E>>,
|
||||
/// Whether the dark theme is active (toggles via 视图 → 主题).
|
||||
dark: bool,
|
||||
/// The modal currently shown on top of the shell, if any.
|
||||
modal: ModalState,
|
||||
/// The running export session, if any.
|
||||
export: Option<ExportRun>,
|
||||
}
|
||||
|
||||
impl OakApp {
|
||||
/// Builds the whole shell.
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
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 {
|
||||
apply_theme(cx, &OakTheme::olive_dark());
|
||||
crate::oakui::icons::init(cx);
|
||||
|
||||
// --- engine and shared state ---------------------------------------
|
||||
let engine = cx.new(|cx| MockEngine::demo(cx));
|
||||
let source_clock = engine.read(cx).source_clock.clone();
|
||||
let program_clock = engine.read(cx).program_clock.clone();
|
||||
let engine = cx.new(|cx| E::create(cx));
|
||||
let source_clock = engine.read(cx).source_clock().clone();
|
||||
let program_clock = engine.read(cx).program_clock().clone();
|
||||
let timeline = cx.new(|cx| TimelineView::new(engine.clone(), window, cx).zoom(2.0));
|
||||
let meter = cx.new(|cx| AudioLevelMeter::new(3, engine.clone(), window, cx));
|
||||
|
||||
@@ -312,27 +394,21 @@ impl OakApp {
|
||||
.detach();
|
||||
|
||||
// --- timeline events -----------------------------------------------
|
||||
// The playhead is driven by the program monitor; seeking the timeline
|
||||
// (ruler click, keyboard) is routed back to the engine, guarded so
|
||||
// clock-driven syncs are no-ops.
|
||||
// Every timeline widget request (playhead seek, trim, move, track
|
||||
// height) is applied by the engine through its backend's edit
|
||||
// commands; the playhead is routed to the program monitor.
|
||||
cx.subscribe(
|
||||
&timeline,
|
||||
|this, _timeline, event: &TimelineEvent, cx| match event {
|
||||
TimelineEvent::PlayheadChanged(frame) => {
|
||||
let current = this.engine.read(cx).clock_frame(Monitor::Program, cx);
|
||||
if *frame != current {
|
||||
this.engine.update(cx, |engine, cx| {
|
||||
engine.request_frame(Monitor::Program, *frame, cx)
|
||||
});
|
||||
}
|
||||
}
|
||||
other => println!("[timeline] request: {other:?} (not applied by the mock)"),
|
||||
|this, _timeline, event: &TimelineEvent, cx| {
|
||||
this.engine
|
||||
.update(cx, |engine, cx| engine.apply_timeline_event(event, cx));
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
|
||||
// --- tick loop -----------------------------------------------------
|
||||
// Drives playback clocks, playhead sync and the audio meter at ~60Hz.
|
||||
// Drives playback clocks, playhead sync, the audio meter and the
|
||||
// export progress at ~60Hz.
|
||||
let this = cx.weak_entity();
|
||||
window
|
||||
.spawn(cx, async move |cx: &mut AsyncWindowContext| loop {
|
||||
@@ -347,7 +423,7 @@ impl OakApp {
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
let shell = Self {
|
||||
engine,
|
||||
program_clock,
|
||||
timeline,
|
||||
@@ -356,17 +432,32 @@ impl OakApp {
|
||||
dock,
|
||||
status_bar,
|
||||
dark: true,
|
||||
modal: ModalState::None,
|
||||
export: None,
|
||||
};
|
||||
|
||||
// Open the CLI-provided project once the shell is up.
|
||||
if let Some(path) = initial_path {
|
||||
shell.engine.update(cx, |engine, cx| {
|
||||
if let Err(err) = engine.open_project_path(path.clone(), cx) {
|
||||
println!("[app] failed to open {}: {err}", path.display());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
shell
|
||||
}
|
||||
|
||||
/// One animation-frame tick: advance the engine, sync the timeline
|
||||
/// playhead to the program clock, and refresh the audio meter.
|
||||
/// playhead to the program clock, refresh the audio meter and drain the
|
||||
/// export progress events.
|
||||
fn tick(&mut self, cx: &mut Context<Self>) {
|
||||
self.engine.update(cx, |engine, cx| engine.tick(cx));
|
||||
let frame = self.program_clock.read(cx).transport.frame();
|
||||
let frame = self.program_clock.read(cx).current_frame();
|
||||
self.timeline
|
||||
.update(cx, |timeline, cx| timeline.seek(frame, cx));
|
||||
self.meter.update(cx, |meter, cx| meter.update(cx));
|
||||
self.poll_export(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -374,8 +465,41 @@ impl OakApp {
|
||||
fn on_menu(&mut self, item: usize, cx: &mut Context<Self>) {
|
||||
use menu_ids::*;
|
||||
match item {
|
||||
// --- File ------------------------------------------------------
|
||||
NEW_PROJECT => self.engine.update(cx, |engine, cx| engine.new_project(cx)),
|
||||
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)),
|
||||
EXPORT => self.open_export_dialog(cx),
|
||||
QUIT => cx.quit(),
|
||||
// --- Edit ------------------------------------------------------
|
||||
UNDO => self.engine.update(cx, |engine, cx| engine.undo(cx)),
|
||||
REDO => self.engine.update(cx, |engine, cx| engine.redo(cx)),
|
||||
DELETE => self.delete_timeline_selection(false, cx),
|
||||
RIPPLE_DELETE => self.delete_timeline_selection(true, cx),
|
||||
CUT | COPY | PASTE => {
|
||||
println!("[menu] clipboard action {item} not wired yet");
|
||||
}
|
||||
// --- View ------------------------------------------------------
|
||||
THEME_DARK => {
|
||||
self.dark = true;
|
||||
apply_theme(cx, &OakTheme::olive_dark());
|
||||
self.rebuild_menu_bar(cx);
|
||||
cx.notify();
|
||||
}
|
||||
THEME_LIGHT => {
|
||||
self.dark = false;
|
||||
apply_theme(cx, &OakTheme::olive_light());
|
||||
self.rebuild_menu_bar(cx);
|
||||
cx.notify();
|
||||
}
|
||||
LANG_ZH => self.switch_language(crate::i18n::Language::ZhCN, cx),
|
||||
LANG_EN => self.switch_language(crate::i18n::Language::EnUs, cx),
|
||||
PREFERENCES => self.open_preferences(cx),
|
||||
// --- Playback --------------------------------------------------
|
||||
PLAY_PAUSE => {
|
||||
let playing = self.program_clock.read(cx).transport.is_playing();
|
||||
let playing = self.program_clock.read(cx).is_playing();
|
||||
let monitor = Monitor::Program;
|
||||
self.engine.update(cx, |engine, cx| {
|
||||
if playing {
|
||||
@@ -401,20 +525,7 @@ impl OakApp {
|
||||
engine.request_frame(monitor, Frame::ZERO, cx)
|
||||
});
|
||||
}
|
||||
THEME_DARK => {
|
||||
self.dark = true;
|
||||
apply_theme(cx, &OakTheme::olive_dark());
|
||||
self.rebuild_menu_bar(cx);
|
||||
cx.notify();
|
||||
}
|
||||
THEME_LIGHT => {
|
||||
self.dark = false;
|
||||
apply_theme(cx, &OakTheme::olive_light());
|
||||
self.rebuild_menu_bar(cx);
|
||||
cx.notify();
|
||||
}
|
||||
LANG_ZH => self.switch_language(crate::i18n::Language::ZhCN, cx),
|
||||
LANG_EN => self.switch_language(crate::i18n::Language::EnUs, cx),
|
||||
// --- Sequence --------------------------------------------------
|
||||
ADD_VIDEO_TRACK => {
|
||||
let kind = gpui::timeline::TrackKind::Video;
|
||||
self.engine
|
||||
@@ -425,6 +536,11 @@ impl OakApp {
|
||||
self.engine
|
||||
.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))
|
||||
}
|
||||
// --- Window ----------------------------------------------------
|
||||
FOCUS_PROJECT => self.focus_panel(PROJECT, cx),
|
||||
FOCUS_SOURCE_VIEWER => self.focus_panel(SOURCE_VIEWER, cx),
|
||||
FOCUS_PROGRAM_VIEWER => self.focus_panel(PROGRAM_VIEWER, cx),
|
||||
@@ -436,6 +552,46 @@ impl OakApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the project (to its own filename, or the given `path`).
|
||||
fn save_project(&mut self, path: Option<PathBuf>, cx: &mut Context<Self>) {
|
||||
let result = self
|
||||
.engine
|
||||
.update(cx, |engine, cx| engine.save_project(path, cx));
|
||||
if let Err(err) = result {
|
||||
println!("[file] save failed: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
if ids.is_empty() {
|
||||
println!("[timeline] delete: nothing selected");
|
||||
return;
|
||||
}
|
||||
for id in ids {
|
||||
self.engine
|
||||
.update(cx, |engine, cx| engine.delete_clip(id, ripple, cx));
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the first track selected in the timeline header.
|
||||
fn remove_selected_track(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(&index) = self.timeline.read(cx).selected_tracks().iter().next() else {
|
||||
println!("[timeline] remove track: nothing selected");
|
||||
return;
|
||||
};
|
||||
self.engine
|
||||
.update(cx, |engine, cx| engine.remove_track(index, cx));
|
||||
}
|
||||
|
||||
/// Focuses a dock panel (used by the 窗口 menu).
|
||||
fn focus_panel(&self, id: gpui::dock::PanelId, cx: &mut Context<Self>) {
|
||||
if let Some(handle) = cx.windows().first() {
|
||||
@@ -448,8 +604,7 @@ impl OakApp {
|
||||
|
||||
/// Switches the UI language live: updates the [`i18n`] global, rebuilds
|
||||
/// the menu bar (so the menu labels and the language checkmark move
|
||||
/// immediately), and repaints the whole shell — every label goes through
|
||||
/// [`crate::i18n::tr`] at render time, so panels flip without a restart.
|
||||
/// immediately), and repaints the whole shell.
|
||||
fn switch_language(&mut self, language: crate::i18n::Language, cx: &mut Context<Self>) {
|
||||
crate::i18n::set_language(language);
|
||||
self.rebuild_menu_bar(cx);
|
||||
@@ -481,17 +636,311 @@ impl OakApp {
|
||||
)
|
||||
.detach();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Modal dialogs
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Closes the current modal.
|
||||
fn close_modal(&mut self, cx: &mut Context<Self>) {
|
||||
self.modal = ModalState::None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Builds a modal on the main window, subscribes it to
|
||||
/// [`Self::on_modal`] and layers it onto the shell.
|
||||
///
|
||||
/// The modal is created inside `update_window` (modal widgets need a
|
||||
/// `&mut Window`); the state swap and the subscription happen *after* the
|
||||
/// window update returns, on this entity's own `Context` — swapping state
|
||||
/// 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).
|
||||
fn spawn_modal(
|
||||
&mut self,
|
||||
cx: &mut Context<Self>,
|
||||
build: impl FnOnce(&mut Window, &mut App) -> ModalState,
|
||||
) {
|
||||
let windows = cx.windows();
|
||||
let Some(handle) = windows.first() else {
|
||||
return;
|
||||
};
|
||||
let Ok(state) = cx.update_window(*handle, |_root, window, app| build(window, app)) else {
|
||||
return;
|
||||
};
|
||||
let modal = state
|
||||
.modal_entity()
|
||||
.expect("spawned modal always carries a Modal");
|
||||
cx.subscribe(&modal, |this, _entity, event: &ModalEvent, cx| {
|
||||
this.on_modal(event, cx);
|
||||
})
|
||||
.detach();
|
||||
self.modal = state;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// 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::SaveAs => (
|
||||
crate::i18n::tr("file.save_as.title"),
|
||||
modal_ids::FILE_SAVE_AS,
|
||||
),
|
||||
};
|
||||
let current_path = self
|
||||
.engine
|
||||
.read(cx)
|
||||
.project()
|
||||
.map(|p| p.path.clone())
|
||||
.filter(|p| !p.as_os_str().is_empty());
|
||||
self.spawn_modal(cx, move |window, app| {
|
||||
let (modal, content) =
|
||||
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));
|
||||
}
|
||||
}
|
||||
ModalState::FileDialog {
|
||||
modal,
|
||||
content,
|
||||
action,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Opens the preferences dialog.
|
||||
fn open_preferences(&mut self, cx: &mut Context<Self>) {
|
||||
self.spawn_modal(cx, |window, app| {
|
||||
let content = app.new(|cx| PreferencesContent::new(window, cx));
|
||||
let modal = app.new(|cx| {
|
||||
Modal::new(
|
||||
modal_ids::PREFERENCES,
|
||||
ModalOptions::new(crate::i18n::tr("preferences.title"), px(380.0))
|
||||
.with_button(DialogButton::primary(crate::i18n::tr("dialog.close"))),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.with_content(content.clone())
|
||||
});
|
||||
ModalState::Preferences { modal, content }
|
||||
});
|
||||
}
|
||||
|
||||
/// Opens the export dialog.
|
||||
fn open_export_dialog(&mut self, cx: &mut Context<Self>) {
|
||||
if self.engine.read(cx).current_sequence().is_none() {
|
||||
println!("[export] no sequence open");
|
||||
return;
|
||||
}
|
||||
let default_path = self.default_export_path(cx);
|
||||
self.spawn_modal(cx, move |window, app| {
|
||||
let content = app.new(|cx| ExportDialogContent::new(window, cx));
|
||||
content.update(app, |content, cx| {
|
||||
content.set_path(default_path.clone(), cx)
|
||||
});
|
||||
let modal = app.new(|cx| {
|
||||
Modal::new(
|
||||
modal_ids::EXPORT,
|
||||
ModalOptions::new(crate::i18n::tr("export.title"), px(440.0))
|
||||
.with_button(DialogButton::primary(crate::i18n::tr("export.run")))
|
||||
.with_button(DialogButton::cancel(crate::i18n::tr("dialog.cancel"))),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.with_content(content.clone())
|
||||
});
|
||||
ModalState::Export { modal, content }
|
||||
});
|
||||
}
|
||||
|
||||
/// A default output path for the export dialog: the project name with
|
||||
/// the format's extension, next to the project file.
|
||||
fn default_export_path(&self, cx: &App) -> String {
|
||||
let project = self.engine.read(cx).project();
|
||||
let name = project
|
||||
.map(|p| p.name.clone())
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or_else(|| "untitled".to_string());
|
||||
let dir = project
|
||||
.and_then(|p| p.path.parent().map(|d| d.to_path_buf()))
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
dir.join(format!("{name}.mp4"))
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
/// Starts the export from the export dialog's state and swaps the dialog
|
||||
/// for the progress dialog.
|
||||
fn begin_export(&mut self, cx: &mut Context<Self>) {
|
||||
let ModalState::Export { content, .. } = &self.modal else {
|
||||
return;
|
||||
};
|
||||
let format = content.read(cx).format(cx);
|
||||
let ext = content.read(cx).extension(cx);
|
||||
let mut path = content.read(cx).path(cx).to_string();
|
||||
if path.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
// Append the format's extension when the user left it off.
|
||||
let has_ext = std::path::Path::new(&path)
|
||||
.extension()
|
||||
.map(|e| !e.to_string_lossy().is_empty())
|
||||
.unwrap_or(false);
|
||||
if !has_ext {
|
||||
path = format!("{path}.{ext}");
|
||||
}
|
||||
|
||||
let result = self.engine.update(cx, |engine, _cx| {
|
||||
engine.start_export(format, PathBuf::from(&path))
|
||||
});
|
||||
match result {
|
||||
Ok(session) => {
|
||||
self.export = Some(ExportRun { session });
|
||||
self.spawn_modal(cx, |window, app| {
|
||||
let (modal, content) = progress_dialog(
|
||||
modal_ids::EXPORT_PROGRESS,
|
||||
crate::i18n::tr("export.progress.title"),
|
||||
crate::i18n::tr("export.progress.label"),
|
||||
window,
|
||||
app,
|
||||
);
|
||||
ModalState::Progress { modal, content }
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
println!("[export] failed to start: {err}");
|
||||
self.close_modal(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels the running export (the task aborts at the next frame).
|
||||
fn cancel_export(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(run) = &self.export {
|
||||
(run.session.cancel)();
|
||||
}
|
||||
let _ = cx;
|
||||
}
|
||||
|
||||
/// Drains the export progress events on the tick loop: updates the
|
||||
/// progress bar and closes the dialog when the task finishes.
|
||||
fn poll_export(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(run) = &self.export else {
|
||||
return;
|
||||
};
|
||||
let mut events = Vec::new();
|
||||
while let Ok(event) = run.session.events.try_recv() {
|
||||
events.push(event);
|
||||
}
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut finished: Option<(bool, String)> = None;
|
||||
for event in events {
|
||||
match event {
|
||||
crate::oakui::ExportEvent::Started => {}
|
||||
crate::oakui::ExportEvent::Progress(fraction) => {
|
||||
if let ModalState::Progress { content, .. } = &self.modal {
|
||||
let fraction = fraction as f32;
|
||||
content.update(cx, |content, cx| content.set_progress(fraction, cx));
|
||||
}
|
||||
}
|
||||
crate::oakui::ExportEvent::Finished(ok, err) => finished = Some((ok, err)),
|
||||
}
|
||||
}
|
||||
if let Some((ok, err)) = finished {
|
||||
self.export = None;
|
||||
self.modal = ModalState::None;
|
||||
if ok {
|
||||
println!("[export] finished");
|
||||
} else {
|
||||
println!("[export] failed: {err}");
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes a modal dialog event.
|
||||
fn on_modal(&mut self, event: &ModalEvent, cx: &mut Context<Self>) {
|
||||
match event {
|
||||
ModalEvent::ButtonClicked { control, button } => match *control {
|
||||
modal_ids::FILE_OPEN | modal_ids::FILE_SAVE_AS => {
|
||||
self.on_file_dialog_button(*button, cx);
|
||||
}
|
||||
modal_ids::EXPORT => {
|
||||
if *button == 0 {
|
||||
self.begin_export(cx);
|
||||
} else {
|
||||
self.close_modal(cx);
|
||||
}
|
||||
}
|
||||
modal_ids::EXPORT_PROGRESS => {
|
||||
if *button == 1 {
|
||||
// Cancel button: ask the task to abort; the finished
|
||||
// event closes the dialog.
|
||||
self.cancel_export(cx);
|
||||
}
|
||||
}
|
||||
modal_ids::PREFERENCES => self.close_modal(cx),
|
||||
_ => {}
|
||||
},
|
||||
ModalEvent::Dismissed { control } => match *control {
|
||||
modal_ids::EXPORT_PROGRESS => {
|
||||
// Escape cancels the running export and closes the dialog.
|
||||
self.cancel_export(cx);
|
||||
self.close_modal(cx);
|
||||
}
|
||||
_ => self.close_modal(cx),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the file dialog's OK/Cancel.
|
||||
fn on_file_dialog_button(&mut self, button: usize, cx: &mut Context<Self>) {
|
||||
let ModalState::FileDialog {
|
||||
content, action, ..
|
||||
} = &self.modal
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if button != 0 {
|
||||
self.close_modal(cx);
|
||||
return;
|
||||
}
|
||||
let path = PathBuf::from(content.read(cx).path(cx).to_string());
|
||||
let action = *action;
|
||||
if path.as_os_str().is_empty() {
|
||||
return;
|
||||
}
|
||||
let result = self.engine.update(cx, |engine, cx| match action {
|
||||
FileAction::Open => engine.open_project_path(path.clone(), cx),
|
||||
FileAction::SaveAs => engine.save_project(Some(path.clone()), cx),
|
||||
});
|
||||
if let Err(err) = result {
|
||||
println!("[file] {action:?} failed: {err}");
|
||||
}
|
||||
self.close_modal(cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for OakApp {
|
||||
impl<E: AppEngine> Render for OakApp<E> {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
let mut root = div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(self.menu_bar.clone())
|
||||
.child(div().flex_1().child(self.dock.clone()))
|
||||
.child(self.status_bar.clone())
|
||||
.child(self.status_bar.clone());
|
||||
if let Some(modal) = self.modal.modal_entity() {
|
||||
root = root.child(modal);
|
||||
}
|
||||
root
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,8 +972,10 @@ fn make_menus(dark: bool) -> Vec<MenuBarEntry> {
|
||||
Menu::new(vec![
|
||||
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").separated(),
|
||||
MenuItem::new(EXPORT, tr("menu.file.export")).disabled(),
|
||||
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(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(),
|
||||
]),
|
||||
),
|
||||
@@ -536,7 +987,8 @@ fn make_menus(dark: bool) -> Vec<MenuBarEntry> {
|
||||
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")).separated(),
|
||||
MenuItem::new(DELETE, tr("menu.edit.delete")).with_shortcut("⌫").separated(),
|
||||
MenuItem::new(RIPPLE_DELETE, tr("menu.edit.ripple_delete")),
|
||||
]),
|
||||
),
|
||||
MenuBarEntry::new(
|
||||
@@ -544,6 +996,7 @@ fn make_menus(dark: bool) -> Vec<MenuBarEntry> {
|
||||
Menu::new(vec![
|
||||
MenuItem::new(THEME_DARK, tr("menu.view.theme")).with_submenu(theme_submenu),
|
||||
MenuItem::new(LANG_ZH, tr("menu.view.language")).with_submenu(language_submenu),
|
||||
MenuItem::new(PREFERENCES, tr("menu.view.preferences")).separated(),
|
||||
]),
|
||||
),
|
||||
MenuBarEntry::new(
|
||||
@@ -562,7 +1015,9 @@ fn make_menus(dark: bool) -> Vec<MenuBarEntry> {
|
||||
Menu::new(vec![
|
||||
MenuItem::new(ADD_VIDEO_TRACK, tr("menu.sequence.add_video_track")),
|
||||
MenuItem::new(ADD_AUDIO_TRACK, tr("menu.sequence.add_audio_track")),
|
||||
MenuItem::new(503, tr("menu.sequence.settings")).disabled(),
|
||||
MenuItem::new(REMOVE_TRACK, tr("menu.sequence.remove_track")).separated(),
|
||||
MenuItem::new(SPLIT_AT_PLAYHEAD, tr("menu.sequence.split_at_playhead")),
|
||||
MenuItem::new(704, tr("menu.sequence.settings")).disabled(),
|
||||
]),
|
||||
),
|
||||
MenuBarEntry::new(
|
||||
@@ -592,21 +1047,82 @@ fn make_menus(dark: bool) -> Vec<MenuBarEntry> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Command-line arguments the app accepts.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct AppArgs {
|
||||
/// A project file to open at startup.
|
||||
project: Option<PathBuf>,
|
||||
/// Force the mock engine.
|
||||
mock: bool,
|
||||
}
|
||||
|
||||
impl AppArgs {
|
||||
/// Parses `std::env::args` plus the `OAK_ENGINE` override:
|
||||
/// `oakapp [project.ove] [--mock]`.
|
||||
fn from_env() -> Self {
|
||||
let mut args = AppArgs::default();
|
||||
for arg in std::env::args_os().skip(1) {
|
||||
let text = arg.to_string_lossy();
|
||||
match text.as_ref() {
|
||||
"--mock" => args.mock = true,
|
||||
"--help" | "-h" => {
|
||||
println!("oakapp — Oak Video Editor");
|
||||
println!("usage: oakapp [project.ove] [--mock]");
|
||||
println!(" --mock use the mock engine (or set OAK_ENGINE=mock)");
|
||||
std::process::exit(0);
|
||||
}
|
||||
_ if args.project.is_none() => args.project = Some(arg.into()),
|
||||
other => println!("[app] ignoring unknown argument {other:?}"),
|
||||
}
|
||||
}
|
||||
if std::env::var("OAK_ENGINE")
|
||||
.map(|v| v.eq_ignore_ascii_case("mock"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
args.mock = true;
|
||||
}
|
||||
args
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the app root entity for the chosen backend.
|
||||
fn build_root<E: AppEngine>(
|
||||
window: &mut Window,
|
||||
initial: Option<PathBuf>,
|
||||
cx: &mut App,
|
||||
) -> Entity<OakApp<E>> {
|
||||
cx.new(|cx| OakApp::new(window, initial, cx))
|
||||
}
|
||||
|
||||
/// The crate entry point: applies the olive-dark theme and opens the main
|
||||
/// window.
|
||||
/// window, running on the real engine by default (mock with `--mock` /
|
||||
/// `OAK_ENGINE=mock` / the `mock-engine` feature).
|
||||
pub fn run() {
|
||||
gpui_platform::application().run(|cx: &mut App| {
|
||||
// Restore the persisted UI language (oakcommon config `Language` key)
|
||||
// before the first window renders.
|
||||
let args = AppArgs::from_env();
|
||||
let use_mock = args.mock || cfg!(feature = "mock-engine");
|
||||
if use_mock {
|
||||
run_with::<MockEngine>(args.clone());
|
||||
} else {
|
||||
run_with::<RealEngine>(args);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the app window with `E` as the engine backend.
|
||||
fn run_with<E: AppEngine>(args: AppArgs) {
|
||||
let initial = args.project.clone();
|
||||
gpui_platform::application().run(move |cx: &mut App| {
|
||||
// Restore the persisted UI language (config `Language` key) before the
|
||||
// first window renders.
|
||||
crate::i18n::init();
|
||||
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| cx.new(|cx| OakApp::new(window, cx)),
|
||||
|window, cx| build_root::<E>(window, initial, cx),
|
||||
)
|
||||
.expect("failed to open the main window");
|
||||
|
||||
@@ -623,6 +1139,7 @@ pub fn run() {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::{TestAppContext, px, size};
|
||||
|
||||
/// The 视图/View menu carries a 语言/Language submenu whose items are
|
||||
/// labeled in their own language and whose checkmark follows the active
|
||||
@@ -714,4 +1231,121 @@ mod tests {
|
||||
assert_eq!(dark_item(true).checked, Some(true));
|
||||
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.
|
||||
#[test]
|
||||
fn file_and_edit_menus_cover_the_project_lifecycle() {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
|
||||
let entry = |title: &str| -> MenuBarEntry {
|
||||
make_menus(true)
|
||||
.into_iter()
|
||||
.find(|entry| entry.title == title)
|
||||
.expect("menu exists")
|
||||
};
|
||||
|
||||
crate::i18n::set_language(crate::i18n::Language::EnUs);
|
||||
let file = entry("File(F)");
|
||||
for id in [
|
||||
menu_ids::NEW_PROJECT,
|
||||
menu_ids::OPEN_PROJECT,
|
||||
menu_ids::SAVE,
|
||||
menu_ids::SAVE_AS,
|
||||
menu_ids::CLOSE,
|
||||
menu_ids::EXPORT,
|
||||
menu_ids::QUIT,
|
||||
] {
|
||||
assert!(
|
||||
file.menu.items.iter().any(|item| item.id == id),
|
||||
"File menu is missing item {id}"
|
||||
);
|
||||
}
|
||||
let edit = entry("Edit(E)");
|
||||
for id in [
|
||||
menu_ids::UNDO,
|
||||
menu_ids::REDO,
|
||||
menu_ids::DELETE,
|
||||
menu_ids::RIPPLE_DELETE,
|
||||
] {
|
||||
assert!(
|
||||
edit.menu.items.iter().any(|item| item.id == id),
|
||||
"Edit menu is missing item {id}"
|
||||
);
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
/// Opening 视图 → Preferences… must not crash: the dialog content and the
|
||||
/// modal are built on the main window and the shell state swaps over the
|
||||
/// entity's weak handle (regression test for the Preferences crash).
|
||||
#[gpui::test]
|
||||
async fn preferences_dialog_opens_without_crashing(cx: &mut TestAppContext) {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
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");
|
||||
|
||||
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| {
|
||||
window.draw(cx).clear();
|
||||
})
|
||||
.expect("window is still open");
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
/// The command-line parser understands the project path and the mock
|
||||
/// flag, and the `OAK_ENGINE` env var forces the mock.
|
||||
#[test]
|
||||
fn app_args_parse_path_and_mock_flag() {
|
||||
// Simulate argv without touching the real environment: parse a slice
|
||||
// directly.
|
||||
let parse = |argv: &[&str], env: Option<&str>| -> AppArgs {
|
||||
let mut args = AppArgs::default();
|
||||
for text in argv {
|
||||
match *text {
|
||||
"--mock" => args.mock = true,
|
||||
other => args.project = Some(PathBuf::from(other)),
|
||||
}
|
||||
}
|
||||
if env.map(|v| v.eq_ignore_ascii_case("mock")).unwrap_or(false) {
|
||||
args.mock = true;
|
||||
}
|
||||
args
|
||||
};
|
||||
let a = parse(&["/tmp/a.ove"], None);
|
||||
assert_eq!(a.project, Some(PathBuf::from("/tmp/a.ove")));
|
||||
assert!(!a.mock);
|
||||
|
||||
let b = parse(&["/tmp/a.ove", "--mock"], None);
|
||||
assert!(b.mock);
|
||||
|
||||
let c = parse(&["/tmp/a.ove"], Some("MOCK"));
|
||||
assert!(c.mock);
|
||||
|
||||
let d = parse(&[], None);
|
||||
assert!(d.project.is_none());
|
||||
assert!(!d.mock);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
// 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 content views of the app's modal dialogs: preferences (renderer
|
||||
//! backend + language) and export (format + output path).
|
||||
//!
|
||||
//! Each view owns its widgets and emits nothing itself — the host
|
||||
//! (`crate::app::OakApp`) reads the state (format / path) when a dialog
|
||||
//! button is clicked, and the preferences view writes its choices straight
|
||||
//! through the config C ABI on selection.
|
||||
|
||||
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_widgets::combo_box::{ComboBox, ComboBoxEvent, ComboBoxOption};
|
||||
|
||||
use crate::i18n;
|
||||
use crate::oakui::real::{
|
||||
config_get_string, config_set_string, encoding_formats, renderer_backends,
|
||||
CONFIG_KEY_RENDERER_BACKEND, EXPORT_FORMAT_MP4,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preferences
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The preferences dialog content: the renderer backend and the language
|
||||
/// dropdowns. Both write through the config C ABI on selection, so the
|
||||
/// choices survive restarts.
|
||||
pub struct PreferencesContent {
|
||||
backend: Entity<ComboBox>,
|
||||
language: Entity<ComboBox>,
|
||||
/// The backend options, in display order.
|
||||
backends: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl PreferencesContent {
|
||||
/// Builds the content: reads the current config values and seeds the
|
||||
/// dropdowns.
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let backends = renderer_backends();
|
||||
let current_backend = config_get_string(CONFIG_KEY_RENDERER_BACKEND);
|
||||
let backend_selected = backends
|
||||
.iter()
|
||||
.position(|b| b.eq_ignore_ascii_case(¤t_backend))
|
||||
.unwrap_or(0);
|
||||
let backend_options = backends
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| ComboBoxOption::new(i, backend_label(name)))
|
||||
.collect();
|
||||
let backend = cx.new(|cx| {
|
||||
ComboBox::new(1, backend_options, window, cx)
|
||||
.with_placeholder(i18n::tr("preferences.backend.placeholder"))
|
||||
});
|
||||
cx.subscribe(&backend, |this, _combo, event: &ComboBoxEvent, cx| {
|
||||
if let ComboBoxEvent::Selected { value, .. } = event {
|
||||
if let Some(name) = this.backends.get(*value) {
|
||||
config_set_string(CONFIG_KEY_RENDERER_BACKEND, name);
|
||||
println!("[preferences] renderer backend → {name}");
|
||||
}
|
||||
}
|
||||
let _ = cx;
|
||||
})
|
||||
.detach();
|
||||
backend.update(cx, |combo, cx| combo.set_selected(Some(backend_selected), cx));
|
||||
|
||||
let language_options = vec![
|
||||
ComboBoxOption::new(0, "English (en-US)"),
|
||||
ComboBoxOption::new(1, "简体中文 (zh-CN)"),
|
||||
];
|
||||
let language = cx.new(|cx| {
|
||||
ComboBox::new(2, language_options, window, cx)
|
||||
.with_placeholder(i18n::tr("preferences.language.placeholder"))
|
||||
});
|
||||
let language_selected = match crate::i18n::language() {
|
||||
crate::i18n::Language::EnUs => 0,
|
||||
crate::i18n::Language::ZhCN => 1,
|
||||
};
|
||||
cx.subscribe(&language, |_this, _combo, event: &ComboBoxEvent, cx| {
|
||||
if let ComboBoxEvent::Selected { value, .. } = event {
|
||||
let language = match *value {
|
||||
1 => crate::i18n::Language::ZhCN,
|
||||
_ => crate::i18n::Language::EnUs,
|
||||
};
|
||||
crate::i18n::set_language(language);
|
||||
}
|
||||
let _ = cx;
|
||||
})
|
||||
.detach();
|
||||
language.update(cx, |combo, cx| combo.set_selected(Some(language_selected), cx));
|
||||
|
||||
Self {
|
||||
backend,
|
||||
language,
|
||||
backends,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A display label for a renderer backend id.
|
||||
fn backend_label(name: &str) -> String {
|
||||
match name {
|
||||
"opengl" => "OpenGL",
|
||||
"metal" => "Metal",
|
||||
"vulkan" => "Vulkan",
|
||||
"none" => "None (off)",
|
||||
other => other,
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A labeled form row: a small caption above the widget.
|
||||
fn form_row(
|
||||
colors: &gpui::colors::Colors,
|
||||
label: SharedString,
|
||||
widget: impl IntoElement,
|
||||
) -> gpui::Div {
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(div().text_color(colors.text).child(label))
|
||||
.child(widget)
|
||||
}
|
||||
|
||||
impl Render for PreferencesContent {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.w_full()
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("preferences.backend").into(),
|
||||
self.backend.clone(),
|
||||
))
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("preferences.language").into(),
|
||||
self.language.clone(),
|
||||
))
|
||||
.child(
|
||||
div()
|
||||
.text_color(colors.disabled)
|
||||
.text_xs()
|
||||
.child(i18n::tr("preferences.hint")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A text field with the same shape as the file dialog's path field.
|
||||
pub struct PathField {
|
||||
editor: Entity<EditableTextState>,
|
||||
}
|
||||
|
||||
impl PathField {
|
||||
/// The path currently entered.
|
||||
pub fn path(&self, app: &App) -> SharedString {
|
||||
self.editor.read(app).as_str().into()
|
||||
}
|
||||
|
||||
/// Replaces the path shown in the field.
|
||||
pub fn set_path(&mut self, path: impl Into<SharedString>, cx: &mut Context<Self>) {
|
||||
let path = path.into();
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.emplace(path.as_ref(), cx);
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for PathField {
|
||||
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()
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.background)
|
||||
.px_2()
|
||||
.py_1()
|
||||
.child(text_input("gpui-widgets-export-path").state(weak).accepts_input(true))
|
||||
}
|
||||
}
|
||||
|
||||
/// The export dialog content: the container-format dropdown and the output
|
||||
/// path field.
|
||||
pub struct ExportDialogContent {
|
||||
format: Entity<ComboBox>,
|
||||
path: Entity<PathField>,
|
||||
/// (format id, display name, extension) in dropdown order.
|
||||
formats: Vec<(i32, String, String)>,
|
||||
}
|
||||
|
||||
impl ExportDialogContent {
|
||||
/// Builds the content: the format list comes from the oakcodec encoding
|
||||
/// enumeration (MP4 default), the path starts empty.
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let formats: Vec<(i32, String, String)> = encoding_formats()
|
||||
.into_iter()
|
||||
.filter(|(_, _, ext)| !ext.is_empty())
|
||||
.collect();
|
||||
let options = formats
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (_, name, ext))| {
|
||||
ComboBoxOption::new(i, format!("{name} (.{ext})"))
|
||||
})
|
||||
.collect();
|
||||
let format = cx.new(|cx| {
|
||||
ComboBox::new(3, options, window, cx)
|
||||
.with_placeholder(i18n::tr("export.format.placeholder"))
|
||||
});
|
||||
let mp4_index = formats
|
||||
.iter()
|
||||
.position(|(id, _, _)| *id == EXPORT_FORMAT_MP4)
|
||||
.unwrap_or(0);
|
||||
format.update(cx, |combo, cx| combo.set_selected(Some(mp4_index), cx));
|
||||
|
||||
let path = cx.new(|cx| {
|
||||
let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
|
||||
PathField { editor }
|
||||
});
|
||||
|
||||
Self {
|
||||
format,
|
||||
path,
|
||||
formats,
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected format id.
|
||||
pub fn format(&self, cx: &App) -> i32 {
|
||||
let Some(selected) = self.format.read(cx).selected() else {
|
||||
return EXPORT_FORMAT_MP4;
|
||||
};
|
||||
self.formats
|
||||
.get(selected)
|
||||
.map(|(id, _, _)| *id)
|
||||
.unwrap_or(EXPORT_FORMAT_MP4)
|
||||
}
|
||||
|
||||
/// The selected format's file extension (without the dot).
|
||||
pub fn extension(&self, cx: &App) -> String {
|
||||
let Some(selected) = self.format.read(cx).selected() else {
|
||||
return "mp4".to_string();
|
||||
};
|
||||
self.formats
|
||||
.get(selected)
|
||||
.map(|(_, _, ext)| ext.clone())
|
||||
.unwrap_or_else(|| "mp4".to_string())
|
||||
}
|
||||
|
||||
/// The output path currently entered.
|
||||
pub fn path(&self, cx: &App) -> SharedString {
|
||||
self.path.read(cx).path(cx)
|
||||
}
|
||||
|
||||
/// 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));
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ExportDialogContent {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.w_full()
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("export.format").into(),
|
||||
self.format.clone(),
|
||||
))
|
||||
.child(form_row(&colors, i18n::tr("export.path").into(), self.path.clone()))
|
||||
.child(
|
||||
div()
|
||||
.text_color(colors.disabled)
|
||||
.text_xs()
|
||||
.child(i18n::tr("export.hint")),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,9 @@
|
||||
|
||||
//! Tiny localization layer: embedded en-US / zh-CN string tables, a
|
||||
//! [`tr`] lookup used by every user-visible label in the app, and a runtime
|
||||
//! language setting persisted through the oakcommon config C ABI
|
||||
//! (`oakcommon_config_get` / `oakcommon_config_set`, the process-wide
|
||||
//! `ConfigStore`).
|
||||
//! language setting persisted through the oakengine config C ABI
|
||||
//! (`oakengine_config_get_string` / `oakengine_config_set_string`, the
|
||||
//! process-wide `ConfigStore`).
|
||||
//!
|
||||
//! # The tables
|
||||
//!
|
||||
@@ -31,18 +31,11 @@
|
||||
//!
|
||||
//! The language is a process-global [`Language`] (an atomic, so any thread
|
||||
//! can read it without locking). At startup [`init`] loads the persisted
|
||||
//! value from the oakcommon config key `Language` (`"zh-CN"` / `"en-US"`;
|
||||
//! empty or unknown values mean en-US). [`set_language`] flips the global
|
||||
//! and writes the new value back through the same key so the preference
|
||||
//! survives restarts.
|
||||
//!
|
||||
//! The oakcommon C ABI is resolved at runtime with `dlopen`/`dlsym`, so the
|
||||
//! app builds, tests and runs without liboakcommon present (e.g. under
|
||||
//! `cargo test`): when the library cannot be loaded the layer degrades to an
|
||||
//! in-process store and still switches languages live. Once the app is
|
||||
//! packaged with liboakcommon in the library search path (or
|
||||
//! `OAK_LIB_DIR` is set to a build tree), the setting round-trips through
|
||||
//! `config.ini`.
|
||||
//! value from the config key `Language` (`"zh-CN"` / `"en-US"`; empty or
|
||||
//! unknown values mean en-US). [`set_language`] flips the global and writes
|
||||
//! the new value back through the same key so the preference survives
|
||||
//! restarts. The preferences dialog drives the same setting through the
|
||||
//! config C ABI directly.
|
||||
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
@@ -88,7 +81,7 @@ pub fn language() -> Language {
|
||||
}
|
||||
|
||||
/// Switches the active language live and persists the choice through the
|
||||
/// oakcommon config C ABI (when the library is loadable).
|
||||
/// oakengine config C ABI.
|
||||
pub fn set_language(language: Language) {
|
||||
CURRENT.store(match language {
|
||||
Language::EnUs => 0,
|
||||
@@ -98,24 +91,21 @@ pub fn set_language(language: Language) {
|
||||
sync_widgets();
|
||||
}
|
||||
|
||||
/// Loads the persisted language from the oakcommon config C ABI. Called once
|
||||
/// at startup. Never fails: without liboakcommon the default (en-US) stays.
|
||||
/// Loads the persisted language from the oakengine config C ABI. Called once
|
||||
/// at startup. Never fails: a missing key keeps the default (en-US).
|
||||
pub fn init() {
|
||||
let Some(store) = ConfigAbi::load() else {
|
||||
let code = crate::oakui::real::config_get_string("Language");
|
||||
if !code.is_empty() {
|
||||
set_language(Language::from_code(&code));
|
||||
} else {
|
||||
sync_widgets();
|
||||
return;
|
||||
};
|
||||
match store.get("Language") {
|
||||
Some(code) if !code.is_empty() => set_language(Language::from_code(&code)),
|
||||
_ => sync_widgets(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `language` back to the oakcommon config `Language` key.
|
||||
/// Writes `language` back to the config `Language` key through the facade
|
||||
/// config C ABI.
|
||||
fn persist_language(language: Language) {
|
||||
if let Some(store) = ConfigAbi::load() {
|
||||
store.set("Language", language.code());
|
||||
}
|
||||
crate::oakui::real::config_set_string("Language", language.code());
|
||||
}
|
||||
|
||||
/// Translates `key` in the active language.
|
||||
@@ -135,6 +125,13 @@ pub const WIDGET_KEYS: &[&str] = &[
|
||||
"viewer.safe_frames",
|
||||
"viewer.zoom",
|
||||
"viewer.no_frame_source",
|
||||
"viewer.in_point",
|
||||
"viewer.step_back",
|
||||
"viewer.play",
|
||||
"viewer.pause",
|
||||
"viewer.step_forward",
|
||||
"viewer.out_point",
|
||||
"viewer.clear_range",
|
||||
"effect_stack.empty",
|
||||
"effect_stack.add",
|
||||
];
|
||||
@@ -184,6 +181,8 @@ const EN: &[(&str, &str)] = &[
|
||||
("menu.file.new_project", "New Project…"),
|
||||
("menu.file.open_project", "Open Project…"),
|
||||
("menu.file.save", "Save"),
|
||||
("menu.file.save_as", "Save As…"),
|
||||
("menu.file.close", "Close Project"),
|
||||
("menu.file.export", "Export…"),
|
||||
("menu.file.quit", "Quit"),
|
||||
// --- Edit ---
|
||||
@@ -193,6 +192,7 @@ const EN: &[(&str, &str)] = &[
|
||||
("menu.edit.copy", "Copy"),
|
||||
("menu.edit.paste", "Paste"),
|
||||
("menu.edit.delete", "Delete"),
|
||||
("menu.edit.ripple_delete", "Ripple Delete"),
|
||||
// --- View ---
|
||||
("menu.view.theme", "Theme"),
|
||||
("menu.view.theme.dark", "Olive Dark"),
|
||||
@@ -200,6 +200,7 @@ const EN: &[(&str, &str)] = &[
|
||||
("menu.view.language", "Language"),
|
||||
("menu.view.language.en", "English"),
|
||||
("menu.view.language.zh", "简体中文"),
|
||||
("menu.view.preferences", "Preferences…"),
|
||||
// --- Playback ---
|
||||
("menu.playback.play_pause", "Play/Pause"),
|
||||
("menu.playback.prev_frame", "Previous Frame"),
|
||||
@@ -208,6 +209,8 @@ const EN: &[(&str, &str)] = &[
|
||||
// --- Sequence ---
|
||||
("menu.sequence.add_video_track", "Add Video Track"),
|
||||
("menu.sequence.add_audio_track", "Add Audio Track"),
|
||||
("menu.sequence.remove_track", "Remove Selected Track"),
|
||||
("menu.sequence.split_at_playhead", "Split Clips at Playhead"),
|
||||
("menu.sequence.settings", "Sequence Settings…"),
|
||||
// --- Window ---
|
||||
("menu.window.project", "Project"),
|
||||
@@ -237,6 +240,7 @@ const EN: &[(&str, &str)] = &[
|
||||
("status.proxy", "Proxy: Off"),
|
||||
("status.autosave", "Autosave: 3 min ago"),
|
||||
("status.untitled", "Untitled Project"),
|
||||
("status.backend", "Engine:"),
|
||||
// --- timeline toolbar ---
|
||||
("timeline.tool.select", "Select"),
|
||||
("timeline.tool.razor", "Razor"),
|
||||
@@ -244,16 +248,35 @@ const EN: &[(&str, &str)] = &[
|
||||
("timeline.tool.slip", "Slip"),
|
||||
("timeline.tool.roll", "Roll"),
|
||||
("timeline.tool.zoom", "Zoom"),
|
||||
("timeline.tool.knife", "Knife"),
|
||||
("timeline.tool.marker", "Marker"),
|
||||
("timeline.tool.slide", "Slide"),
|
||||
("timeline.tool.track_select", "Track Select"),
|
||||
("timeline.zoom", "Zoom"),
|
||||
("timeline.zoom_in", "Zoom In"),
|
||||
("timeline.zoom_out", "Zoom Out"),
|
||||
("timeline.track_height", "Track Height"),
|
||||
("timeline.snap", "Snap"),
|
||||
// --- project bin ---
|
||||
("bin.footage", "Footage"),
|
||||
("bin.music", "Music"),
|
||||
// --- history (undo stack demo entries) ---
|
||||
("history.transform", "Transform"),
|
||||
("history.move_clip", "Move Clip"),
|
||||
("history.delete_clip", "Delete"),
|
||||
("history.add_lut", "Add OCIO LUT"),
|
||||
("history.set_in_point", "Set In Point"),
|
||||
// --- node editor ---
|
||||
("node.fit", "Fit"),
|
||||
// --- viewer header chips ---
|
||||
("viewer.source", "Source Viewer · Source"),
|
||||
("viewer.program", "Program Viewer · Program"),
|
||||
// --- viewer transport tooltips ---
|
||||
("viewer.in_point", "Set In Point"),
|
||||
("viewer.step_back", "Previous Frame"),
|
||||
("viewer.play", "Play"),
|
||||
("viewer.pause", "Pause"),
|
||||
("viewer.step_forward", "Next Frame"),
|
||||
("viewer.out_point", "Set Out Point"),
|
||||
("viewer.clear_range", "Clear In/Out Range"),
|
||||
// --- widget-baked strings (synced to gpui_widgets::i18n) ---
|
||||
("viewer.safe_frames", "Safe Frames"),
|
||||
("viewer.zoom", "Zoom"),
|
||||
@@ -262,6 +285,25 @@ const EN: &[(&str, &str)] = &[
|
||||
("effect_stack.add", "+ Add Effect"),
|
||||
// --- inspector ---
|
||||
("inspector.params", "Parameters (placeholder)"),
|
||||
// --- dialogs ---
|
||||
("dialog.cancel", "Cancel"),
|
||||
("dialog.close", "Close"),
|
||||
("file.open.title", "Open Project"),
|
||||
("file.save_as.title", "Save Project As"),
|
||||
("preferences.title", "Preferences"),
|
||||
("preferences.backend", "Renderer backend"),
|
||||
("preferences.backend.placeholder", "Select a backend…"),
|
||||
("preferences.language", "Language"),
|
||||
("preferences.language.placeholder", "Select a language…"),
|
||||
("preferences.hint", "The renderer backend applies to the render worker at the next launch; the language switches immediately."),
|
||||
("export.title", "Export Sequence"),
|
||||
("export.format", "Format"),
|
||||
("export.format.placeholder", "Select a format…"),
|
||||
("export.path", "Output path"),
|
||||
("export.run", "Export"),
|
||||
("export.hint", "The sequence is exported through the oaktask export path; progress is shown in the dialog."),
|
||||
("export.progress.title", "Exporting"),
|
||||
("export.progress.label", "Rendering frames…"),
|
||||
];
|
||||
|
||||
/// The zh-CN table. Mirrors [`EN`] key-for-key.
|
||||
@@ -279,6 +321,8 @@ const ZH: &[(&str, &str)] = &[
|
||||
("menu.file.new_project", "新建项目…"),
|
||||
("menu.file.open_project", "打开项目…"),
|
||||
("menu.file.save", "保存"),
|
||||
("menu.file.save_as", "另存为…"),
|
||||
("menu.file.close", "关闭项目"),
|
||||
("menu.file.export", "导出…"),
|
||||
("menu.file.quit", "退出"),
|
||||
// --- Edit ---
|
||||
@@ -288,6 +332,7 @@ const ZH: &[(&str, &str)] = &[
|
||||
("menu.edit.copy", "复制"),
|
||||
("menu.edit.paste", "粘贴"),
|
||||
("menu.edit.delete", "删除"),
|
||||
("menu.edit.ripple_delete", "波纹删除"),
|
||||
// --- View ---
|
||||
("menu.view.theme", "主题"),
|
||||
("menu.view.theme.dark", "Olive Dark"),
|
||||
@@ -295,6 +340,7 @@ const ZH: &[(&str, &str)] = &[
|
||||
("menu.view.language", "语言"),
|
||||
("menu.view.language.en", "English"),
|
||||
("menu.view.language.zh", "简体中文"),
|
||||
("menu.view.preferences", "偏好设置…"),
|
||||
// --- Playback ---
|
||||
("menu.playback.play_pause", "播放/暂停"),
|
||||
("menu.playback.prev_frame", "上一帧"),
|
||||
@@ -303,6 +349,8 @@ const ZH: &[(&str, &str)] = &[
|
||||
// --- Sequence ---
|
||||
("menu.sequence.add_video_track", "添加视频轨道"),
|
||||
("menu.sequence.add_audio_track", "添加音频轨道"),
|
||||
("menu.sequence.remove_track", "删除所选轨道"),
|
||||
("menu.sequence.split_at_playhead", "在播放头处分割片段"),
|
||||
("menu.sequence.settings", "序列设置…"),
|
||||
// --- Window ---
|
||||
("menu.window.project", "项目"),
|
||||
@@ -332,6 +380,7 @@ const ZH: &[(&str, &str)] = &[
|
||||
("status.proxy", "代理:关"),
|
||||
("status.autosave", "自动保存:3分钟前"),
|
||||
("status.untitled", "未命名项目"),
|
||||
("status.backend", "引擎:"),
|
||||
// --- timeline toolbar ---
|
||||
("timeline.tool.select", "选择"),
|
||||
("timeline.tool.razor", "剃刀"),
|
||||
@@ -339,16 +388,35 @@ const ZH: &[(&str, &str)] = &[
|
||||
("timeline.tool.slip", "滑动"),
|
||||
("timeline.tool.roll", "滚动"),
|
||||
("timeline.tool.zoom", "缩放"),
|
||||
("timeline.tool.knife", "刀"),
|
||||
("timeline.tool.marker", "标记"),
|
||||
("timeline.tool.slide", "滑移"),
|
||||
("timeline.tool.track_select", "轨道选择"),
|
||||
("timeline.zoom", "缩放"),
|
||||
("timeline.zoom_in", "放大"),
|
||||
("timeline.zoom_out", "缩小"),
|
||||
("timeline.track_height", "轨道高"),
|
||||
("timeline.snap", "吸附"),
|
||||
// --- project bin ---
|
||||
("bin.footage", "素材"),
|
||||
("bin.music", "音乐"),
|
||||
// --- history (undo stack demo entries) ---
|
||||
("history.transform", "变换"),
|
||||
("history.move_clip", "移动片段"),
|
||||
("history.delete_clip", "删除"),
|
||||
("history.add_lut", "添加 OCIO LUT"),
|
||||
("history.set_in_point", "设置入点"),
|
||||
// --- node editor ---
|
||||
("node.fit", "适配"),
|
||||
// --- viewer header chips ---
|
||||
("viewer.source", "素材查看器 · 源"),
|
||||
("viewer.program", "序列查看器 · 节目"),
|
||||
// --- viewer transport tooltips ---
|
||||
("viewer.in_point", "设置入点"),
|
||||
("viewer.step_back", "上一帧"),
|
||||
("viewer.play", "播放"),
|
||||
("viewer.pause", "暂停"),
|
||||
("viewer.step_forward", "下一帧"),
|
||||
("viewer.out_point", "设置出点"),
|
||||
("viewer.clear_range", "清除入出点"),
|
||||
// --- widget-baked strings (synced to gpui_widgets::i18n) ---
|
||||
("viewer.safe_frames", "安全框"),
|
||||
("viewer.zoom", "缩放"),
|
||||
@@ -357,127 +425,35 @@ const ZH: &[(&str, &str)] = &[
|
||||
("effect_stack.add", "+ 添加效果"),
|
||||
// --- inspector ---
|
||||
("inspector.params", "参数(占位)"),
|
||||
// --- dialogs ---
|
||||
("dialog.cancel", "取消"),
|
||||
("dialog.close", "关闭"),
|
||||
("file.open.title", "打开项目"),
|
||||
("file.save_as.title", "项目另存为"),
|
||||
("preferences.title", "偏好设置"),
|
||||
("preferences.backend", "渲染后端"),
|
||||
("preferences.backend.placeholder", "选择一个后端…"),
|
||||
("preferences.language", "语言"),
|
||||
("preferences.language.placeholder", "选择语言…"),
|
||||
("preferences.hint", "渲染后端在下次启动渲染工作进程时生效;语言立即切换。"),
|
||||
("export.title", "导出序列"),
|
||||
("export.format", "格式"),
|
||||
("export.format.placeholder", "选择格式…"),
|
||||
("export.path", "输出路径"),
|
||||
("export.run", "导出"),
|
||||
("export.hint", "序列通过 oaktask 导出路径导出;进度显示在对话框中。"),
|
||||
("export.progress.title", "正在导出"),
|
||||
("export.progress.label", "正在渲染帧…"),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// oakcommon config C ABI (runtime-resolved)
|
||||
// Config persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The subset of the oakcommon config C ABI the language setting needs. Each
|
||||
/// function pointer is optional: when liboakcommon cannot be loaded the whole
|
||||
/// struct is `None` and the in-process fallback store is used instead.
|
||||
struct ConfigAbi {
|
||||
get: unsafe extern "C" fn(*const i8, *const i8, *mut i8, i32) -> i32,
|
||||
set: unsafe extern "C" fn(*const i8, *const i8, *const i8),
|
||||
}
|
||||
|
||||
impl ConfigAbi {
|
||||
/// Resolves the ABI once (process-wide) and returns it when loadable.
|
||||
fn load() -> Option<&'static ConfigAbi> {
|
||||
static ABI: std::sync::OnceLock<Option<ConfigAbi>> = std::sync::OnceLock::new();
|
||||
ABI.get_or_init(resolve_abi).as_ref()
|
||||
}
|
||||
|
||||
/// Reads the string entry for a flat `key`, or `None` when absent.
|
||||
fn get(&self, key: &str) -> Option<String> {
|
||||
let key = to_c(key)?;
|
||||
let mut buf = [0i8; 128];
|
||||
let result = unsafe {
|
||||
(self.get)(
|
||||
std::ptr::null(),
|
||||
key.as_ptr(),
|
||||
buf.as_mut_ptr(),
|
||||
buf.len() as i32,
|
||||
)
|
||||
};
|
||||
if result <= 0 {
|
||||
// Negative codes are OAKCOMMON_E_* errors (e.g. not-found).
|
||||
return None;
|
||||
}
|
||||
Some(from_c(&buf))
|
||||
}
|
||||
|
||||
/// Writes a string entry for a flat `key`.
|
||||
fn set(&self, key: &str, value: &str) {
|
||||
let (Some(key), Some(value)) = (to_c(key), to_c(value)) else {
|
||||
return;
|
||||
};
|
||||
unsafe {
|
||||
(self.set)(std::ptr::null(), key.as_ptr(), value.as_ptr());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the oakcommon config functions via `dlopen`/`dlsym`.
|
||||
///
|
||||
/// Candidate library names: `OAK_LIB_DIR` (build-tree override) first, then
|
||||
/// the bare `liboakcommon.dylib` name on the platform search path. When none
|
||||
/// loads (plain `cargo test`/`cargo run` without a built oakcommon), this
|
||||
/// returns `None` and [`ConfigAbi::load`] degrades to the fallback store.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn resolve_abi() -> Option<ConfigAbi> {
|
||||
use std::ffi::c_void;
|
||||
|
||||
const RTLD_LAZY: i32 = 0x1;
|
||||
unsafe extern "C" {
|
||||
fn dlopen(filename: *const i8, flag: i32) -> *mut c_void;
|
||||
fn dlsym(handle: *mut c_void, symbol: *const i8) -> *mut c_void;
|
||||
}
|
||||
|
||||
// Candidate handles; the first dlopen that succeeds wins.
|
||||
let mut candidates: Vec<*mut c_void> = Vec::new();
|
||||
let mut handle: *mut c_void = std::ptr::null_mut();
|
||||
|
||||
if let Ok(dir) = std::env::var("OAK_LIB_DIR") {
|
||||
let path = format!("{dir}/liboakcommon.dylib");
|
||||
if let Some(c) = to_c(&path) {
|
||||
handle = unsafe { dlopen(c.as_ptr(), RTLD_LAZY) };
|
||||
if !handle.is_null() {
|
||||
candidates.push(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
if handle.is_null() {
|
||||
if let Some(c) = to_c("liboakcommon.dylib") {
|
||||
handle = unsafe { dlopen(c.as_ptr(), RTLD_LAZY) };
|
||||
}
|
||||
}
|
||||
if handle.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let get = unsafe { dlsym(handle, b"oakcommon_config_get\0".as_ptr() as *const i8) };
|
||||
let set = unsafe { dlsym(handle, b"oakcommon_config_set\0".as_ptr() as *const i8) };
|
||||
if get.is_null() || set.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ConfigAbi {
|
||||
get: unsafe { std::mem::transmute(get) },
|
||||
set: unsafe { std::mem::transmute(set) },
|
||||
})
|
||||
}
|
||||
|
||||
/// Non-macOS fallback: no dlopen plumbing here; the in-process store is
|
||||
/// always used.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn resolve_abi() -> Option<ConfigAbi> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Turns a `&str` into a NUL-terminated C string.
|
||||
fn to_c(s: &str) -> Option<std::ffi::CString> {
|
||||
std::ffi::CString::new(s).ok()
|
||||
}
|
||||
|
||||
/// Reads a NUL-terminated buffer back into a `String`.
|
||||
fn from_c(buf: &[i8]) -> String {
|
||||
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
||||
buf[..len]
|
||||
.iter()
|
||||
.map(|&c| c as u8 as char)
|
||||
.collect()
|
||||
}
|
||||
//
|
||||
// The language setting round-trips through the oakengine config C ABI
|
||||
// (`oakengine_config_get_string` / `oakengine_config_set_string`, the
|
||||
// in-process `ConfigStore` backed by the linked oakcommon crate). See
|
||||
// [`real`](crate::oakui::real) for the facade-side helpers.
|
||||
|
||||
/// Serializes every test that mutates the process-global language, so
|
||||
/// parallel tests (in this module and in [`crate::app`]) cannot race each
|
||||
@@ -514,6 +490,48 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// No table entry may be its own key, and identical en-US / zh-CN values
|
||||
/// are only allowed for proper nouns that stay in their original language
|
||||
/// (theme names, language names, codecs) — anything else means one side
|
||||
/// was left untranslated.
|
||||
#[test]
|
||||
fn no_untranslated_values() {
|
||||
// Values that are intentionally identical across languages.
|
||||
let shared = [
|
||||
"Olive Dark",
|
||||
"Olive Light",
|
||||
"English",
|
||||
"简体中文",
|
||||
"en-US",
|
||||
"zh-CN",
|
||||
"OpenGL",
|
||||
"Metal",
|
||||
"Vulkan",
|
||||
"MP4",
|
||||
];
|
||||
for (key, en_value) in EN {
|
||||
assert_ne!(
|
||||
*en_value, *key,
|
||||
"en-US value for {key} is still the raw key (untranslated)"
|
||||
);
|
||||
let zh_value = ZH
|
||||
.iter()
|
||||
.find(|(k, _)| *k == *key)
|
||||
.map(|(_, v)| *v)
|
||||
.unwrap();
|
||||
assert_ne!(
|
||||
zh_value, *key,
|
||||
"zh-CN value for {key} is still the raw key (untranslated)"
|
||||
);
|
||||
if *en_value == zh_value && !shared.contains(en_value) {
|
||||
panic!(
|
||||
"key {key} has identical en-US and zh-CN values ({en_value:?}); \
|
||||
one side is untranslated"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys are unique within each table (a duplicate would make `tr`'
|
||||
/// lookup order-dependent).
|
||||
#[test]
|
||||
|
||||
@@ -19,25 +19,37 @@
|
||||
//!
|
||||
//! This is the start of the Rust rewrite of `app/` (Qt): a gpui window with
|
||||
//! the main layout from the design (`design/`), dockable panels built from
|
||||
//! the `gpui_widgets` library, and an engine seam (`oakui`) that currently
|
||||
//! feeds demo data through [`MockEngine`](oakui::MockEngine).
|
||||
//! the `gpui_widgets` library, and an engine seam (`oakui`) with two
|
||||
//! backends: the mock ([`oakui::MockEngine`]) feeding demo data, and the
|
||||
//! real engine ([`oakui::RealEngine`]) bound to the built `liboakengine`
|
||||
//! dylib through its frozen `oakengine_*` C ABI only (project open/save,
|
||||
//! sequence/track/clip data, timeline edits through the oaktimeline edit
|
||||
//! commands, the oaktask export path, and the config C ABI).
|
||||
//!
|
||||
//! # Layout
|
||||
//!
|
||||
//! * [`app`] — the window shell: menu bar, dock layout, status bar, tick
|
||||
//! loop.
|
||||
//! * [`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.
|
||||
//! * [`panels`] — the dockable panels (viewers, timeline, inspector, ...).
|
||||
//! * [`oakui`] — the engine gateway trait, the mock implementation, and the
|
||||
//! pure view-state logic (timecode, transport).
|
||||
//! * [`oakui`] — the engine gateway trait, the mock + real implementations,
|
||||
//! and the pure view-state logic (timecode, transport).
|
||||
//!
|
||||
//! # Running
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --bin oakapp # the demo window
|
||||
//! cargo test # unit tests (timecode, transport)
|
||||
//! cargo run --bin oak-editor # the real engine, no project
|
||||
//! cargo run --bin oak-editor -- path/to/project.ove # open a project at startup
|
||||
//! cargo run --bin oak-editor -- --mock # the demo (mock) engine
|
||||
//! cargo test # unit tests (app + crates)
|
||||
//! ```
|
||||
//!
|
||||
//! The engine backend is selected at startup: the real engine by default,
|
||||
//! the mock with the `--mock` flag, `OAK_ENGINE=mock`, or the
|
||||
//! `mock-engine` cargo feature.
|
||||
|
||||
pub mod app;
|
||||
pub mod dialogs;
|
||||
pub mod i18n;
|
||||
pub mod oakui;
|
||||
pub mod panels;
|
||||
|
||||
@@ -36,8 +36,16 @@
|
||||
//! Everything here is plain Rust — no C ABI, no FFI. The C-ABI binding is a
|
||||
//! later concern of the real backend only.
|
||||
|
||||
use gpui::timeline::{Frame, FrameRate};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::effect_stack::{EffectStackDataSource, EffectStackEvent};
|
||||
use gpui::node_graph::{NodeGraphDataSource, NodeGraphEvent};
|
||||
use gpui::timeline::{ClipId, Frame, FrameRate, TimelineDataSource, TimelineEvent, TrackKind};
|
||||
use gpui::{App, Context, Entity, Pixels, RenderImage};
|
||||
use gpui_widgets::audio_meter::AudioMeterDataSource;
|
||||
use gpui_widgets::project_explorer::ProjectDataSource;
|
||||
use gpui_widgets::viewer::PlaybackClock;
|
||||
|
||||
/// A monitor the transport can address.
|
||||
///
|
||||
@@ -128,3 +136,151 @@ pub trait EngineGateway: Sized {
|
||||
/// periodic timer while any monitor is playing.
|
||||
fn tick(&mut self, cx: &mut gpui::Context<Self>);
|
||||
}
|
||||
|
||||
/// The transport clock type an engine drives its monitors with.
|
||||
///
|
||||
/// Each engine owns two clocks (source + program), one per
|
||||
/// [`Monitor`], and exposes them to the viewer widgets through the
|
||||
/// [`PlaybackClock`] trait.
|
||||
pub trait EngineClock: PlaybackClock + 'static {}
|
||||
|
||||
impl<T: PlaybackClock + 'static> EngineClock for T {}
|
||||
|
||||
/// The full app-facing engine surface: the gateway plus every widget
|
||||
/// data-source trait and the app-only operations (clocks, viewer frames,
|
||||
/// edits, undo/redo, file operations).
|
||||
///
|
||||
/// The app shell (`crate::app::OakApp`) and every panel are generic over
|
||||
/// `E: AppEngine`, so swapping the backend (mock vs real) is a one-line
|
||||
/// choice at startup — see [`crate::app::run`].
|
||||
pub trait AppEngine:
|
||||
EngineGateway
|
||||
+ TimelineDataSource
|
||||
+ EffectStackDataSource
|
||||
+ NodeGraphDataSource
|
||||
+ ProjectDataSource
|
||||
+ AudioMeterDataSource
|
||||
{
|
||||
/// The concrete transport-clock type (see [`EngineClock`]).
|
||||
type Clock: EngineClock;
|
||||
|
||||
/// Builds a fresh engine instance (no project open, or demo data for
|
||||
/// the mock).
|
||||
fn create(cx: &mut Context<Self>) -> Self;
|
||||
|
||||
/// The source monitor's clock entity.
|
||||
fn source_clock(&self) -> &Entity<Self::Clock>;
|
||||
|
||||
/// The program monitor's clock entity.
|
||||
fn program_clock(&self) -> &Entity<Self::Clock>;
|
||||
|
||||
/// The current playhead frame of `monitor`'s clock.
|
||||
fn clock_frame(&self, monitor: Monitor, cx: &App) -> Frame;
|
||||
|
||||
/// The CPU frame the viewers display for `monitor` (cached per playhead
|
||||
/// frame, so a paused viewer never regenerates its picture).
|
||||
fn cpu_frame(&self, monitor: Monitor, cx: &App) -> Arc<RenderImage>;
|
||||
|
||||
/// Adds a new empty track of the given kind (undoable where the backend
|
||||
/// supports it).
|
||||
fn add_track(&mut self, kind: TrackKind, cx: &mut Context<Self>);
|
||||
|
||||
/// Removes the track at display `index` (the index into
|
||||
/// [`TimelineDataSource::track`]; undoable where the backend supports
|
||||
/// it).
|
||||
fn remove_track(&mut self, index: usize, cx: &mut Context<Self>);
|
||||
|
||||
/// Sets the row height of every timeline track (timeline toolbar).
|
||||
fn set_track_height(&mut self, height: Pixels, cx: &mut Context<Self>);
|
||||
|
||||
/// Selects a material-bin entry (project-explorer "open").
|
||||
fn select_item(&mut self, id: u64, cx: &mut Context<Self>);
|
||||
|
||||
/// Applies an effect-stack edit request to the engine's model.
|
||||
fn apply_effect_event(&mut self, event: &EffectStackEvent, cx: &mut Context<Self>);
|
||||
|
||||
/// Applies a node-editor edit request to the engine's model.
|
||||
fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context<Self>);
|
||||
|
||||
/// Applies a timeline widget edit request (trim / move / playhead) to the
|
||||
/// engine's model. Edits are applied through the backend's edit commands
|
||||
/// with undo packaging; the playhead change is a plain seek.
|
||||
fn apply_timeline_event(&mut self, event: &TimelineEvent, cx: &mut Context<Self>);
|
||||
|
||||
/// Splits the clip with `clip` id at `time` (the razor action).
|
||||
fn split_clip(&mut self, clip: ClipId, time: Frame, cx: &mut Context<Self>);
|
||||
|
||||
/// Splits every clip whose range spans the program playhead (the razor
|
||||
/// tool's menu action).
|
||||
fn split_at_playhead(&mut self, cx: &mut Context<Self>);
|
||||
|
||||
/// Deletes the clip with `clip` id, rippling following content left when
|
||||
/// `ripple` is set.
|
||||
fn delete_clip(&mut self, clip: ClipId, ripple: bool, cx: &mut Context<Self>);
|
||||
|
||||
/// Whether the undo stack has an entry to undo.
|
||||
fn can_undo(&self) -> bool;
|
||||
|
||||
/// Whether the undo stack has an entry to redo.
|
||||
fn can_redo(&self) -> bool;
|
||||
|
||||
/// Steps the undo stack back one entry.
|
||||
fn undo(&mut self, cx: &mut Context<Self>);
|
||||
|
||||
/// Steps the undo stack forward one entry.
|
||||
fn redo(&mut self, cx: &mut Context<Self>);
|
||||
|
||||
/// Whether the project has unsaved changes.
|
||||
fn project_modified(&self) -> bool;
|
||||
|
||||
/// Starts a new blank project with a single default sequence.
|
||||
fn new_project(&mut self, cx: &mut Context<Self>);
|
||||
|
||||
/// Opens a project file. The format is dispatched by extension: `.ove`
|
||||
/// through the OVE serializer, `.otio` / `.fcpxml` through the oaktask
|
||||
/// interchange loader.
|
||||
fn open_project_path(&mut self, path: PathBuf, cx: &mut Context<Self>) -> Result<(), String>;
|
||||
|
||||
/// 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>;
|
||||
|
||||
/// Closes the current project, leaving the app with no sequence.
|
||||
fn close_project(&mut self, cx: &mut Context<Self>);
|
||||
|
||||
/// Starts an export of the current sequence in `format` to `path` and
|
||||
/// returns a session the host polls for progress and can cancel.
|
||||
///
|
||||
/// The export runs on a background thread; the returned
|
||||
/// [`ExportSession`] carries the event channel and the cancel handle.
|
||||
fn start_export(&mut self, format: i32, path: PathBuf) -> Result<ExportSession, String>;
|
||||
|
||||
/// The display name of the engine backend ("mock" / "real"), shown in
|
||||
/// the status bar.
|
||||
fn backend_name(&self) -> &'static str;
|
||||
}
|
||||
|
||||
/// A single progress event from a running export task.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ExportEvent {
|
||||
/// The task started.
|
||||
Started,
|
||||
/// Fraction done, in `0.0..=1.0`.
|
||||
Progress(f64),
|
||||
/// The task finished. `true` = succeeded; the string carries the failure
|
||||
/// message on error.
|
||||
Finished(bool, String),
|
||||
}
|
||||
|
||||
/// A running export: the event channel the host drains plus the cancel
|
||||
/// handle. Dropping the session does not abort the export thread; the
|
||||
/// thread owns the task and frees it when it finishes.
|
||||
pub struct ExportSession {
|
||||
/// The event receiver (the background thread's sender lives as long as
|
||||
/// the session's `cancel` side, so a dropped receiver just stops
|
||||
/// delivering).
|
||||
pub events: std::sync::mpsc::Receiver<ExportEvent>,
|
||||
/// Cancels the running export as soon as possible.
|
||||
pub cancel: Box<dyn Fn() + Send>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
// 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 app's pure-C surface of the `liboakengine` dylib.
|
||||
//!
|
||||
//! The app links the built `liboakengine.dylib` (see `build.rs`) and calls
|
||||
//! ONLY its `oakengine_*` C ABI — it never depends on the `oakengine` crate
|
||||
//! as an rlib. This module declares every exported function the real engine
|
||||
//! binding uses, with the exact signatures from the facade's `#[no_mangle]`
|
||||
//! exports (`crates/oakengine/src/*.rs`), plus the two `oaktask_*` module
|
||||
//! exports the dylib carries alongside the facade (interchange
|
||||
//! load/save getter and the task event subscription, see the comments
|
||||
//! below).
|
||||
//!
|
||||
//! The facade also exports the module C ABIs (`oakundo_*`, `oakcommon_*`,
|
||||
//! ...) inside the same dylib; the module functions the app needs beyond
|
||||
//! the facade's wrapping (`oaktask_load_take_project`,
|
||||
//! `oaktask_task_subscribe`) are declared here too and resolve from the
|
||||
//! dylib.
|
||||
//!
|
||||
//! # Handle layout mirrors
|
||||
//!
|
||||
//! The facade's opaque `OakEngine*` handle types are thin `#[repr(C)]`
|
||||
//! newtypes around one module [`CHandle`] value (see
|
||||
//! `crates/oakengine/src/handle.rs`), and boxes created with
|
||||
//! `box_handle`/`free_box` live in the heap. The dylib ABI passes those
|
||||
//! boxes as opaque pointers, but a pure-C consumer that needs to (a) build
|
||||
//! a project box from a module handle (interchange load) or (b) free a
|
||||
//! borrowed handle box (sequences / clips the facade returns but has no
|
||||
//! `oakengine_*_free` for) must know the box layout. The mirrors below
|
||||
//! reproduce it exactly (identical `repr(C)` field layout), so boxes
|
||||
//! created by the facade can be read/freed from the app and vice versa.
|
||||
//!
|
||||
//! The module handle type itself is the frozen `{ctx, addref, release,
|
||||
//! abi_version}` value handle (`include/common/handle.h`); `release` is
|
||||
//! what `free_box` calls before deallocating the box.
|
||||
|
||||
use std::ffi::{c_char, c_int, c_void};
|
||||
|
||||
/// The module value handle (`{ctx, addref, release, abi_version}`), mirror
|
||||
/// of `oakcore_rs::handle::CHandle` / `include/common/handle.h`.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct CHandle {
|
||||
/// Opaque refcounted box pointer.
|
||||
pub ctx: *mut c_void,
|
||||
/// Atomic increment.
|
||||
pub addref: Option<unsafe extern "C" fn(*mut c_void)>,
|
||||
/// Atomic decrement; destroys at zero.
|
||||
pub release: Option<unsafe extern "C" fn(*mut c_void)>,
|
||||
/// ABI version.
|
||||
pub abi_version: u32,
|
||||
}
|
||||
|
||||
impl CHandle {
|
||||
/// Whether this is the empty (zero) handle.
|
||||
pub fn is_null(&self) -> bool {
|
||||
self.ctx.is_null()
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque engine handle boxes, mirroring the facade's `engine_handle!`
|
||||
/// newtypes (one `CHandle` per box). Only the types the app actually
|
||||
/// touches are declared.
|
||||
macro_rules! engine_handle {
|
||||
($($name:ident),* $(,)?) => {
|
||||
$(
|
||||
/// Opaque engine handle: a `#[repr(C)]` box holding one module
|
||||
/// [`CHandle`].
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct $name {
|
||||
/// The wrapped module handle.
|
||||
pub handle: CHandle,
|
||||
}
|
||||
|
||||
impl HandleBox for $name {
|
||||
fn boxed_new(handle: CHandle) -> Self {
|
||||
$name { handle }
|
||||
}
|
||||
fn handle(&self) -> CHandle {
|
||||
self.handle
|
||||
}
|
||||
}
|
||||
)*
|
||||
};
|
||||
}
|
||||
|
||||
engine_handle! {
|
||||
OakEngineClip,
|
||||
OakEngineEncodingParams,
|
||||
OakEngineProject,
|
||||
OakEngineSequence,
|
||||
OakEngineTask,
|
||||
}
|
||||
|
||||
/// Uniform construction/extraction surface of the engine opaque boxes.
|
||||
pub trait HandleBox: Sized {
|
||||
/// Build the box from a module handle.
|
||||
fn boxed_new(handle: CHandle) -> Self;
|
||||
/// Extract the wrapped module handle (copy).
|
||||
fn handle(&self) -> CHandle;
|
||||
}
|
||||
|
||||
/// Allocate a heap box for a module handle and return its raw pointer.
|
||||
/// The box must later be released with [`free_box`] or a consuming
|
||||
/// `oakengine_*_free` export.
|
||||
///
|
||||
/// # Safety
|
||||
/// The handle must be a live module handle (e.g. from
|
||||
/// `oaktask_load_take_project`).
|
||||
pub unsafe fn box_handle<T: HandleBox>(handle: CHandle) -> *mut T {
|
||||
// SAFETY: the caller passes a live handle; the box is managed by the
|
||||
// C ABI consumers from here on.
|
||||
Box::into_raw(Box::new(T::boxed_new(handle)))
|
||||
}
|
||||
|
||||
/// Dereference an engine opaque box and copy out its module handle.
|
||||
/// Returns `None` for a NULL pointer or an empty handle.
|
||||
///
|
||||
/// # Safety
|
||||
/// `ptr` must point to a live box created by [`box_handle`] or by the
|
||||
/// facade (or be NULL).
|
||||
pub unsafe fn unbox<T: HandleBox>(ptr: *const T) -> Option<CHandle> {
|
||||
// SAFETY: see the function docs.
|
||||
if ptr.is_null() {
|
||||
return None;
|
||||
}
|
||||
let h = (*ptr).handle();
|
||||
if h.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(h)
|
||||
}
|
||||
}
|
||||
|
||||
/// Free a box created by [`box_handle`] (or returned by the facade):
|
||||
/// release the module handle (via its `release` function pointer) and
|
||||
/// deallocate the box. NULL and empty handles are no-ops. After the call
|
||||
/// `ptr` is dangling; the caller must not use it again.
|
||||
///
|
||||
/// # Safety
|
||||
/// `ptr` must be a pointer previously returned by [`box_handle`] or by the
|
||||
/// facade (or NULL) and must not be freed twice.
|
||||
pub unsafe fn free_box<T: HandleBox>(ptr: *mut T) {
|
||||
// SAFETY: see the function docs.
|
||||
if ptr.is_null() {
|
||||
return;
|
||||
}
|
||||
let handle = (*ptr).handle();
|
||||
if let Some(release) = handle.release {
|
||||
release(handle.ctx);
|
||||
}
|
||||
drop(Box::from_raw(ptr));
|
||||
}
|
||||
|
||||
/// `engine/include/oakengine/videoparams.h` — POD mirror of VideoParams'
|
||||
/// user-facing fields (Rust mirror of `oak_video_params`; the facade's
|
||||
/// `oakengine::common::OakVideoParamsPod`).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct OakVideoParamsPod {
|
||||
/// Width.
|
||||
pub width: c_int,
|
||||
/// Height.
|
||||
pub height: c_int,
|
||||
/// Frame duration numerator (e.g. 1001/30000 s).
|
||||
pub time_base_num: c_int,
|
||||
/// Frame duration denominator.
|
||||
pub time_base_den: c_int,
|
||||
/// PixelFormat::Format value.
|
||||
pub format: c_int,
|
||||
/// Pixel aspect numerator.
|
||||
pub pixel_aspect_num: c_int,
|
||||
/// Pixel aspect denominator.
|
||||
pub pixel_aspect_den: c_int,
|
||||
/// Interlacing value.
|
||||
pub interlacing: c_int,
|
||||
/// ColorRange value.
|
||||
pub color_range: c_int,
|
||||
/// Preview resolution divider (1 = full).
|
||||
pub divider: c_int,
|
||||
/// VideoParams::Type value.
|
||||
pub video_type: c_int,
|
||||
/// 0/1 premultiplied alpha.
|
||||
pub premultiplied_alpha: c_int,
|
||||
}
|
||||
|
||||
/// The task event callback the module subscription invokes on the task's
|
||||
/// own thread (`oaktask` C ABI, `include/task/task.h`).
|
||||
pub type OakTaskEventFn = unsafe extern "C" fn(event_id: c_int, value: f64, userdata: *mut c_void);
|
||||
|
||||
// The `oakengine_*` C ABI surface the app binds. Signatures mirror the
|
||||
// facade's `#[no_mangle] pub extern "C"` exports verbatim (the facade
|
||||
// declares a few of them without `unsafe`; calling any extern-block item
|
||||
// still requires an unsafe context on the current toolchain, so the call
|
||||
// sites in `real.rs` carry their own `unsafe` blocks).
|
||||
//
|
||||
// String outputs follow the engine buf/size convention: the return value
|
||||
// is the required length excluding the terminating NUL; negative values
|
||||
// are error codes.
|
||||
#[link(name = "oakengine")]
|
||||
unsafe extern "C" {
|
||||
// -- oakengine::codec (encoding formats + params) --
|
||||
|
||||
/// `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;
|
||||
/// `oakengine_encoding_format_extension` (buf/size).
|
||||
pub fn oakengine_encoding_format_extension(
|
||||
format: c_int,
|
||||
buf: *mut c_char,
|
||||
buf_size: c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_encoding_format_video_codec_at` — codec id, -1 invalid.
|
||||
pub fn oakengine_encoding_format_video_codec_at(format: c_int, index: c_int) -> c_int;
|
||||
/// `oakengine_encoding_format_audio_codec_at` — codec id, -1 invalid.
|
||||
pub fn oakengine_encoding_format_audio_codec_at(format: c_int, index: c_int) -> c_int;
|
||||
/// `oakengine_encoding_params_create` — owned params box.
|
||||
pub fn oakengine_encoding_params_create() -> *mut OakEngineEncodingParams;
|
||||
/// `oakengine_encoding_params_destroy` — consuming free.
|
||||
pub fn oakengine_encoding_params_destroy(params: *mut OakEngineEncodingParams);
|
||||
/// `oakengine_encoding_params_set_filename`.
|
||||
pub fn oakengine_encoding_params_set_filename(
|
||||
params: *mut OakEngineEncodingParams,
|
||||
filename: *const c_char,
|
||||
) -> c_int;
|
||||
/// `oakengine_encoding_params_set_format` — rejects out-of-range values.
|
||||
pub fn oakengine_encoding_params_set_format(
|
||||
params: *mut OakEngineEncodingParams,
|
||||
format: c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_encoding_params_enable_video` — copy the POD-carryable
|
||||
/// fields of `video` and enable the video track.
|
||||
pub fn oakengine_encoding_params_enable_video(
|
||||
params: *mut OakEngineEncodingParams,
|
||||
video: *const OakVideoParamsPod,
|
||||
codec: c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_encoding_params_enable_audio`.
|
||||
pub fn oakengine_encoding_params_enable_audio(
|
||||
params: *mut OakEngineEncodingParams,
|
||||
sample_rate: c_int,
|
||||
channel_layout: u64,
|
||||
sample_format: c_int,
|
||||
codec: c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_encoding_params_set_export_length`.
|
||||
pub fn oakengine_encoding_params_set_export_length(
|
||||
params: *mut OakEngineEncodingParams,
|
||||
num: c_int,
|
||||
den: c_int,
|
||||
);
|
||||
|
||||
// -- oakengine::common (config) --
|
||||
|
||||
/// `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;
|
||||
/// `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::node (project) --
|
||||
|
||||
/// `oakengine_project_create` — owned project box (no content yet).
|
||||
pub fn oakengine_project_create() -> *mut OakEngineProject;
|
||||
/// `oakengine_project_free` — consuming free of an owned project box.
|
||||
pub fn oakengine_project_free(self_: *mut OakEngineProject);
|
||||
/// `oakengine_project_new` — initialize a blank project.
|
||||
pub fn oakengine_project_new(self_: *mut OakEngineProject) -> c_int;
|
||||
/// `oakengine_project_load` — load from `path`; fills `err` (buf/size)
|
||||
/// on failure.
|
||||
pub fn oakengine_project_load(
|
||||
self_: *mut OakEngineProject,
|
||||
path: *const c_char,
|
||||
err: *mut c_char,
|
||||
err_size: c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_project_save` — save to `path` (or the recorded
|
||||
/// filename when NULL).
|
||||
pub fn oakengine_project_save(self_: *mut OakEngineProject, path: *const c_char) -> c_int;
|
||||
/// `oakengine_project_is_modified`.
|
||||
pub fn oakengine_project_is_modified(self_: *const OakEngineProject) -> c_int;
|
||||
/// `oakengine_project_name` (buf/size).
|
||||
pub fn oakengine_project_name(
|
||||
self_: *const OakEngineProject,
|
||||
buf: *mut c_char,
|
||||
buf_size: c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_project_filename` (buf/size).
|
||||
pub fn oakengine_project_filename(
|
||||
self_: *const OakEngineProject,
|
||||
buf: *mut c_char,
|
||||
buf_size: c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_project_footage_count`.
|
||||
pub fn oakengine_project_footage_count(self_: *const OakEngineProject) -> c_int;
|
||||
/// `oakengine_project_footage_filename` (buf/size).
|
||||
pub fn oakengine_project_footage_filename(
|
||||
self_: *const OakEngineProject,
|
||||
index: c_int,
|
||||
buf: *mut c_char,
|
||||
buf_size: c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_project_can_undo`.
|
||||
pub fn oakengine_project_can_undo(self_: *const OakEngineProject) -> c_int;
|
||||
/// `oakengine_project_can_redo`.
|
||||
pub fn oakengine_project_can_redo(self_: *const OakEngineProject) -> c_int;
|
||||
/// `oakengine_project_undo`.
|
||||
pub fn oakengine_project_undo(self_: *mut OakEngineProject) -> c_int;
|
||||
/// `oakengine_project_redo`.
|
||||
pub fn oakengine_project_redo(self_: *mut OakEngineProject) -> c_int;
|
||||
/// `oakengine_project_sequence_count`.
|
||||
pub fn oakengine_project_sequence_count(self_: *const OakEngineProject) -> c_int;
|
||||
/// `oakengine_project_sequence_at` — borrowed sequence box (free with
|
||||
/// [`free_box`]).
|
||||
pub fn oakengine_project_sequence_at(
|
||||
self_: *const OakEngineProject,
|
||||
index: c_int,
|
||||
) -> *mut OakEngineSequence;
|
||||
/// `oakengine_project_set_filename`.
|
||||
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;
|
||||
/// `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.
|
||||
pub fn oakengine_task_start_sync(task: *mut OakEngineTask) -> c_int;
|
||||
/// `oakengine_task_free` — consuming free of an owned task box.
|
||||
pub fn oakengine_task_free(task: *mut OakEngineTask) -> c_int;
|
||||
/// `oakengine_task_create_project_load_otio` — owned interchange load
|
||||
/// task box.
|
||||
pub fn oakengine_task_create_project_load_otio(filename: *const c_char) -> *mut OakEngineTask;
|
||||
/// `oakengine_task_create_project_save_otio` — owned interchange save
|
||||
/// task box.
|
||||
pub fn oakengine_task_create_project_save_otio(
|
||||
project: *mut OakEngineProject,
|
||||
) -> *mut OakEngineTask;
|
||||
/// `oakengine_task_create_export` — owned export task box.
|
||||
pub fn oakengine_task_create_export(
|
||||
sequence: *mut OakEngineSequence,
|
||||
params: *mut OakEngineEncodingParams,
|
||||
) -> *mut OakEngineTask;
|
||||
|
||||
// -- oakengine::timeline --
|
||||
|
||||
/// `oakengine_sequence_new` — in-memory sequence (facade scratch
|
||||
/// project).
|
||||
pub fn oakengine_sequence_new(
|
||||
project: *mut OakEngineProject,
|
||||
name: *const c_char,
|
||||
) -> *mut OakEngineSequence;
|
||||
/// `oakengine_sequence_name` (buf/size).
|
||||
pub fn oakengine_sequence_name(
|
||||
self_: *const OakEngineSequence,
|
||||
buf: *mut c_char,
|
||||
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;
|
||||
/// `oakengine_sequence_get_frame_rate` — num/den rational.
|
||||
pub fn oakengine_sequence_get_frame_rate(
|
||||
self_: *const OakEngineSequence,
|
||||
num: *mut c_int,
|
||||
den: *mut c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_sequence_get_video_params` — width/height/par.
|
||||
pub fn oakengine_sequence_get_video_params(
|
||||
self_: *const OakEngineSequence,
|
||||
width: *mut c_int,
|
||||
height: *mut c_int,
|
||||
par_num: *mut c_int,
|
||||
par_den: *mut c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_sequence_track_count` — per-type counts.
|
||||
pub fn oakengine_sequence_track_count(
|
||||
self_: *const OakEngineSequence,
|
||||
video: *mut c_int,
|
||||
audio: *mut c_int,
|
||||
subtitle: *mut c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_sequence_set_playhead`.
|
||||
pub fn oakengine_sequence_set_playhead(self_: *mut OakEngineSequence, timestamp: i64) -> c_int;
|
||||
/// `oakengine_sequence_add_track`.
|
||||
pub fn oakengine_sequence_add_track(self_: *mut OakEngineSequence, track_type: c_int) -> c_int;
|
||||
/// `oakengine_sequence_clip_count`.
|
||||
pub fn oakengine_sequence_clip_count(
|
||||
self_: *mut OakEngineSequence,
|
||||
track_type: c_int,
|
||||
track_index: c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_sequence_clip_at` — borrowed clip box (free with
|
||||
/// [`free_box`]).
|
||||
pub fn oakengine_sequence_clip_at(
|
||||
self_: *mut OakEngineSequence,
|
||||
track_type: c_int,
|
||||
track_index: c_int,
|
||||
clip_index: c_int,
|
||||
) -> *mut OakEngineClip;
|
||||
/// `oakengine_clip_get_range` — clip timeline range and media in-point
|
||||
/// as frame timestamps.
|
||||
pub fn oakengine_clip_get_range(
|
||||
self_: *const OakEngineClip,
|
||||
in_: *mut i64,
|
||||
out: *mut i64,
|
||||
media_in: *mut i64,
|
||||
) -> c_int;
|
||||
/// `oakengine_sequence_split_clip`.
|
||||
pub fn oakengine_sequence_split_clip(
|
||||
seq: *mut OakEngineSequence,
|
||||
track_type: c_int,
|
||||
track_index: c_int,
|
||||
clip_index: c_int,
|
||||
time: i64,
|
||||
) -> c_int;
|
||||
/// `oakengine_sequence_ripple_delete_clip`.
|
||||
pub fn oakengine_sequence_ripple_delete_clip(
|
||||
seq: *mut OakEngineSequence,
|
||||
track_type: c_int,
|
||||
track_index: c_int,
|
||||
clip_index: c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_clip_trim` — change the clip's timeline range.
|
||||
pub fn oakengine_clip_trim(clip: *mut OakEngineClip, new_in: i64, new_out: i64) -> c_int;
|
||||
/// `oakengine_sequence_delete_clips` — delete a clip array, optionally
|
||||
/// rippling; `rippled` reports the ripple length.
|
||||
pub fn oakengine_sequence_delete_clips(
|
||||
seq: *mut OakEngineSequence,
|
||||
clips: *mut *mut OakEngineClip,
|
||||
clip_count: c_int,
|
||||
ripple: c_int,
|
||||
ripple_ranges_ts: *const i64,
|
||||
ripple_range_count: c_int,
|
||||
rippled: *mut c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_sequence_remove_track`.
|
||||
pub fn oakengine_sequence_remove_track(
|
||||
seq: *mut OakEngineSequence,
|
||||
track_type: c_int,
|
||||
track_index: c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_track_get_height` — height in internal units.
|
||||
pub fn oakengine_track_get_height(
|
||||
seq: *const OakEngineSequence,
|
||||
track_type: c_int,
|
||||
track_index: c_int,
|
||||
height: *mut f64,
|
||||
) -> c_int;
|
||||
/// `oakengine_track_set_height` — height in internal units.
|
||||
pub fn oakengine_track_set_height(
|
||||
seq: *mut OakEngineSequence,
|
||||
track_type: c_int,
|
||||
track_index: c_int,
|
||||
height: f64,
|
||||
) -> c_int;
|
||||
|
||||
/// `oakengine_track_height_internal_to_pixels`.
|
||||
pub fn oakengine_track_height_internal_to_pixels(height: f64) -> c_int;
|
||||
/// `oakengine_track_height_pixels_to_internal`.
|
||||
pub fn oakengine_track_height_pixels_to_internal(pixels: c_int) -> f64;
|
||||
|
||||
// -- oaktask module C ABI (carried by the dylib) --
|
||||
|
||||
/// `oaktask_load_take_project` — take the project an interchange
|
||||
/// load/load-otio task produced (ownership moves to the caller).
|
||||
pub fn oaktask_load_take_project(t: CHandle) -> CHandle;
|
||||
/// `oaktask_task_subscribe` — register the task event callback
|
||||
/// (`OAKTASK_EVENT_STARTED`=0, `OAKTASK_EVENT_PROGRESS`=1,
|
||||
/// `OAKTASK_EVENT_FINISHED`=2).
|
||||
pub fn oaktask_task_subscribe(
|
||||
t: CHandle,
|
||||
cb: Option<OakTaskEventFn>,
|
||||
userdata: *mut c_void,
|
||||
) -> i64;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// 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 synthetic CPU viewer frame both engines display.
|
||||
//!
|
||||
//! The real engine delivers frames through the render worker (a separate
|
||||
//! process speaking the NDJSON control-plane protocol, `oakengine::worker`),
|
||||
//! which is out of scope for this increment. Until that transport is wired,
|
||||
//! both the mock and the real engine feed the viewers the same SMPTE-style
|
||||
//! test pattern, so playback is visibly moving while the engine metadata
|
||||
//! (project / sequence / tracks) comes from the real facade in real mode.
|
||||
|
||||
use gpui::timeline::Frame;
|
||||
use gpui::RenderImage;
|
||||
|
||||
/// Width of the synthetic test frame (a small proxy size; the real engine
|
||||
/// will deliver full-resolution frames).
|
||||
pub(crate) const SYNTH_FRAME_WIDTH: u32 = 384;
|
||||
/// Height of the synthetic test frame.
|
||||
pub(crate) const SYNTH_FRAME_HEIGHT: u32 = 216;
|
||||
|
||||
/// Generates a synthetic test frame: SMPTE-style color bars with a white
|
||||
/// sweep whose x position follows `frame`, so transport playback shows up as
|
||||
/// motion across the picture.
|
||||
///
|
||||
/// Samples are computed as F32 RGBA (mirroring the real engine's pixel
|
||||
/// pipeline) and downconverted to BGRA8 for the viewer's CPU-frame path.
|
||||
pub(crate) fn synthetic_frame(frame: Frame) -> RenderImage {
|
||||
let width = SYNTH_FRAME_WIDTH;
|
||||
let height = SYNTH_FRAME_HEIGHT;
|
||||
|
||||
// F32 RGBA samples, then quantized to BGRA8 for the sprite atlas.
|
||||
let mut samples = vec![0.0f32; (width * height * 4) as usize];
|
||||
// SMPTE bars: 75% white, yellow, cyan, green, magenta, red, blue.
|
||||
let bars: [(f32, f32, f32); 7] = [
|
||||
(1.0, 1.0, 1.0),
|
||||
(1.0, 1.0, 0.0),
|
||||
(0.0, 1.0, 1.0),
|
||||
(0.0, 1.0, 0.0),
|
||||
(1.0, 0.0, 1.0),
|
||||
(1.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 1.0),
|
||||
];
|
||||
// Bottom strip: blue, magenta, 75% white, black.
|
||||
let strip: [(f32, f32, f32); 4] = [
|
||||
(0.0, 0.0, 1.0),
|
||||
(1.0, 0.0, 1.0),
|
||||
(0.75, 0.75, 0.75),
|
||||
(0.0, 0.0, 0.0),
|
||||
];
|
||||
// The sweep moves 6 px per frame and wraps around the width, so
|
||||
// transport playback shows up as motion across the picture.
|
||||
let sweep = (frame.0 as f32 * 6.0) % width as f32;
|
||||
let bars_top = height as f32 * 0.66;
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let in_sweep = (x as f32 - sweep).abs() < 6.0;
|
||||
let color = if in_sweep {
|
||||
(1.0, 1.0, 1.0)
|
||||
} else if (y as f32) < bars_top {
|
||||
bars[((x as f32 / width as f32) * 7.0) as usize]
|
||||
} else {
|
||||
strip[((x as f32 / width as f32) * 4.0) as usize]
|
||||
};
|
||||
let i = ((y * width + x) * 4) as usize;
|
||||
samples[i] = color.0;
|
||||
samples[i + 1] = color.1;
|
||||
samples[i + 2] = color.2;
|
||||
samples[i + 3] = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
let mut bytes = Vec::with_capacity((width * height * 4) as usize);
|
||||
for i in (0..samples.len()).step_by(4) {
|
||||
bytes.push((samples[i + 2] * 255.0) as u8); // B
|
||||
bytes.push((samples[i + 1] * 255.0) as u8); // G
|
||||
bytes.push((samples[i] * 255.0) as u8); // R
|
||||
bytes.push((samples[i + 3] * 255.0) as u8); // A
|
||||
}
|
||||
let buffer = image::RgbaImage::from_raw(width, height, bytes).expect("synthetic frame");
|
||||
RenderImage::new(smallvec::SmallVec::from_elem(image::Frame::new(buffer), 1))
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// 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/>.
|
||||
|
||||
//! In-process stand-ins for the C++ host symbols the module crates call.
|
||||
//!
|
||||
//! The oakcodec / oakcommon crates reference a handful of symbols that in
|
||||
//! the real desktop product live in the C++ host process (`liboakcore` and
|
||||
//! `ffmpeg_bridge`): `oakcore_audioparams_*`, `oakcore_rational_*` and
|
||||
//! `fb_find_best_pix_fmt_of_list`. The facade's own test binaries provide
|
||||
//! the same stubs in `crates/oakengine/tests/common/mod.rs`; this module is
|
||||
//! the equivalent for the app binary, so linking oakengine (and through it
|
||||
//! oakcodec/oakcommon) never leaves undefined symbols.
|
||||
//!
|
||||
//! The stubs are small, in-memory and functional enough for the app's use:
|
||||
//! the audio-params handle carries the fields the codec probes read back,
|
||||
//! and the rational helpers store the (num, den) pair behind an opaque
|
||||
//! pointer.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::{c_char, c_int, c_void};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// Opaque `OakAudioParams` handle type (the real one lives in liboakcore).
|
||||
#[repr(C)]
|
||||
pub struct OakAudioParams {
|
||||
_opaque: [u8; 0],
|
||||
}
|
||||
|
||||
/// Per-`OakAudioParams` backing state.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
struct MockAudioParams {
|
||||
sample_rate: i32,
|
||||
channel_layout: u64,
|
||||
format: i32,
|
||||
stream_index: i32,
|
||||
duration: i64,
|
||||
time_base_num: i32,
|
||||
time_base_den: i32,
|
||||
}
|
||||
|
||||
fn audio_params_store() -> &'static Mutex<HashMap<usize, MockAudioParams>> {
|
||||
static S: OnceLock<Mutex<HashMap<usize, MockAudioParams>>> = OnceLock::new();
|
||||
S.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
fn audio_params_get(ctx: *const c_void) -> MockAudioParams {
|
||||
let store = audio_params_store().lock().unwrap();
|
||||
store.get(&(ctx as usize)).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn audio_params_set(ctx: *mut c_void, f: impl FnOnce(&mut MockAudioParams)) {
|
||||
let mut store = audio_params_store().lock().unwrap();
|
||||
if let Some(p) = store.get_mut(&(ctx as usize)) {
|
||||
f(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-`OakRational` backing state (an owned `(num, den)` pair).
|
||||
fn rational_store() -> &'static Mutex<HashMap<usize, (i32, i32)>> {
|
||||
static S: OnceLock<Mutex<HashMap<usize, (i32, i32)>>> = OnceLock::new();
|
||||
S.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn oakcore_audioparams_create(
|
||||
sample_rate: c_int,
|
||||
channel_layout: u64,
|
||||
format: c_int,
|
||||
) -> *mut OakAudioParams {
|
||||
let p = MockAudioParams {
|
||||
sample_rate,
|
||||
channel_layout,
|
||||
format,
|
||||
stream_index: 0,
|
||||
duration: 0,
|
||||
time_base_num: 1,
|
||||
time_base_den: sample_rate,
|
||||
};
|
||||
let raw = Box::into_raw(Box::new(p.clone()));
|
||||
audio_params_store().lock().unwrap().insert(raw as usize, p);
|
||||
raw as *mut OakAudioParams
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn oakcore_audioparams_free(params: *mut OakAudioParams) {
|
||||
if params.is_null() {
|
||||
return;
|
||||
}
|
||||
audio_params_store()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&(params as usize));
|
||||
// SAFETY: produced by `oakcore_audioparams_create`; we hold the only
|
||||
// reference after removal.
|
||||
unsafe { drop(Box::from_raw(params as *mut MockAudioParams)) };
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn oakcore_audioparams_sample_rate(params: *const OakAudioParams) -> c_int {
|
||||
audio_params_get(params as *const c_void).sample_rate
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn oakcore_audioparams_set_sample_rate(
|
||||
params: *mut OakAudioParams,
|
||||
sample_rate: c_int,
|
||||
) {
|
||||
audio_params_set(params as *mut c_void, |p| p.sample_rate = sample_rate);
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn oakcore_audioparams_channel_layout(params: *const OakAudioParams) -> u64 {
|
||||
audio_params_get(params as *const c_void).channel_layout
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn oakcore_audioparams_set_channel_layout(params: *mut OakAudioParams, layout: u64) {
|
||||
audio_params_set(params as *mut c_void, |p| p.channel_layout = layout);
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn oakcore_audioparams_set_time_base(
|
||||
params: *mut OakAudioParams,
|
||||
num: c_int,
|
||||
den: c_int,
|
||||
) {
|
||||
audio_params_set(params as *mut c_void, |p| {
|
||||
p.time_base_num = num;
|
||||
p.time_base_den = den;
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn oakcore_audioparams_set_format(params: *mut OakAudioParams, format: c_int) {
|
||||
audio_params_set(params as *mut c_void, |p| p.format = format);
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn oakcore_audioparams_set_stream_index(params: *mut OakAudioParams, index: c_int) {
|
||||
audio_params_set(params as *mut c_void, |p| p.stream_index = index);
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn oakcore_audioparams_set_duration(params: *mut OakAudioParams, duration: i64) {
|
||||
audio_params_set(params as *mut c_void, |p| p.duration = duration);
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn oakcore_audioparams_channel_count(params: *const OakAudioParams) -> c_int {
|
||||
audio_params_get(params as *const c_void)
|
||||
.channel_layout
|
||||
.count_ones() as c_int
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn oakcore_audioparams_format(params: *const OakAudioParams) -> c_int {
|
||||
audio_params_get(params as *const c_void).format
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn oakcore_audioparams_stream_index(params: *const OakAudioParams) -> c_int {
|
||||
audio_params_get(params as *const c_void).stream_index
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn oakcore_audioparams_duration(params: *const OakAudioParams) -> i64 {
|
||||
audio_params_get(params as *const c_void).duration
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn oakcore_audioparams_is_valid(params: *const OakAudioParams) -> c_int {
|
||||
let p = audio_params_get(params as *const c_void);
|
||||
(p.sample_rate > 0 && p.channel_layout != 0 && p.format >= 0) as c_int
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn oakcore_audioparams_time_base(params: *const OakAudioParams) -> *mut c_void {
|
||||
let p = audio_params_get(params as *const c_void);
|
||||
let r = (p.time_base_num, p.time_base_den);
|
||||
let raw = Box::into_raw(Box::new(r));
|
||||
rational_store().lock().unwrap().insert(raw as usize, r);
|
||||
raw as *mut c_void
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn oakcore_rational_numerator(rational: *const c_void) -> c_int {
|
||||
rational_store()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&(rational as usize))
|
||||
.map(|r| r.0)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn oakcore_rational_denominator(rational: *const c_void) -> c_int {
|
||||
rational_store()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&(rational as usize))
|
||||
.map(|r| r.1)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn oakcore_rational_free(rational: *mut c_void) {
|
||||
if rational.is_null() {
|
||||
return;
|
||||
}
|
||||
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))) };
|
||||
}
|
||||
|
||||
/// `fb_find_best_pix_fmt_of_list` — pick the entry of a
|
||||
/// `FB_PIX_FMT_NONE`-terminated list closest to `pix_fmt` (the real
|
||||
/// 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 {
|
||||
if list.is_null() {
|
||||
return 0;
|
||||
}
|
||||
let mut i = 0;
|
||||
let mut first: c_int = 0;
|
||||
loop {
|
||||
// SAFETY: `list` is `FB_PIX_FMT_NONE`-terminated; the read is within
|
||||
// bounds by construction.
|
||||
let entry = unsafe { *list.add(i) };
|
||||
if entry == 0 {
|
||||
return first;
|
||||
}
|
||||
if i == 0 {
|
||||
first = entry;
|
||||
}
|
||||
if entry == pix_fmt {
|
||||
return entry;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// 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/>.
|
||||
|
||||
//! Theme-aware toolbar icons.
|
||||
//!
|
||||
//! The toolbar/transport icons are the PNGs pulled from the legacy C++ app
|
||||
//! (`app/ui/style/olive-{dark,light}/png` in the pre-Rust history), stored in
|
||||
//! `assets/icons/{dark,light}/`. Each theme ships its own glyph color (white
|
||||
//! on dark, black on light), so the active [`OakTheme`] picks the family.
|
||||
//!
|
||||
//! Icons render on a 16px logical grid from the 32px (2×) files; buttons give
|
||||
//! them a 24px hit target and a localized tooltip.
|
||||
//!
|
||||
//! [`OakTheme`]: gpui_widgets::theme::OakTheme
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gpui::App;
|
||||
|
||||
/// Icon file names (without the `.png` suffix), mirroring the legacy set.
|
||||
pub const ICON_ARROW: &str = "arrow";
|
||||
pub const ICON_RAZOR: &str = "razor";
|
||||
pub const ICON_RIPPLE: &str = "ripple";
|
||||
pub const ICON_SLIP: &str = "slip";
|
||||
pub const ICON_ROLLING: &str = "rolling";
|
||||
pub const ICON_ZOOM: &str = "zoomin";
|
||||
pub const ICON_ZOOM_IN: &str = "zoomin";
|
||||
pub const ICON_ZOOM_OUT: &str = "zoomout";
|
||||
pub const ICON_SLIDE: &str = "slide";
|
||||
pub const ICON_TRACK_SELECT: &str = "track-tool";
|
||||
pub const ICON_SNAP: &str = "magnet";
|
||||
pub const ICON_PLAY: &str = "play";
|
||||
pub const ICON_PAUSE: &str = "pause";
|
||||
pub const ICON_PREV: &str = "prev";
|
||||
pub const ICON_NEXT: &str = "next";
|
||||
pub const ICON_REW: &str = "rew";
|
||||
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"))
|
||||
}
|
||||
|
||||
/// 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,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -49,7 +50,8 @@ use gpui::node_graph::{
|
||||
PortDataType, PortId, PortKind,
|
||||
};
|
||||
use gpui::timeline::{
|
||||
ClipData, ClipId, Frame, FrameRange, FrameRate, TimelineDataSource, TrackData, TrackKind,
|
||||
ClipData, ClipId, Frame, FrameRange, FrameRate, TimelineDataSource, TimelineEvent, TrackData,
|
||||
TrackKind, TrimEdge,
|
||||
};
|
||||
use gpui::{
|
||||
hsla, point, prelude::*, px, App, Context, Entity, Hsla, Pixels, Point, RenderImage,
|
||||
@@ -59,18 +61,14 @@ use gpui_widgets::audio_meter::AudioMeterDataSource;
|
||||
use gpui_widgets::project_explorer::{ProjectDataSource, ProjectEntry};
|
||||
use gpui_widgets::viewer::PlaybackClock;
|
||||
|
||||
use super::engine::{EngineGateway, Monitor, Project, Sequence, VideoFormat};
|
||||
use super::engine::{
|
||||
AppEngine, EngineGateway, ExportEvent, ExportSession, Monitor, Project, Sequence, VideoFormat,
|
||||
};
|
||||
use super::transport::TransportState;
|
||||
|
||||
/// The demo sequence length: 00:04:18:18 at 25 fps.
|
||||
const SEQUENCE_LENGTH: i64 = 6468;
|
||||
|
||||
/// The synthetic viewer test frame is rendered at a small proxy size (the
|
||||
/// real engine will deliver full-resolution frames; the mock only needs to
|
||||
/// prove the CPU-frame path end to end).
|
||||
const SYNTH_FRAME_WIDTH: u32 = 384;
|
||||
const SYNTH_FRAME_HEIGHT: u32 = 216;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clocks
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -902,6 +900,52 @@ impl MockEngine {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Finds the (track, clip) position of `clip` in the display list.
|
||||
fn mock_clip_position(&self, clip: ClipId) -> Option<(usize, usize)> {
|
||||
self.tracks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(track_index, track)| {
|
||||
track
|
||||
.clips
|
||||
.iter()
|
||||
.position(|c| c.id() == clip)
|
||||
.map(|clip_index| (track_index, clip_index))
|
||||
})
|
||||
}
|
||||
|
||||
/// A clip id larger than every existing one (for splits).
|
||||
fn next_mock_clip_id(&self) -> u64 {
|
||||
self.tracks
|
||||
.iter()
|
||||
.flat_map(|t| t.clips.iter())
|
||||
.map(|c| c.id().0)
|
||||
.max()
|
||||
.map(|max| max + 1)
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
/// Splits the clip at (track, clip) position into two at `time`
|
||||
/// (mock-apply, not undoable).
|
||||
fn split_mock_clip(&mut self, track: usize, index: usize, time: Frame) {
|
||||
let source = &self.tracks[track].clips[index];
|
||||
if time.0 <= source.range.start.0 || time.0 >= source.range.end.0 {
|
||||
return;
|
||||
}
|
||||
let new_id = ClipId(self.next_mock_clip_id());
|
||||
let second = MockClip {
|
||||
id: new_id,
|
||||
range: FrameRange::new(time, source.range.end),
|
||||
media_in: Frame(source.media_in.0 + (time.0 - source.range.start.0)),
|
||||
label: source.label.clone(),
|
||||
color: source.color,
|
||||
};
|
||||
let mut first = self.tracks[track].clips[index].clone();
|
||||
first.range = FrameRange::new(source.range.start, time);
|
||||
self.tracks[track].clips[index] = first;
|
||||
self.tracks[track].clips.insert(index + 1, second);
|
||||
}
|
||||
|
||||
/// The demo audio levels: animated while the program monitor plays.
|
||||
fn meter_levels(&self) -> Vec<f32> {
|
||||
if !self.program_playing {
|
||||
@@ -999,6 +1043,228 @@ impl EngineGateway for MockEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppEngine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl AppEngine for MockEngine {
|
||||
type Clock = MockClock;
|
||||
|
||||
fn create(cx: &mut Context<Self>) -> Self {
|
||||
Self::demo(cx)
|
||||
}
|
||||
|
||||
fn source_clock(&self) -> &Entity<Self::Clock> {
|
||||
&self.source_clock
|
||||
}
|
||||
|
||||
fn program_clock(&self) -> &Entity<Self::Clock> {
|
||||
&self.program_clock
|
||||
}
|
||||
|
||||
fn clock_frame(&self, monitor: Monitor, cx: &App) -> Frame {
|
||||
self.clock_frame(monitor, cx)
|
||||
}
|
||||
|
||||
fn cpu_frame(&self, monitor: Monitor, cx: &App) -> Arc<RenderImage> {
|
||||
self.cpu_frame(monitor, cx)
|
||||
}
|
||||
|
||||
fn add_track(&mut self, kind: TrackKind, cx: &mut Context<Self>) {
|
||||
self.add_track(kind, cx);
|
||||
}
|
||||
|
||||
fn remove_track(&mut self, index: usize, cx: &mut Context<Self>) {
|
||||
if index < self.tracks.len() {
|
||||
self.tracks.remove(index);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_track_height(&mut self, height: Pixels, cx: &mut Context<Self>) {
|
||||
self.set_track_height(height, cx);
|
||||
}
|
||||
|
||||
fn select_item(&mut self, id: u64, cx: &mut Context<Self>) {
|
||||
self.select_item(id, cx);
|
||||
}
|
||||
|
||||
fn apply_effect_event(&mut self, event: &EffectStackEvent, cx: &mut Context<Self>) {
|
||||
self.apply_effect_event(event, cx);
|
||||
}
|
||||
|
||||
fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context<Self>) {
|
||||
self.apply_node_graph_event(event, cx);
|
||||
}
|
||||
|
||||
fn apply_timeline_event(&mut self, event: &TimelineEvent, cx: &mut Context<Self>) {
|
||||
match event {
|
||||
TimelineEvent::PlayheadChanged(frame) => {
|
||||
let current = self.clock_frame(Monitor::Program, cx);
|
||||
if *frame != current {
|
||||
self.request_frame(Monitor::Program, *frame, cx);
|
||||
}
|
||||
}
|
||||
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 {
|
||||
gpui::timeline::TrimEdge::Start => {
|
||||
let delta = new_frame.0 - clip.range.start.0;
|
||||
clip.range.start = *new_frame;
|
||||
clip.media_in = Frame(clip.media_in.0 + delta);
|
||||
}
|
||||
gpui::timeline::TrimEdge::End => {
|
||||
clip.range.end = *new_frame;
|
||||
}
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
TimelineEvent::ClipMoveRequested { clip, new_track, new_start } => {
|
||||
let Some((track, index)) = self.mock_clip_position(*clip) else {
|
||||
return;
|
||||
};
|
||||
let mut clip = self.tracks[track].clips.remove(index);
|
||||
let length = clip.range.end.0 - clip.range.start.0;
|
||||
clip.range = FrameRange::new(*new_start, Frame(new_start.0 + length));
|
||||
if *new_track < self.tracks.len() {
|
||||
self.tracks[*new_track].clips.push(clip);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
TimelineEvent::TrackHeightChanged { track, height } => {
|
||||
if let Some(track) = self.tracks.get_mut(*track) {
|
||||
track.height = *height;
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
TimelineEvent::SelectionChanged
|
||||
| TimelineEvent::TrackSelected { .. }
|
||||
| TimelineEvent::TransitionChanged { .. }
|
||||
| TimelineEvent::ZoomChanged(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn split_clip(&mut self, clip: ClipId, time: Frame, cx: &mut Context<Self>) {
|
||||
let Some((track, index)) = self.mock_clip_position(clip) else {
|
||||
return;
|
||||
};
|
||||
self.split_mock_clip(track, index, time);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn split_at_playhead(&mut self, cx: &mut Context<Self>) {
|
||||
let frame = self.clock_frame(Monitor::Program, cx);
|
||||
let targets: Vec<(usize, usize)> = self
|
||||
.tracks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(track_index, track)| {
|
||||
track
|
||||
.clips
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, clip)| {
|
||||
clip.range.start.0 < frame.0 && frame.0 < clip.range.end.0
|
||||
})
|
||||
.map(|(clip_index, _)| (track_index, clip_index))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
for (track, index) in targets {
|
||||
self.split_mock_clip(track, index, frame);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn delete_clip(&mut self, clip: ClipId, ripple: bool, cx: &mut Context<Self>) {
|
||||
let Some((track, index)) = self.mock_clip_position(clip) else {
|
||||
return;
|
||||
};
|
||||
let removed = self.tracks[track].clips.remove(index);
|
||||
if ripple {
|
||||
// Shift the following clips on the same track left by the removed
|
||||
// clip's length (the mock ripples one track, not the whole
|
||||
// sequence).
|
||||
let shift = removed.range.end.0 - removed.range.start.0;
|
||||
for later in &mut self.tracks[track].clips[index..] {
|
||||
later.range = FrameRange::new(
|
||||
Frame(later.range.start.0 - shift),
|
||||
Frame(later.range.end.0 - shift),
|
||||
);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn can_undo(&self) -> bool {
|
||||
// The mock keeps no undo stack; the real engine's facade stack drives
|
||||
// the Edit menu in real mode.
|
||||
false
|
||||
}
|
||||
|
||||
fn can_redo(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn undo(&mut self, cx: &mut Context<Self>) {
|
||||
println!("[mock engine] undo: no undo stack in mock mode");
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn redo(&mut self, cx: &mut Context<Self>) {
|
||||
println!("[mock engine] redo: no undo stack in mock mode");
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn project_modified(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn new_project(&mut self, cx: &mut Context<Self>) {
|
||||
println!("[mock engine] new project: demo data stays (mock mode)");
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn open_project_path(&mut self, path: PathBuf, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
self.open_project(path, cx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
fn close_project(&mut self, cx: &mut Context<Self>) {
|
||||
println!("[mock engine] close project: demo data stays (mock mode)");
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
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>();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(ExportEvent::Started);
|
||||
for (delay_ms, fraction) in [(150u64, 0.33), (300, 0.66), (450, 1.0)] {
|
||||
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
|
||||
let _ = tx.send(ExportEvent::Progress(fraction));
|
||||
}
|
||||
let _ = tx.send(ExportEvent::Finished(true, String::new()));
|
||||
});
|
||||
Ok(ExportSession {
|
||||
events: rx,
|
||||
cancel: Box::new(|| {}),
|
||||
})
|
||||
}
|
||||
|
||||
fn backend_name(&self) -> &'static str {
|
||||
"mock"
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data-source traits
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1087,8 +1353,8 @@ impl NodeGraphDataSource for MockEngine {
|
||||
impl ProjectDataSource for MockEngine {
|
||||
fn roots(&self) -> Vec<ProjectEntry> {
|
||||
vec![
|
||||
ProjectEntry::new(1, "素材", true),
|
||||
ProjectEntry::new(2, "音乐", true),
|
||||
ProjectEntry::new(1, crate::i18n::tr("bin.footage"), true),
|
||||
ProjectEntry::new(2, crate::i18n::tr("bin.music"), true),
|
||||
ProjectEntry::new(3, "第一稿.mp4", false),
|
||||
ProjectEntry::new(4, "aaa.ove", false),
|
||||
]
|
||||
@@ -1137,7 +1403,8 @@ impl MockEngine {
|
||||
/// The synthetic CPU test frame for `monitor`, cached per playhead frame so
|
||||
/// a paused viewer never regenerates its picture. This is the frame the
|
||||
/// source/program viewers display through [`ViewerWidget::set_cpu_frame`],
|
||||
/// proving the CPU-frame path end to end before the real engine lands.
|
||||
/// proving the CPU-frame path end to end (the real engine shows the same
|
||||
/// pattern until the render-worker frame transport is bound).
|
||||
pub fn cpu_frame(&self, monitor: Monitor, cx: &App) -> Arc<RenderImage> {
|
||||
let frame = self.clock_frame(monitor, cx);
|
||||
let mut cache = self.cpu_frame_cache.lock().unwrap();
|
||||
@@ -1146,74 +1413,10 @@ impl MockEngine {
|
||||
return image.clone();
|
||||
}
|
||||
}
|
||||
let image = Arc::new(self.synthetic_frame(frame));
|
||||
let image = Arc::new(crate::oakui::frames::synthetic_frame(frame));
|
||||
cache.insert(monitor, (frame.0, image.clone()));
|
||||
image
|
||||
}
|
||||
|
||||
/// Generates a synthetic test frame: SMPTE-style color bars with a white
|
||||
/// sweep whose x position follows `frame`, so playback is visibly moving.
|
||||
///
|
||||
/// Samples are computed as F32 RGBA (mirroring the real engine's pixel
|
||||
/// pipeline) and downconverted to BGRA8 for the viewer's CPU-frame path.
|
||||
/// The picture is rendered at a small proxy size ([`SYNTH_FRAME_WIDTH`] ×
|
||||
/// [`SYNTH_FRAME_HEIGHT`]); the real engine delivers full resolution.
|
||||
fn synthetic_frame(&self, frame: Frame) -> RenderImage {
|
||||
let width = SYNTH_FRAME_WIDTH;
|
||||
let height = SYNTH_FRAME_HEIGHT;
|
||||
|
||||
// F32 RGBA samples, then quantized to BGRA8 for the sprite atlas.
|
||||
let mut samples = vec![0.0f32; (width * height * 4) as usize];
|
||||
// SMPTE bars: 75% white, yellow, cyan, green, magenta, red, blue.
|
||||
let bars: [(f32, f32, f32); 7] = [
|
||||
(1.0, 1.0, 1.0),
|
||||
(1.0, 1.0, 0.0),
|
||||
(0.0, 1.0, 1.0),
|
||||
(0.0, 1.0, 0.0),
|
||||
(1.0, 0.0, 1.0),
|
||||
(1.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 1.0),
|
||||
];
|
||||
// Bottom strip: blue, magenta, 75% white, black.
|
||||
let strip: [(f32, f32, f32); 4] = [
|
||||
(0.0, 0.0, 1.0),
|
||||
(1.0, 0.0, 1.0),
|
||||
(0.75, 0.75, 0.75),
|
||||
(0.0, 0.0, 0.0),
|
||||
];
|
||||
// The sweep moves 6 px per frame and wraps around the width, so
|
||||
// transport playback shows up as motion across the picture.
|
||||
let sweep = (frame.0 as f32 * 6.0) % width as f32;
|
||||
let bars_top = height as f32 * 0.66;
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let in_sweep = (x as f32 - sweep).abs() < 6.0;
|
||||
let color = if in_sweep {
|
||||
(1.0, 1.0, 1.0)
|
||||
} else if (y as f32) < bars_top {
|
||||
bars[((x as f32 / width as f32) * 7.0) as usize]
|
||||
} else {
|
||||
strip[((x as f32 / width as f32) * 4.0) as usize]
|
||||
};
|
||||
let i = ((y * width + x) * 4) as usize;
|
||||
samples[i] = color.0;
|
||||
samples[i + 1] = color.1;
|
||||
samples[i + 2] = color.2;
|
||||
samples[i + 3] = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
let mut bytes = Vec::with_capacity((width * height * 4) as usize);
|
||||
for i in (0..samples.len()).step_by(4) {
|
||||
bytes.push((samples[i + 2] * 255.0) as u8); // B
|
||||
bytes.push((samples[i + 1] * 255.0) as u8); // G
|
||||
bytes.push((samples[i] * 255.0) as u8); // R
|
||||
bytes.push((samples[i + 3] * 255.0) as u8); // A
|
||||
}
|
||||
let buffer = image::RgbaImage::from_raw(width, height, bytes).expect("synthetic frame");
|
||||
RenderImage::new(smallvec::SmallVec::from_elem(image::Frame::new(buffer), 1))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1303,16 +1506,92 @@ mod tests {
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn timeline_edits_are_requests_not_applied(cx: &mut TestAppContext) {
|
||||
async fn timeline_edits_are_applied_to_the_mock_model(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
// The demo timeline model ignores edit requests: the mock keeps
|
||||
// its clips where they are until a real engine applies them.
|
||||
let before = engine.read(app).track(1).expect("V1").clips().len();
|
||||
// (Nothing to assert beyond stability: the widget never mutates
|
||||
// the data source directly — reads stay stable across reads.)
|
||||
let after = engine.read(app).track(1).expect("V1").clips().len();
|
||||
assert_eq!(before, after);
|
||||
|
||||
// Trim the V1 "B-roll.mp4" clip (id 12, 240–600) in by 40 frames.
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.apply_timeline_event(
|
||||
&TimelineEvent::ClipTrimRequested {
|
||||
clip: ClipId(12),
|
||||
edge: TrimEdge::Start,
|
||||
new_frame: Frame(280),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
let v1_track = engine.read(app).track(1).expect("V1");
|
||||
let b_roll = v1_track
|
||||
.clips()
|
||||
.iter()
|
||||
.find(|c| c.id() == ClipId(12))
|
||||
.expect("B-roll clip");
|
||||
assert_eq!(b_roll.range(), FrameRange::new(Frame(280), Frame(600)));
|
||||
assert_eq!(b_roll.media_in(), Frame(140), "media-in follows the trim");
|
||||
|
||||
// Move the 开场 clip (id 11) onto the V2 track at frame 300.
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.apply_timeline_event(
|
||||
&TimelineEvent::ClipMoveRequested {
|
||||
clip: ClipId(11),
|
||||
new_track: 0,
|
||||
new_start: Frame(300),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
let v2 = engine.read(app).track(0).expect("V2");
|
||||
assert!(
|
||||
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)),
|
||||
"开场 left V1"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn mock_split_at_playhead_and_ripple_delete(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
|
||||
// Park the program playhead inside 开场 (0–240) and split there.
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.request_frame(Monitor::Program, Frame(120), cx);
|
||||
engine.split_at_playhead(cx);
|
||||
});
|
||||
let v1 = engine.read(app).track(1).expect("V1");
|
||||
assert_eq!(v1.clips().len(), 3, "开场 split into two");
|
||||
let spanning = v1
|
||||
.clips()
|
||||
.iter()
|
||||
.filter(|c| c.range().start.0 < 120 && 120 < c.range().end.0)
|
||||
.count();
|
||||
assert_eq!(spanning, 0, "no clip spans the split point");
|
||||
|
||||
// Ripple-delete the second half of 开场 (id 11's split tail).
|
||||
let tail_id = v1
|
||||
.clips()
|
||||
.iter()
|
||||
.find(|c| c.range().start == Frame(120))
|
||||
.map(|c| c.id())
|
||||
.expect("split tail");
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.delete_clip(tail_id, true, cx);
|
||||
});
|
||||
let v1 = engine.read(app).track(1).expect("V1");
|
||||
assert_eq!(v1.clips().len(), 2);
|
||||
// The following B-roll (was 240–600) shifted left by the removed
|
||||
// 120-frame tail: now starts at 120.
|
||||
let b_roll = v1
|
||||
.clips()
|
||||
.iter()
|
||||
.find(|c| c.id() == ClipId(12))
|
||||
.expect("B-roll clip");
|
||||
assert_eq!(b_roll.range().start, Frame(120), "ripple shifted B-roll left");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1487,10 +1766,13 @@ mod tests {
|
||||
|
||||
// The frame has the documented proxy size and opaque BGRA8 bytes.
|
||||
let size = c.size(0);
|
||||
assert_eq!(size.width, SYNTH_FRAME_WIDTH.into());
|
||||
assert_eq!(size.height, SYNTH_FRAME_HEIGHT.into());
|
||||
assert_eq!(size.width, crate::oakui::frames::SYNTH_FRAME_WIDTH.into());
|
||||
assert_eq!(size.height, crate::oakui::frames::SYNTH_FRAME_HEIGHT.into());
|
||||
let bytes = c.as_bytes(0).expect("single frame");
|
||||
assert_eq!(bytes.len(), (SYNTH_FRAME_WIDTH * SYNTH_FRAME_HEIGHT * 4) as usize);
|
||||
assert_eq!(
|
||||
bytes.len(),
|
||||
(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");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,20 +26,32 @@
|
||||
//! * [`mock`] — [`MockEngine`](mock::MockEngine) and
|
||||
//! [`MockClock`](mock::MockClock), the demo implementation feeding every
|
||||
//! widget's data-source trait.
|
||||
//! * [`real`] — [`RealEngine`](real::RealEngine) and
|
||||
//! [`RealClock`](real::RealClock), the real engine binding. It calls only
|
||||
//! the frozen `oakengine_*` C ABI of the built `liboakengine` dylib (see
|
||||
//! [`ffi`] and the crate's `build.rs`) behind the same
|
||||
//! [`EngineGateway`](engine::EngineGateway) seam the mock implements.
|
||||
//! * [`ffi`] — the pure-C declarations of that ABI (extern imports, handle
|
||||
//! layout mirrors, the `OakVideoParamsPod`).
|
||||
//! * [`transport`] — the play/pause/step/seek state machine (pure, unit
|
||||
//! tested).
|
||||
//! * [`timecode`] — timecode / duration / fps / resolution formatting (pure,
|
||||
//! unit tested).
|
||||
//!
|
||||
//! The real engine binding (the `liboakengine` C ABI through
|
||||
//! `src/facade/rust`) will implement [`EngineGateway`](engine::EngineGateway)
|
||||
//! later; nothing else in the crate needs to change for the swap.
|
||||
|
||||
pub mod engine;
|
||||
pub mod ffi;
|
||||
pub mod frames;
|
||||
mod host_syms;
|
||||
pub mod icons;
|
||||
pub mod mock;
|
||||
pub mod real;
|
||||
pub mod timecode;
|
||||
pub mod transport;
|
||||
|
||||
pub use engine::{EngineGateway, Monitor, Project, Sequence, VideoFormat};
|
||||
pub use engine::{
|
||||
AppEngine, EngineClock, EngineGateway, ExportEvent, ExportSession, Monitor, Project,
|
||||
Sequence, VideoFormat,
|
||||
};
|
||||
pub use mock::{MockClock, MockEngine};
|
||||
pub use real::{RealClock, RealEngine};
|
||||
pub use transport::{PlayState, TransportState};
|
||||
|
||||
@@ -38,13 +38,10 @@ use gpui::timeline::{Frame, FrameRate};
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use gpui::timeline::{Frame, FrameRate};
|
||||
/// use oakapp::oakui::timecode::format_timecode;
|
||||
/// let rate = FrameRate::new(25, 1);
|
||||
/// assert_eq!(format_timecode(Frame(0), rate), "00:00:00:00");
|
||||
/// assert_eq!(format_timecode(Frame(25), rate), "00:00:01:00");
|
||||
/// assert_eq!(format_timecode(Frame(6468), rate), "00:04:18:18");
|
||||
/// ```text
|
||||
/// format_timecode(Frame(0), 25fps) → "00:00:00:00"
|
||||
/// format_timecode(Frame(25), 25fps) → "00:00:01:00"
|
||||
/// format_timecode(Frame(6468), 25fps) → "00:04:18:18"
|
||||
/// ```
|
||||
pub fn format_timecode(frame: Frame, rate: FrameRate) -> String {
|
||||
let negative = frame.0 < 0;
|
||||
@@ -79,11 +76,9 @@ pub fn format_duration(frames: Frame, rate: FrameRate) -> String {
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use gpui::timeline::FrameRate;
|
||||
/// use oakapp::oakui::timecode::format_fps;
|
||||
/// assert_eq!(format_fps(FrameRate::new(25, 1)), "25");
|
||||
/// assert_eq!(format_fps(FrameRate::NTSC_2997), "29.97");
|
||||
/// ```text
|
||||
/// format_fps(FrameRate::new(25, 1)) → "25"
|
||||
/// format_fps(FrameRate::NTSC_2997) → "29.97"
|
||||
/// ```
|
||||
pub fn format_fps(rate: FrameRate) -> String {
|
||||
let fps = rate.num as f64 / rate.den as f64;
|
||||
|
||||
@@ -25,9 +25,11 @@ use crate::panels::ids::HISTORY;
|
||||
|
||||
/// The undo-history placeholder panel.
|
||||
pub struct HistoryPanel {
|
||||
/// Demo entries (newest first), matching the design's date format
|
||||
/// `YYYY-MM-DD HH:mm`.
|
||||
entries: Vec<(&'static str, &'static str)>,
|
||||
/// Demo entries `(i18n key, label suffix, timestamp)`, newest first,
|
||||
/// matching the design's date format `YYYY-MM-DD HH:mm`. The label is
|
||||
/// `tr(key) + suffix`, so the verb is localized while clip names stay as
|
||||
/// data.
|
||||
entries: Vec<(&'static str, &'static str, &'static str)>,
|
||||
}
|
||||
|
||||
impl HistoryPanel {
|
||||
@@ -35,11 +37,11 @@ impl HistoryPanel {
|
||||
pub fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
entries: vec![
|
||||
("变换", "2026-06-03 20:25"),
|
||||
("移动片段", "2026-06-03 20:24"),
|
||||
("删除 B-roll.mp4", "2026-06-03 20:22"),
|
||||
("添加 OCIO LUT", "2026-06-03 20:20"),
|
||||
("设置入点", "2026-06-03 20:18"),
|
||||
("history.transform", "", "2026-06-03 20:25"),
|
||||
("history.move_clip", "", "2026-06-03 20:24"),
|
||||
("history.delete_clip", " B-roll.mp4", "2026-06-03 20:22"),
|
||||
("history.add_lut", "", "2026-06-03 20:20"),
|
||||
("history.set_in_point", "", "2026-06-03 20:18"),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -55,7 +57,8 @@ impl Render for HistoryPanel {
|
||||
.flex_col()
|
||||
.py_1()
|
||||
.overflow_y_scroll();
|
||||
for (label, timestamp) in &self.entries {
|
||||
for (key, suffix, timestamp) in &self.entries {
|
||||
let label = format!("{}{}", crate::i18n::tr(key), suffix);
|
||||
list = list.child(
|
||||
div()
|
||||
.flex()
|
||||
@@ -65,7 +68,7 @@ impl Render for HistoryPanel {
|
||||
.px_3()
|
||||
.py_1()
|
||||
.text_color(colors.text)
|
||||
.child(div().child(*label))
|
||||
.child(div().child(label))
|
||||
.child(div().text_color(colors.disabled).child(*timestamp)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,18 +26,18 @@ use gpui::{
|
||||
div, prelude::*, AnyElement, App, Context, Entity, EventEmitter, Render, SharedString, Window,
|
||||
};
|
||||
|
||||
use crate::oakui::MockEngine;
|
||||
use crate::oakui::AppEngine;
|
||||
use crate::panels::ids::INSPECTOR;
|
||||
|
||||
/// The inspector / effect stack panel.
|
||||
pub struct InspectorPanel {
|
||||
stack: Entity<EffectStackView<MockEngine>>,
|
||||
engine: Entity<MockEngine>,
|
||||
pub struct InspectorPanel<E: AppEngine> {
|
||||
stack: Entity<EffectStackView<E>>,
|
||||
engine: Entity<E>,
|
||||
}
|
||||
|
||||
impl InspectorPanel {
|
||||
impl<E: AppEngine> InspectorPanel<E> {
|
||||
/// Builds the stack over `engine`'s effect model.
|
||||
pub fn new(engine: Entity<MockEngine>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
pub fn new(engine: Entity<E>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let stack = cx.new(|cx| {
|
||||
EffectStackView::new(engine.clone(), cx)
|
||||
.params_renderer(|_effect, _window, cx| cx.new(|_cx| ParamPlaceholder).into())
|
||||
@@ -54,15 +54,15 @@ impl InspectorPanel {
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for InspectorPanel {
|
||||
impl<E: AppEngine> Render for InspectorPanel<E> {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().size_full().child(self.stack.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for InspectorPanel {}
|
||||
impl<E: AppEngine> EventEmitter<PanelEvent> for InspectorPanel<E> {}
|
||||
|
||||
impl DockPanel for InspectorPanel {
|
||||
impl<E: AppEngine> DockPanel for InspectorPanel<E> {
|
||||
fn panel_id(&self) -> gpui::dock::PanelId {
|
||||
INSPECTOR
|
||||
}
|
||||
|
||||
@@ -27,32 +27,31 @@
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::dock::{DockPanel, PanelEvent};
|
||||
use gpui::node_graph::{
|
||||
MAX_ZOOM, MIN_ZOOM, NodeData, NodeElement, NodeGraphDataSource, NodeGraphEvent, NodeGraphView,
|
||||
NodeVisualState,
|
||||
MAX_ZOOM, MIN_ZOOM, NodeData, NodeElement, NodeGraphEvent, NodeGraphView, NodeVisualState,
|
||||
};
|
||||
use gpui::{
|
||||
div, point, prelude::*, px, AnyElement, App, Bounds, ClickEvent, Context, Entity,
|
||||
EventEmitter, Pixels, Render, SharedString, Window,
|
||||
};
|
||||
|
||||
use crate::oakui::MockEngine;
|
||||
use crate::oakui::AppEngine;
|
||||
use crate::panels::ids::NODE_EDITOR;
|
||||
|
||||
/// The node editor panel.
|
||||
pub struct NodeEditorPanel {
|
||||
/// The node-graph canvas over the engine's mock graph.
|
||||
graph: Entity<NodeGraphView<MockEngine>>,
|
||||
engine: Entity<MockEngine>,
|
||||
pub struct NodeEditorPanel<E: AppEngine> {
|
||||
/// The node-graph canvas over the engine's graph data.
|
||||
graph: Entity<NodeGraphView<E>>,
|
||||
engine: Entity<E>,
|
||||
/// Whether the initial fit-to-window has been applied (the canvas size is
|
||||
/// only known after the first layout).
|
||||
fitted: bool,
|
||||
}
|
||||
|
||||
impl NodeEditorPanel {
|
||||
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<MockEngine>,
|
||||
engine: Entity<E>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
@@ -134,7 +133,7 @@ impl NodeEditorPanel {
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for NodeEditorPanel {
|
||||
impl<E: AppEngine> Render for NodeEditorPanel<E> {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
// Fit the graph once the canvas size is known (first layout). Before
|
||||
// that the viewport is zero-sized, so ask for another frame instead.
|
||||
@@ -161,16 +160,32 @@ impl Render for NodeEditorPanel {
|
||||
.py_1()
|
||||
.border_b_1()
|
||||
.border_color(colors.border)
|
||||
.child(zoom_button(cx, "node-zoom-in", "+", |this, window, cx| {
|
||||
this.zoom(1.25, window, cx);
|
||||
}))
|
||||
.child(zoom_button(cx, "node-zoom-out", "−", |this, window, cx| {
|
||||
this.zoom(1.0 / 1.25, window, cx);
|
||||
}))
|
||||
.child(zoom_button(
|
||||
cx,
|
||||
"node-zoom-in",
|
||||
Some(crate::oakui::icons::ICON_ZOOM_IN),
|
||||
"+",
|
||||
"timeline.zoom_in",
|
||||
|this, window, cx| {
|
||||
this.zoom(1.25, window, cx);
|
||||
},
|
||||
))
|
||||
.child(zoom_button(
|
||||
cx,
|
||||
"node-zoom-out",
|
||||
Some(crate::oakui::icons::ICON_ZOOM_OUT),
|
||||
"−",
|
||||
"timeline.zoom_out",
|
||||
|this, window, cx| {
|
||||
this.zoom(1.0 / 1.25, window, cx);
|
||||
},
|
||||
))
|
||||
.child(zoom_button(
|
||||
cx,
|
||||
"node-zoom-fit",
|
||||
None,
|
||||
crate::i18n::tr("node.fit"),
|
||||
"node.fit",
|
||||
|this, window, cx| this.fit_graph(window, cx),
|
||||
))
|
||||
.child(div().flex_1())
|
||||
@@ -195,34 +210,52 @@ impl Render for NodeEditorPanel {
|
||||
}
|
||||
}
|
||||
|
||||
/// A small toolbar button driving the graph viewport.
|
||||
fn zoom_button(
|
||||
cx: &mut Context<NodeEditorPanel>,
|
||||
/// A small toolbar button driving the graph viewport. With `icon_name`, the
|
||||
/// button shows the 16px icon on a 24px hit target; otherwise the `label`
|
||||
/// text. Both get a localized `tooltip`.
|
||||
fn zoom_button<E: AppEngine>(
|
||||
cx: &mut Context<NodeEditorPanel<E>>,
|
||||
id: &'static str,
|
||||
icon_name: Option<&'static str>,
|
||||
label: impl IntoElement,
|
||||
action: impl Fn(&mut NodeEditorPanel, &mut Window, &mut Context<NodeEditorPanel>) + 'static,
|
||||
tooltip: &'static str,
|
||||
action: impl Fn(&mut NodeEditorPanel<E>, &mut Window, &mut Context<NodeEditorPanel<E>>) + 'static,
|
||||
) -> impl gpui::IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
let container = colors.container;
|
||||
div()
|
||||
let tooltip_label = crate::i18n::tr(tooltip);
|
||||
let mut el = div()
|
||||
.id(id)
|
||||
.px_2()
|
||||
.py_1()
|
||||
.size(px(24.0))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.text_color(colors.text)
|
||||
.cursor_pointer()
|
||||
.hover(move |style| style.bg(container))
|
||||
.tooltip(move |window, cx| {
|
||||
gpui_widgets::tooltip::tooltip_view(tooltip_label.into(), window, cx)
|
||||
})
|
||||
.on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| {
|
||||
action(this, window, cx);
|
||||
}))
|
||||
.child(label)
|
||||
}));
|
||||
if let Some(name) = icon_name {
|
||||
el = el.child(
|
||||
gpui::img(crate::oakui::icons::icon_path(name, cx)).size(px(16.0)),
|
||||
);
|
||||
} else {
|
||||
el = el.child(label);
|
||||
}
|
||||
el
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for NodeEditorPanel {}
|
||||
|
||||
impl DockPanel for NodeEditorPanel {
|
||||
impl<E: AppEngine> EventEmitter<PanelEvent> for NodeEditorPanel<E> {}
|
||||
|
||||
impl<E: AppEngine> DockPanel for NodeEditorPanel<E> {
|
||||
fn panel_id(&self) -> gpui::dock::PanelId {
|
||||
NODE_EDITOR
|
||||
}
|
||||
@@ -241,13 +274,14 @@ impl DockPanel for NodeEditorPanel {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::oakui::MockEngine;
|
||||
use gpui::{TestAppContext, VisualTestContext, size};
|
||||
|
||||
/// Builds the panel in a window and returns a `VisualTestContext` for
|
||||
/// bounds assertions.
|
||||
fn panel_window(
|
||||
cx: &mut TestAppContext,
|
||||
) -> (&'static mut VisualTestContext, Entity<NodeEditorPanel>) {
|
||||
) -> (&'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));
|
||||
|
||||
@@ -28,7 +28,7 @@ use gpui_widgets::audio_meter::AudioLevelMeter;
|
||||
use gpui_widgets::viewer::{ViewerEvent, ViewerWidget};
|
||||
|
||||
use crate::oakui::timecode::{format_fps, format_resolution};
|
||||
use crate::oakui::{EngineGateway, MockClock, MockEngine, Monitor};
|
||||
use crate::oakui::{AppEngine, Monitor};
|
||||
use crate::panels::chip;
|
||||
use crate::panels::ids::PROGRAM_VIEWER;
|
||||
|
||||
@@ -36,22 +36,22 @@ use crate::panels::ids::PROGRAM_VIEWER;
|
||||
const METER_WIDTH: f32 = 26.0;
|
||||
|
||||
/// The program viewer panel.
|
||||
pub struct ProgramViewerPanel {
|
||||
viewer: Entity<ViewerWidget<MockClock>>,
|
||||
meter: Entity<AudioLevelMeter<MockEngine>>,
|
||||
engine: Entity<MockEngine>,
|
||||
pub struct ProgramViewerPanel<E: AppEngine> {
|
||||
viewer: Entity<ViewerWidget<E::Clock>>,
|
||||
meter: Entity<AudioLevelMeter<E>>,
|
||||
engine: Entity<E>,
|
||||
/// The last CPU frame handed to the viewer (compared by `Arc` identity so
|
||||
/// a paused playhead does not re-upload the picture every frame).
|
||||
last_cpu_frame: Option<std::sync::Arc<gpui::RenderImage>>,
|
||||
}
|
||||
|
||||
impl ProgramViewerPanel {
|
||||
impl<E: AppEngine> ProgramViewerPanel<E> {
|
||||
/// Builds a viewer over `clock` (the program monitor's clock) with the
|
||||
/// level meter `meter` (updated on the app's tick timer).
|
||||
pub fn new(
|
||||
engine: Entity<MockEngine>,
|
||||
clock: Entity<MockClock>,
|
||||
meter: Entity<AudioLevelMeter<MockEngine>>,
|
||||
engine: Entity<E>,
|
||||
clock: Entity<E::Clock>,
|
||||
meter: Entity<AudioLevelMeter<E>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
@@ -90,7 +90,7 @@ impl ProgramViewerPanel {
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ProgramViewerPanel {
|
||||
impl<E: AppEngine> Render for ProgramViewerPanel<E> {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
self.sync_frame(cx);
|
||||
|
||||
@@ -138,9 +138,9 @@ impl Render for ProgramViewerPanel {
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for ProgramViewerPanel {}
|
||||
impl<E: AppEngine> EventEmitter<PanelEvent> for ProgramViewerPanel<E> {}
|
||||
|
||||
impl DockPanel for ProgramViewerPanel {
|
||||
impl<E: AppEngine> DockPanel for ProgramViewerPanel<E> {
|
||||
fn panel_id(&self) -> gpui::dock::PanelId {
|
||||
PROGRAM_VIEWER
|
||||
}
|
||||
|
||||
@@ -23,18 +23,18 @@ use gpui::{
|
||||
};
|
||||
use gpui_widgets::project_explorer::{ProjectExplorer, ProjectExplorerEvent};
|
||||
|
||||
use crate::oakui::MockEngine;
|
||||
use crate::oakui::AppEngine;
|
||||
use crate::panels::ids::PROJECT;
|
||||
|
||||
/// The material bin panel.
|
||||
pub struct ProjectExplorerPanel {
|
||||
explorer: Entity<ProjectExplorer<MockEngine>>,
|
||||
engine: Entity<MockEngine>,
|
||||
pub struct ProjectExplorerPanel<E: AppEngine> {
|
||||
explorer: Entity<ProjectExplorer<E>>,
|
||||
engine: Entity<E>,
|
||||
}
|
||||
|
||||
impl ProjectExplorerPanel {
|
||||
impl<E: AppEngine> ProjectExplorerPanel<E> {
|
||||
/// Builds the explorer over `engine`'s project data.
|
||||
pub fn new(engine: Entity<MockEngine>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
pub fn new(engine: Entity<E>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let explorer = cx.new(|cx| ProjectExplorer::new(1, engine.clone(), window, cx));
|
||||
cx.subscribe(
|
||||
&explorer,
|
||||
@@ -53,15 +53,15 @@ impl ProjectExplorerPanel {
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ProjectExplorerPanel {
|
||||
impl<E: AppEngine> Render for ProjectExplorerPanel<E> {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().size_full().child(self.explorer.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for ProjectExplorerPanel {}
|
||||
impl<E: AppEngine> EventEmitter<PanelEvent> for ProjectExplorerPanel<E> {}
|
||||
|
||||
impl DockPanel for ProjectExplorerPanel {
|
||||
impl<E: AppEngine> DockPanel for ProjectExplorerPanel<E> {
|
||||
fn panel_id(&self) -> gpui::dock::PanelId {
|
||||
PROJECT
|
||||
}
|
||||
|
||||
@@ -26,24 +26,24 @@ use gpui::{
|
||||
use gpui_widgets::viewer::{ViewerEvent, ViewerWidget};
|
||||
|
||||
use crate::oakui::timecode::{format_fps, format_resolution};
|
||||
use crate::oakui::{EngineGateway, MockClock, MockEngine, Monitor};
|
||||
use crate::oakui::{AppEngine, Monitor};
|
||||
use crate::panels::chip;
|
||||
use crate::panels::ids::SOURCE_VIEWER;
|
||||
|
||||
/// The source viewer panel.
|
||||
pub struct SourceViewerPanel {
|
||||
viewer: Entity<ViewerWidget<MockClock>>,
|
||||
engine: Entity<MockEngine>,
|
||||
pub struct SourceViewerPanel<E: AppEngine> {
|
||||
viewer: Entity<ViewerWidget<E::Clock>>,
|
||||
engine: Entity<E>,
|
||||
/// The last CPU frame handed to the viewer (compared by `Arc` identity so
|
||||
/// a paused playhead does not re-upload the picture every frame).
|
||||
last_cpu_frame: Option<std::sync::Arc<gpui::RenderImage>>,
|
||||
}
|
||||
|
||||
impl SourceViewerPanel {
|
||||
impl<E: AppEngine> SourceViewerPanel<E> {
|
||||
/// Builds a viewer over `clock` (the source monitor's clock).
|
||||
pub fn new(
|
||||
engine: Entity<MockEngine>,
|
||||
clock: Entity<MockClock>,
|
||||
engine: Entity<E>,
|
||||
clock: Entity<E::Clock>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
@@ -81,7 +81,7 @@ impl SourceViewerPanel {
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SourceViewerPanel {
|
||||
impl<E: AppEngine> Render for SourceViewerPanel<E> {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
self.sync_frame(cx);
|
||||
|
||||
@@ -117,9 +117,9 @@ impl Render for SourceViewerPanel {
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for SourceViewerPanel {}
|
||||
impl<E: AppEngine> EventEmitter<PanelEvent> for SourceViewerPanel<E> {}
|
||||
|
||||
impl DockPanel for SourceViewerPanel {
|
||||
impl<E: AppEngine> DockPanel for SourceViewerPanel<E> {
|
||||
fn panel_id(&self) -> gpui::dock::PanelId {
|
||||
SOURCE_VIEWER
|
||||
}
|
||||
|
||||
@@ -22,20 +22,22 @@ use gpui::colors::DefaultColors;
|
||||
use gpui::timeline::Frame;
|
||||
use gpui::{div, prelude::*, Context, Entity, Render, Window};
|
||||
|
||||
use gpui_widgets::viewer::PlaybackClock;
|
||||
|
||||
use crate::oakui::timecode::{format_duration, format_fps, format_resolution, format_timecode};
|
||||
use crate::oakui::{EngineGateway, MockClock, MockEngine};
|
||||
use crate::oakui::AppEngine;
|
||||
|
||||
/// The global status bar.
|
||||
pub struct StatusBar {
|
||||
engine: Entity<MockEngine>,
|
||||
program_clock: Entity<MockClock>,
|
||||
pub struct StatusBar<E: AppEngine> {
|
||||
engine: Entity<E>,
|
||||
program_clock: Entity<E::Clock>,
|
||||
}
|
||||
|
||||
impl StatusBar {
|
||||
impl<E: AppEngine> StatusBar<E> {
|
||||
/// Builds the status bar over the engine and the program clock.
|
||||
pub fn new(
|
||||
engine: Entity<MockEngine>,
|
||||
program_clock: Entity<MockClock>,
|
||||
engine: Entity<E>,
|
||||
program_clock: Entity<E::Clock>,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -45,11 +47,11 @@ impl StatusBar {
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for StatusBar {
|
||||
impl<E: AppEngine> Render for StatusBar<E> {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
let engine = self.engine.read(cx);
|
||||
let frame = self.program_clock.read(cx).transport.frame();
|
||||
let frame = self.program_clock.read(cx).current_frame();
|
||||
let sequence = engine.current_sequence();
|
||||
let format = sequence
|
||||
.map(|s| s.format)
|
||||
@@ -100,5 +102,15 @@ impl Render for StatusBar {
|
||||
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(),
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,14 +44,16 @@
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::dock::{DockPanel, PanelEvent};
|
||||
use gpui::timeline::TimelineView;
|
||||
use gpui::{div, prelude::*, px, Context, Entity, Window};
|
||||
use gpui::{div, img, prelude::*, px, Context, Entity, Window};
|
||||
use gpui::{AnyElement, App, ClickEvent, EventEmitter, Render, SharedString};
|
||||
use gpui_widgets::checkbox::{CheckBox, CheckBoxEvent, CheckState};
|
||||
use gpui_widgets::slider::{Slider, SliderEvent, SliderModel};
|
||||
use gpui_widgets::tooltip::tooltip_view;
|
||||
use gpui_widgets::value::ValueKind;
|
||||
|
||||
use crate::i18n;
|
||||
use crate::oakui::MockEngine;
|
||||
use crate::oakui::icons;
|
||||
use crate::oakui::AppEngine;
|
||||
use crate::panels::ids::TIMELINE;
|
||||
|
||||
/// Toolbar height, per the design (31px).
|
||||
@@ -59,23 +61,24 @@ const TOOLBAR_HEIGHT: f32 = 31.0;
|
||||
/// Fixed width of the trailing controls slot (zoom / track-height sliders).
|
||||
/// Kept constant so the sliders can never intrude into the ruler's labels.
|
||||
const RIGHT_CONTROLS_WIDTH: f32 = 140.0;
|
||||
/// The demo tool set, by i18n key. Only the visual selection is implemented;
|
||||
/// each tool's behavior arrives with the real tool system later.
|
||||
const TOOL_KEYS: [&str; 8] = [
|
||||
"timeline.tool.select",
|
||||
"timeline.tool.razor",
|
||||
"timeline.tool.ripple",
|
||||
"timeline.tool.slip",
|
||||
"timeline.tool.roll",
|
||||
"timeline.tool.zoom",
|
||||
"timeline.tool.knife",
|
||||
"timeline.tool.marker",
|
||||
/// The demo tool set, by i18n key, with the matching toolbar icon (the
|
||||
/// legacy C++ icon set). Only the visual selection is implemented; each
|
||||
/// tool's behavior arrives with the real tool system later.
|
||||
const TOOLS: [(&str, &str); 8] = [
|
||||
("timeline.tool.select", crate::oakui::icons::ICON_ARROW),
|
||||
("timeline.tool.razor", crate::oakui::icons::ICON_RAZOR),
|
||||
("timeline.tool.ripple", crate::oakui::icons::ICON_RIPPLE),
|
||||
("timeline.tool.slip", crate::oakui::icons::ICON_SLIP),
|
||||
("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),
|
||||
];
|
||||
|
||||
/// The timeline panel.
|
||||
pub struct TimelinePanel {
|
||||
timeline: Entity<TimelineView<MockEngine>>,
|
||||
engine: Entity<MockEngine>,
|
||||
pub struct TimelinePanel<E: AppEngine> {
|
||||
timeline: Entity<TimelineView<E>>,
|
||||
engine: Entity<E>,
|
||||
zoom: Entity<Slider>,
|
||||
height: Entity<Slider>,
|
||||
snap: Entity<CheckBox>,
|
||||
@@ -83,12 +86,12 @@ pub struct TimelinePanel {
|
||||
selected_tool: usize,
|
||||
}
|
||||
|
||||
impl TimelinePanel {
|
||||
impl<E: AppEngine> TimelinePanel<E> {
|
||||
/// Builds the panel around `timeline` (created by the app shell so it can
|
||||
/// sync the playhead).
|
||||
pub fn new(
|
||||
engine: Entity<MockEngine>,
|
||||
timeline: Entity<TimelineView<MockEngine>>,
|
||||
engine: Entity<E>,
|
||||
timeline: Entity<TimelineView<E>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
@@ -155,7 +158,7 @@ impl TimelinePanel {
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for TimelinePanel {
|
||||
impl<E: AppEngine> Render for TimelinePanel<E> {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
|
||||
@@ -179,74 +182,112 @@ impl Render for TimelinePanel {
|
||||
.flex_shrink_0()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.overflow_hidden()
|
||||
.border_b_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.container);
|
||||
|
||||
for (index, tool_key) in TOOL_KEYS.iter().enumerate() {
|
||||
let tool = i18n::tr(tool_key);
|
||||
// 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 foreground = if selected {
|
||||
colors.selected_text
|
||||
} else {
|
||||
colors.text
|
||||
};
|
||||
let hover_bg = colors.selected;
|
||||
let hover_fg = colors.selected_text;
|
||||
toolbar = toolbar.child(
|
||||
div()
|
||||
.id(SharedString::from(format!("tool-{index}")))
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded_sm()
|
||||
.cursor_pointer()
|
||||
.bg(background)
|
||||
.text_color(foreground)
|
||||
.hover(move |style| style.bg(hover_bg).text_color(hover_fg))
|
||||
.on_click(cx.listener(move |this, _event: &ClickEvent, _window, _cx| {
|
||||
println!("[timeline] tool: {tool} (placeholder)");
|
||||
this.selected_tool = index;
|
||||
}))
|
||||
.child(tool),
|
||||
);
|
||||
}
|
||||
|
||||
let text = colors.text;
|
||||
let container = colors.container;
|
||||
let tool_btn = move |id: &'static str, label: &'static str| {
|
||||
let path = icons::icon_path(icon_name, cx);
|
||||
div()
|
||||
.id(id)
|
||||
.px_2()
|
||||
.py_1()
|
||||
.id(SharedString::from(format!("tool-{index}")))
|
||||
.size(px(24.0))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_sm()
|
||||
.cursor_pointer()
|
||||
.text_color(text)
|
||||
.hover(move |style| style.bg(container))
|
||||
.child(label)
|
||||
.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)))
|
||||
};
|
||||
|
||||
// The snap toggle: a localized label next to the checkbox box. The
|
||||
// label is a plain div so it follows the active language; the box
|
||||
// itself is clickable as in the widget's default row.
|
||||
for (index, (tool_key, icon_name)) in TOOLS.iter().enumerate() {
|
||||
toolbar = toolbar.child(tool_button(index, icon_name, tool_key, cx));
|
||||
}
|
||||
|
||||
// A plain icon button (no selection state), e.g. zoom in/out.
|
||||
let icon_btn = |id: &'static str,
|
||||
icon_name: &'static str,
|
||||
key: &'static str,
|
||||
cx: &mut Context<Self>| {
|
||||
let label = i18n::tr(key);
|
||||
let hover_bg = colors.container;
|
||||
let path = icons::icon_path(icon_name, cx);
|
||||
div()
|
||||
.id(id)
|
||||
.size(px(24.0))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_sm()
|
||||
.cursor_pointer()
|
||||
.text_color(colors.text)
|
||||
.hover(move |style| style.bg(hover_bg))
|
||||
.tooltip(move |window, cx| tooltip_view(label.into(), window, cx))
|
||||
.child(img(path).size(px(16.0)))
|
||||
};
|
||||
|
||||
// The snap toggle: the magnet icon next to the checkbox box. The icon
|
||||
// is decorative (the box itself is clickable, as in the widget's
|
||||
// default row).
|
||||
let snap_row = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.text_color(colors.text)
|
||||
.child(div().child(i18n::tr("timeline.snap")))
|
||||
.child(
|
||||
div()
|
||||
.id("snap-toggle")
|
||||
.size(px(24.0))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.cursor_pointer()
|
||||
.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(self.snap.clone());
|
||||
|
||||
let toolbar = toolbar
|
||||
.child(tool_btn("toolbar-zoom-in", "+"))
|
||||
.child(tool_btn("toolbar-zoom-out", "−"))
|
||||
.child(icon_btn(
|
||||
"toolbar-zoom-in",
|
||||
icons::ICON_ZOOM_IN,
|
||||
"timeline.zoom_in",
|
||||
cx,
|
||||
))
|
||||
.child(icon_btn(
|
||||
"toolbar-zoom-out",
|
||||
icons::ICON_ZOOM_OUT,
|
||||
"timeline.zoom_out",
|
||||
cx,
|
||||
))
|
||||
.child(
|
||||
div()
|
||||
.w_1()
|
||||
@@ -316,9 +357,9 @@ impl Render for TimelinePanel {
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for TimelinePanel {}
|
||||
impl<E: AppEngine> EventEmitter<PanelEvent> for TimelinePanel<E> {}
|
||||
|
||||
impl DockPanel for TimelinePanel {
|
||||
impl<E: AppEngine> DockPanel for TimelinePanel<E> {
|
||||
fn panel_id(&self) -> gpui::dock::PanelId {
|
||||
TIMELINE
|
||||
}
|
||||
@@ -335,6 +376,7 @@ impl DockPanel for TimelinePanel {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::oakui::MockEngine;
|
||||
use gpui::{TestAppContext, VisualTestContext, px, size};
|
||||
|
||||
/// Builds a `TimelinePanel` in a window of the given logical size and
|
||||
@@ -343,7 +385,7 @@ mod tests {
|
||||
cx: &mut TestAppContext,
|
||||
width: f32,
|
||||
height: f32,
|
||||
) -> (&'static mut VisualTestContext, Entity<TimelinePanel>) {
|
||||
) -> (&'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));
|
||||
|
||||