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)
This commit is contained in:
2026-08-11 00:25:20 +08:00
parent 3110e5cc3a
commit 063837446d
59 changed files with 4998 additions and 517 deletions
+1 -21
View File
@@ -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.
Generated
+3 -1
View File
@@ -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",
]
+31 -3
View File
@@ -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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 678 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 723 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 613 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1005 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 838 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 942 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 641 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 649 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+116
View File
@@ -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
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

After

Width:  |  Height:  |  Size: 386 KiB

+49 -5
View File
@@ -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());
+707 -73
View File
@@ -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) = &current_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);
}
}
+310
View File
@@ -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(&current_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")),
)
}
}
+167 -149
View File
@@ -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]
+20 -8
View File
@@ -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;
+157 -1
View File
@@ -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>,
}
+494
View File
@@ -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;
}
+96
View File
@@ -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))
}
+258
View File
@@ -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;
}
}
+133
View File
@@ -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:?}"
);
});
}
}
+369 -87
View File
@@ -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, 240600) 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 开场 (0240) 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 240600) 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");
});
}
+17 -5
View File
@@ -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};
+1820
View File
File diff suppressed because it is too large Load Diff
+7 -12
View File
@@ -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;
+13 -10
View File
@@ -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)),
);
}
+9 -9
View File
@@ -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
}
+62 -28
View File
@@ -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));
+12 -12
View File
@@ -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
}
+9 -9
View File
@@ -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
}
+10 -10
View File
@@ -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
}
+21 -9
View File
@@ -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(),
)),
)
}
}
+107 -65
View File
@@ -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));