diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 486f2be66..bdc9d91e9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -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.
\ No newline at end of file
diff --git a/Cargo.lock b/Cargo.lock
index 77ad5bc73..64ba9f882 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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",
]
diff --git a/Cargo.toml b/Cargo.toml
index 9046b9863..1062de58a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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).
diff --git a/assets/icons/dark/arrow.png b/assets/icons/dark/arrow.png
new file mode 100644
index 000000000..f02715d9d
Binary files /dev/null and b/assets/icons/dark/arrow.png differ
diff --git a/assets/icons/dark/ff.png b/assets/icons/dark/ff.png
new file mode 100644
index 000000000..94d7acc8d
Binary files /dev/null and b/assets/icons/dark/ff.png differ
diff --git a/assets/icons/dark/magnet.png b/assets/icons/dark/magnet.png
new file mode 100644
index 000000000..f86e31cef
Binary files /dev/null and b/assets/icons/dark/magnet.png differ
diff --git a/assets/icons/dark/next.png b/assets/icons/dark/next.png
new file mode 100644
index 000000000..40055ecfa
Binary files /dev/null and b/assets/icons/dark/next.png differ
diff --git a/assets/icons/dark/pause.png b/assets/icons/dark/pause.png
new file mode 100644
index 000000000..5aa38b2f5
Binary files /dev/null and b/assets/icons/dark/pause.png differ
diff --git a/assets/icons/dark/play.png b/assets/icons/dark/play.png
new file mode 100644
index 000000000..ab5c3b714
Binary files /dev/null and b/assets/icons/dark/play.png differ
diff --git a/assets/icons/dark/prev.png b/assets/icons/dark/prev.png
new file mode 100644
index 000000000..b8b9aa861
Binary files /dev/null and b/assets/icons/dark/prev.png differ
diff --git a/assets/icons/dark/razor.png b/assets/icons/dark/razor.png
new file mode 100644
index 000000000..03f131918
Binary files /dev/null and b/assets/icons/dark/razor.png differ
diff --git a/assets/icons/dark/rew.png b/assets/icons/dark/rew.png
new file mode 100644
index 000000000..b80350123
Binary files /dev/null and b/assets/icons/dark/rew.png differ
diff --git a/assets/icons/dark/ripple.png b/assets/icons/dark/ripple.png
new file mode 100644
index 000000000..7d7682b2a
Binary files /dev/null and b/assets/icons/dark/ripple.png differ
diff --git a/assets/icons/dark/rolling.png b/assets/icons/dark/rolling.png
new file mode 100644
index 000000000..c985072e9
Binary files /dev/null and b/assets/icons/dark/rolling.png differ
diff --git a/assets/icons/dark/slide.png b/assets/icons/dark/slide.png
new file mode 100644
index 000000000..697d377ec
Binary files /dev/null and b/assets/icons/dark/slide.png differ
diff --git a/assets/icons/dark/slip.png b/assets/icons/dark/slip.png
new file mode 100644
index 000000000..50dd3b91b
Binary files /dev/null and b/assets/icons/dark/slip.png differ
diff --git a/assets/icons/dark/track-tool.png b/assets/icons/dark/track-tool.png
new file mode 100644
index 000000000..8f214b0f6
Binary files /dev/null and b/assets/icons/dark/track-tool.png differ
diff --git a/assets/icons/dark/zoomin.png b/assets/icons/dark/zoomin.png
new file mode 100644
index 000000000..54c58966d
Binary files /dev/null and b/assets/icons/dark/zoomin.png differ
diff --git a/assets/icons/dark/zoomout.png b/assets/icons/dark/zoomout.png
new file mode 100644
index 000000000..148297f1e
Binary files /dev/null and b/assets/icons/dark/zoomout.png differ
diff --git a/assets/icons/light/arrow.png b/assets/icons/light/arrow.png
new file mode 100644
index 000000000..ed33c62e3
Binary files /dev/null and b/assets/icons/light/arrow.png differ
diff --git a/assets/icons/light/ff.png b/assets/icons/light/ff.png
new file mode 100644
index 000000000..f6d7145c6
Binary files /dev/null and b/assets/icons/light/ff.png differ
diff --git a/assets/icons/light/magnet.png b/assets/icons/light/magnet.png
new file mode 100644
index 000000000..fafa17a6a
Binary files /dev/null and b/assets/icons/light/magnet.png differ
diff --git a/assets/icons/light/next.png b/assets/icons/light/next.png
new file mode 100644
index 000000000..b9f76b842
Binary files /dev/null and b/assets/icons/light/next.png differ
diff --git a/assets/icons/light/pause.png b/assets/icons/light/pause.png
new file mode 100644
index 000000000..62d4dc6e7
Binary files /dev/null and b/assets/icons/light/pause.png differ
diff --git a/assets/icons/light/play.png b/assets/icons/light/play.png
new file mode 100644
index 000000000..80592a207
Binary files /dev/null and b/assets/icons/light/play.png differ
diff --git a/assets/icons/light/prev.png b/assets/icons/light/prev.png
new file mode 100644
index 000000000..2f5f2611e
Binary files /dev/null and b/assets/icons/light/prev.png differ
diff --git a/assets/icons/light/razor.png b/assets/icons/light/razor.png
new file mode 100644
index 000000000..da887064f
Binary files /dev/null and b/assets/icons/light/razor.png differ
diff --git a/assets/icons/light/rew.png b/assets/icons/light/rew.png
new file mode 100644
index 000000000..ae0c0064a
Binary files /dev/null and b/assets/icons/light/rew.png differ
diff --git a/assets/icons/light/ripple.png b/assets/icons/light/ripple.png
new file mode 100644
index 000000000..c2cc24d45
Binary files /dev/null and b/assets/icons/light/ripple.png differ
diff --git a/assets/icons/light/rolling.png b/assets/icons/light/rolling.png
new file mode 100644
index 000000000..128da431c
Binary files /dev/null and b/assets/icons/light/rolling.png differ
diff --git a/assets/icons/light/slide.png b/assets/icons/light/slide.png
new file mode 100644
index 000000000..ae4fe465e
Binary files /dev/null and b/assets/icons/light/slide.png differ
diff --git a/assets/icons/light/slip.png b/assets/icons/light/slip.png
new file mode 100644
index 000000000..cb3c92271
Binary files /dev/null and b/assets/icons/light/slip.png differ
diff --git a/assets/icons/light/track-tool.png b/assets/icons/light/track-tool.png
new file mode 100644
index 000000000..ea58f94cf
Binary files /dev/null and b/assets/icons/light/track-tool.png differ
diff --git a/assets/icons/light/zoomin.png b/assets/icons/light/zoomin.png
new file mode 100644
index 000000000..1af51cbfa
Binary files /dev/null and b/assets/icons/light/zoomin.png differ
diff --git a/assets/icons/light/zoomout.png b/assets/icons/light/zoomout.png
new file mode 100644
index 000000000..ba6b9da44
Binary files /dev/null and b/assets/icons/light/zoomout.png differ
diff --git a/build.rs b/build.rs
new file mode 100644
index 000000000..2e015474b
--- /dev/null
+++ b/build.rs
@@ -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 .
+
+//! 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//deps/liboakengine.dylib` — when built as a
+//! dependency of the app (the normal case),
+//! * `target//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//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-.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-.dylib` in `deps/` (some cargo configurations
+/// name dependency cdylibs with a hash suffix).
+fn find_hashed_dylib(deps_dir: &std::path::Path) -> Option {
+ 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
+}
diff --git a/docs/screenshot-window.png b/docs/screenshot-window.png
index 5e20ab3cf..843d17cf4 100644
Binary files a/docs/screenshot-window.png and b/docs/screenshot-window.png differ
diff --git a/examples/screenshot.rs b/examples/screenshot.rs
index f61fc110e..c2ab9f503 100644
--- a/examples/screenshot.rs
+++ b/examples/screenshot.rs
@@ -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::::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());
diff --git a/src/app.rs b/src/app.rs
index 8ed355c88..50ea8ab2d 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -14,8 +14,14 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
-//! 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,
- source_clock: Entity,
- program_clock: Entity,
+/// 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,
+ content: Entity,
+ action: FileAction,
+ },
+ Preferences {
+ modal: Entity,
+ content: Entity,
+ },
+ Export {
+ modal: Entity,
+ content: Entity,
+ },
+ Progress {
+ modal: Entity,
+ content: Entity,
+ },
+}
+
+/// 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> {
+ 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 {
+ engine: Entity,
+ source_clock: Entity,
+ program_clock: Entity,
+}
+
+impl PanelRegistry for AppPanelRegistry {
fn panel_key(&self, id: gpui::dock::PanelId) -> Option {
Some(
match id {
@@ -119,8 +193,6 @@ impl PanelRegistry for AppPanelRegistry {
}
fn build_panel(&self, key: &str, window: &mut Window, cx: &mut App) -> Option {
- // 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,
- program_clock: Entity,
- timeline: Entity>,
- meter: Entity>,
+pub struct OakApp {
+ engine: Entity,
+ program_clock: Entity,
+ timeline: Entity>,
+ meter: Entity>,
menu_bar: Entity,
dock: Entity,
- status_bar: Entity,
+ status_bar: Entity>,
/// 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,
}
-impl OakApp {
- /// Builds the whole shell.
- pub fn new(window: &mut Window, cx: &mut Context) -> Self {
+impl OakApp {
+ /// 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,
+ cx: &mut Context,
+ ) -> 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.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) {
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, cx: &mut Context) {
+ 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) {
+ let ids: Vec = 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) {
+ 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) {
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) {
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.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,
+ 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) {
+ let (title, control) = match action {
+ FileAction::Open => (
+ crate::i18n::tr("file.open.title"),
+ modal_ids::FILE_OPEN,
+ ),
+ FileAction::SaveAs => (
+ crate::i18n::tr("file.save_as.title"),
+ modal_ids::FILE_SAVE_AS,
+ ),
+ };
+ let current_path = self
+ .engine
+ .read(cx)
+ .project()
+ .map(|p| p.path.clone())
+ .filter(|p| !p.as_os_str().is_empty());
+ self.spawn_modal(cx, move |window, app| {
+ let (modal, content) =
+ gpui_widgets::dialog::file_dialog::file_dialog(control, title, window, app);
+ if action == FileAction::SaveAs {
+ if let Some(path) = ¤t_path {
+ content.update(app, |content, cx| content.set_path(path.to_string_lossy().into_owned(), cx));
+ }
+ }
+ ModalState::FileDialog {
+ modal,
+ content,
+ action,
+ }
+ });
+ }
+
+ /// Opens the preferences dialog.
+ fn open_preferences(&mut self, cx: &mut Context) {
+ self.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) {
+ 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) {
+ 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) {
+ 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) {
+ 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) {
+ 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) {
+ 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 Render for OakApp {
fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> 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 {
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 {
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 {
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 {
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 {
]
}
+/// Command-line arguments the app accepts.
+#[derive(Debug, Clone, Default)]
+struct AppArgs {
+ /// A project file to open at startup.
+ project: Option,
+ /// 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(
+ window: &mut Window,
+ initial: Option,
+ cx: &mut App,
+) -> Entity> {
+ 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::(args.clone());
+ } else {
+ run_with::(args);
+ }
+}
+
+/// Runs the app window with `E` as the engine backend.
+fn run_with(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::(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::::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);
+ }
}
diff --git a/src/dialogs.rs b/src/dialogs.rs
new file mode 100644
index 000000000..a27e0691b
--- /dev/null
+++ b/src/dialogs.rs
@@ -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 .
+
+//! 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,
+ language: Entity,
+ /// 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 {
+ let backends = renderer_backends();
+ let current_backend = config_get_string(CONFIG_KEY_RENDERER_BACKEND);
+ let backend_selected = backends
+ .iter()
+ .position(|b| b.eq_ignore_ascii_case(¤t_backend))
+ .unwrap_or(0);
+ let backend_options = backends
+ .iter()
+ .enumerate()
+ .map(|(i, name)| ComboBoxOption::new(i, backend_label(name)))
+ .collect();
+ let backend = cx.new(|cx| {
+ ComboBox::new(1, backend_options, window, cx)
+ .with_placeholder(i18n::tr("preferences.backend.placeholder"))
+ });
+ cx.subscribe(&backend, |this, _combo, event: &ComboBoxEvent, cx| {
+ if let ComboBoxEvent::Selected { value, .. } = event {
+ if let Some(name) = this.backends.get(*value) {
+ config_set_string(CONFIG_KEY_RENDERER_BACKEND, name);
+ println!("[preferences] renderer backend → {name}");
+ }
+ }
+ let _ = cx;
+ })
+ .detach();
+ backend.update(cx, |combo, cx| combo.set_selected(Some(backend_selected), cx));
+
+ let language_options = vec![
+ ComboBoxOption::new(0, "English (en-US)"),
+ ComboBoxOption::new(1, "简体中文 (zh-CN)"),
+ ];
+ let language = cx.new(|cx| {
+ ComboBox::new(2, language_options, window, cx)
+ .with_placeholder(i18n::tr("preferences.language.placeholder"))
+ });
+ let language_selected = match crate::i18n::language() {
+ crate::i18n::Language::EnUs => 0,
+ crate::i18n::Language::ZhCN => 1,
+ };
+ cx.subscribe(&language, |_this, _combo, event: &ComboBoxEvent, cx| {
+ if let ComboBoxEvent::Selected { value, .. } = event {
+ let language = match *value {
+ 1 => crate::i18n::Language::ZhCN,
+ _ => crate::i18n::Language::EnUs,
+ };
+ crate::i18n::set_language(language);
+ }
+ let _ = cx;
+ })
+ .detach();
+ language.update(cx, |combo, cx| combo.set_selected(Some(language_selected), cx));
+
+ Self {
+ backend,
+ language,
+ backends,
+ }
+ }
+}
+
+/// A display label for a renderer backend id.
+fn backend_label(name: &str) -> String {
+ match name {
+ "opengl" => "OpenGL",
+ "metal" => "Metal",
+ "vulkan" => "Vulkan",
+ "none" => "None (off)",
+ other => other,
+ }
+ .to_string()
+}
+
+/// A labeled form row: a small caption above the widget.
+fn form_row(
+ colors: &gpui::colors::Colors,
+ label: SharedString,
+ widget: impl IntoElement,
+) -> gpui::Div {
+ div()
+ .flex()
+ .flex_col()
+ .gap_1()
+ .child(div().text_color(colors.text).child(label))
+ .child(widget)
+}
+
+impl Render for PreferencesContent {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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,
+}
+
+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, cx: &mut Context) {
+ 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) -> 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,
+ path: Entity,
+ /// (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 {
+ 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, cx: &mut Context) {
+ 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) -> 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")),
+ )
+ }
+}
diff --git a/src/i18n.rs b/src/i18n.rs
index 1a328994f..1021a48f5 100644
--- a/src/i18n.rs
+++ b/src/i18n.rs
@@ -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