feat: oakengine facade, oaknode/oakrender impls, worker+CLI, app skeleton
- oaknode Rust crate: full implementation (core engine, sequence/ track/block/footage, traverser, serializer, 43 node behaviors; 493 tests green) - oakrender Rust crate: full implementation incl. wgpu backend skeleton, ticket arena, worker pool (136 tests green; fixed lost-wakeup and ticket ordering races) - src/facade/rust (oakfacade): 222 oakengine_* exports over the module C ABIs (61 tests green); worker_main + real POSIX shm frame-slot transport (SpscRingBuffer/FrameSlotPool, wire-compatible with engine/render/ipc) - cli/rust + worker/rust binaries (29 + 29 tests green) - oakotio: FCPXML import/export (49 tests green) - oaktask: OTIO/FCPXML format dispatch (90 tests green) - app/rust: gpui app skeleton — dock panels (viewers/timeline/ explorer/inspector/node editor), transport, olive themes, i18n (en/zh), 37 tests green - gpui submodule: menu checkmarks, dock ratios, vertical meter, CPU-frame viewer surface, drop-frame timecode
This commit is contained in:
@@ -0,0 +1 @@
|
||||
target/
|
||||
Generated
+7425
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
# 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/>.
|
||||
|
||||
[package]
|
||||
name = "oakapp"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Oak Video Editor application layer (Rust, gpui-based)"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lib]
|
||||
name = "oakapp"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "oakapp"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# gpui: the GPU-accelerated UI framework (oak-gpui fork, git submodule at gpui/).
|
||||
gpui = { path = "../../gpui/crates/gpui" }
|
||||
# Convenience entry point: `gpui_platform::application()` picks the platform
|
||||
# backend. font-kit enables text shaping/rendering on macOS.
|
||||
gpui_platform = { path = "../../gpui/crates/gpui_platform", features = ["font-kit"] }
|
||||
# Oak's widget library: menus, viewer, form controls, project explorer.
|
||||
gpui_widgets = { path = "../../gpui/crates/gpui_widgets" }
|
||||
|
||||
[dev-dependencies]
|
||||
# `#[gpui::test]` harness for engine-seam smoke tests (test-support feature).
|
||||
gpui = { path = "../../gpui/crates/gpui", features = ["test-support"] }
|
||||
# `test-support` also enables `gpui_macos/test-support`, which is what makes
|
||||
# `render_to_image` (the screenshot example) available.
|
||||
gpui_platform = { path = "../../gpui/crates/gpui_platform", features = ["test-support"] }
|
||||
# Screenshot capture: `examples/screenshot.rs` saves the rendered window PNG
|
||||
# (the `image` crate is already in the lockfile through gpui).
|
||||
image = "0.25"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 457 KiB |
@@ -0,0 +1,72 @@
|
||||
// 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/>.
|
||||
|
||||
//! Offscreen screenshot capture for the app window.
|
||||
//!
|
||||
//! Renders the full [`OakApp`] shell at 1600×900 (2× = 3200×1866 px) in an
|
||||
//! offscreen macOS window and writes the PNG to
|
||||
//! `app/rust/docs/screenshot-window.png`, using the same
|
||||
//! [`VisualTestAppContext`] machinery the gpui visual tests use. The window
|
||||
//! is created at `(-10000, -10000)` so nothing flickers on screen.
|
||||
//!
|
||||
//! Run it on the macOS main thread (examples run on the main thread, unlike
|
||||
//! `#[test]` harness threads):
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --example screenshot # 1600×900 → docs/screenshot-window.png
|
||||
//! cargo run --example screenshot -- 1100 900 # any size (still overwrites the same file)
|
||||
//! ```
|
||||
|
||||
use gpui::{px, size, AnyWindowHandle, AppContext, Result, VisualTestAppContext};
|
||||
use gpui_platform::current_platform;
|
||||
use oakapp::app::OakApp;
|
||||
|
||||
const DEFAULT_WIDTH: f32 = 1600.0;
|
||||
const DEFAULT_HEIGHT: f32 = 900.0;
|
||||
const OUT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-window.png");
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let width = args
|
||||
.first()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(DEFAULT_WIDTH);
|
||||
let height = args
|
||||
.get(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(DEFAULT_HEIGHT);
|
||||
|
||||
let mut cx = VisualTestAppContext::new(current_platform(false));
|
||||
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))
|
||||
})?;
|
||||
let handle: AnyWindowHandle = window.into();
|
||||
|
||||
// Let the platform settle, then draw one full frame into the rendered
|
||||
// scene so `render_to_image` has something to capture.
|
||||
cx.run_until_parked();
|
||||
cx.update_window(handle, |_root, window, app| {
|
||||
let _ = window.draw(app);
|
||||
})?;
|
||||
|
||||
let image = cx.capture_screenshot(handle)?;
|
||||
std::fs::create_dir_all(std::path::Path::new(OUT).parent().unwrap())?;
|
||||
image.save(OUT)?;
|
||||
println!("wrote {OUT} ({}×{})", image.width(), image.height());
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# 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/>.
|
||||
|
||||
# Match the repo convention (see src/facade/rust): tab indentation.
|
||||
hard_tabs = true
|
||||
@@ -0,0 +1,717 @@
|
||||
// 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 application shell: menu bar, dock layout, status bar and the tick
|
||||
//! loop that drives playback, playhead sync and the audio meter.
|
||||
//!
|
||||
//! Layout per the design (`design/Oak-UI设计图-主界面-标注版.png`):
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌ menu bar (文件 编辑 视图 回放 序列 窗口 工具 帮助)
|
||||
//! ├─────────────────────────────────────────────────────
|
||||
//! │ dock: 项目 | 素材查看器 | 序列查看器+节点编辑器 | 检查器+历史记录
|
||||
//! │ (vertical split) 时间线 (full width, 31px toolbar on top)
|
||||
//! ├─────────────────────────────────────────────────────
|
||||
//! └ status bar: 就绪 | 缓存 | 代理 | 自动保存 || 时间码/时长 | 帧率 | 分辨率
|
||||
//! ```
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::dock::{
|
||||
DockArea, DockLayout, DropTarget, DropZone, NodePath, PanelHandle, PanelRegistry,
|
||||
};
|
||||
use gpui::timeline::{Frame, TimelineEvent, TimelineView};
|
||||
use gpui::{
|
||||
div, prelude::*, px, size, App, AsyncWindowContext, Bounds, Context, Entity, Render, Window,
|
||||
WindowBounds, WindowOptions,
|
||||
};
|
||||
use gpui_widgets::audio_meter::AudioLevelMeter;
|
||||
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::panels::history::HistoryPanel;
|
||||
use crate::panels::ids::*;
|
||||
use crate::panels::inspector::InspectorPanel;
|
||||
use crate::panels::node_editor::NodeEditorPanel;
|
||||
use crate::panels::program_viewer::ProgramViewerPanel;
|
||||
use crate::panels::project_explorer::ProjectExplorerPanel;
|
||||
use crate::panels::source_viewer::SourceViewerPanel;
|
||||
use crate::panels::status_bar::StatusBar;
|
||||
use crate::panels::timeline::TimelinePanel;
|
||||
|
||||
// Menu item ids (unique per menu).
|
||||
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 UNDO: usize = 201;
|
||||
pub const REDO: usize = 202;
|
||||
pub const CUT: usize = 203;
|
||||
pub const COPY: usize = 204;
|
||||
pub const PASTE: usize = 205;
|
||||
pub const DELETE: usize = 206;
|
||||
|
||||
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 PLAY_PAUSE: usize = 401;
|
||||
pub const PREV_FRAME: usize = 402;
|
||||
pub const NEXT_FRAME: usize = 403;
|
||||
pub const TO_START: usize = 404;
|
||||
|
||||
pub const ADD_VIDEO_TRACK: usize = 501;
|
||||
pub const ADD_AUDIO_TRACK: usize = 502;
|
||||
|
||||
pub const FOCUS_PROJECT: usize = 601;
|
||||
pub const FOCUS_SOURCE_VIEWER: usize = 602;
|
||||
pub const FOCUS_PROGRAM_VIEWER: usize = 603;
|
||||
pub const FOCUS_NODE_EDITOR: usize = 604;
|
||||
pub const FOCUS_INSPECTOR: usize = 605;
|
||||
pub const FOCUS_HISTORY: usize = 606;
|
||||
pub const FOCUS_TIMELINE: usize = 607;
|
||||
|
||||
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>,
|
||||
}
|
||||
|
||||
impl PanelRegistry for AppPanelRegistry {
|
||||
fn panel_key(&self, id: gpui::dock::PanelId) -> Option<String> {
|
||||
Some(
|
||||
match id {
|
||||
PROJECT => "project",
|
||||
SOURCE_VIEWER => "source-viewer",
|
||||
PROGRAM_VIEWER => "program-viewer",
|
||||
NODE_EDITOR => "node-editor",
|
||||
INSPECTOR => "inspector",
|
||||
HISTORY => "history",
|
||||
TIMELINE => "timeline",
|
||||
_ => return None,
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
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)),
|
||||
cx,
|
||||
)),
|
||||
"source-viewer" => Some(PanelHandle::new(
|
||||
cx.new(|cx| {
|
||||
SourceViewerPanel::new(
|
||||
self.engine.clone(),
|
||||
self.source_clock.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
cx,
|
||||
)),
|
||||
"program-viewer" => Some(PanelHandle::new(
|
||||
cx.new(|cx| {
|
||||
let meter =
|
||||
cx.new(|cx| AudioLevelMeter::new(30, self.engine.clone(), window, cx));
|
||||
ProgramViewerPanel::new(
|
||||
self.engine.clone(),
|
||||
self.program_clock.clone(),
|
||||
meter,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
cx,
|
||||
)),
|
||||
"node-editor" => Some(PanelHandle::new(
|
||||
cx.new(|cx| NodeEditorPanel::new(window, cx)),
|
||||
cx,
|
||||
)),
|
||||
"inspector" => Some(PanelHandle::new(
|
||||
cx.new(|cx| InspectorPanel::new(self.engine.clone(), window, cx)),
|
||||
cx,
|
||||
)),
|
||||
"history" => Some(PanelHandle::new(
|
||||
cx.new(|cx| HistoryPanel::new(window, cx)),
|
||||
cx,
|
||||
)),
|
||||
"timeline" => Some(PanelHandle::new(
|
||||
cx.new(|cx| {
|
||||
let timeline =
|
||||
cx.new(|cx| TimelineView::new(self.engine.clone(), window, cx).zoom(2.0));
|
||||
TimelinePanel::new(self.engine.clone(), timeline, window, cx)
|
||||
}),
|
||||
cx,
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The application root view.
|
||||
pub struct OakApp {
|
||||
engine: Entity<MockEngine>,
|
||||
program_clock: Entity<MockClock>,
|
||||
timeline: Entity<TimelineView<MockEngine>>,
|
||||
meter: Entity<AudioLevelMeter<MockEngine>>,
|
||||
menu_bar: Entity<MenuBar>,
|
||||
dock: Entity<DockArea>,
|
||||
status_bar: Entity<StatusBar>,
|
||||
/// Whether the dark theme is active (toggles via 视图 → 主题).
|
||||
dark: bool,
|
||||
}
|
||||
|
||||
impl OakApp {
|
||||
/// Builds the whole shell.
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
apply_theme(cx, &OakTheme::olive_dark());
|
||||
|
||||
// --- 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 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));
|
||||
|
||||
// --- menu bar ------------------------------------------------------
|
||||
let menu_bar = cx.new(|cx| MenuBar::new(1, make_menus(true), window, cx));
|
||||
cx.subscribe(
|
||||
&menu_bar,
|
||||
|this, _menu: Entity<MenuBar>, event: &MenuBarEvent, cx| {
|
||||
if let MenuBarEvent::Triggered { item, .. } = event {
|
||||
this.on_menu(*item, cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
|
||||
// --- dock ----------------------------------------------------------
|
||||
let dock = cx.new(|cx| {
|
||||
DockArea::new(cx).with_registry(Arc::new(AppPanelRegistry {
|
||||
engine: engine.clone(),
|
||||
source_clock: source_clock.clone(),
|
||||
program_clock: program_clock.clone(),
|
||||
}))
|
||||
});
|
||||
|
||||
let project = cx.new(|cx| ProjectExplorerPanel::new(engine.clone(), window, cx));
|
||||
let source_viewer =
|
||||
cx.new(|cx| SourceViewerPanel::new(engine.clone(), source_clock.clone(), window, cx));
|
||||
let program_viewer = cx.new(|cx| {
|
||||
ProgramViewerPanel::new(
|
||||
engine.clone(),
|
||||
program_clock.clone(),
|
||||
meter.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let node_editor = cx.new(|cx| NodeEditorPanel::new(window, cx));
|
||||
let inspector = cx.new(|cx| InspectorPanel::new(engine.clone(), window, cx));
|
||||
let history = cx.new(|cx| HistoryPanel::new(window, cx));
|
||||
let timeline_panel =
|
||||
cx.new(|cx| TimelinePanel::new(engine.clone(), timeline.clone(), window, cx));
|
||||
|
||||
// Arrange the default workspace: the design's 素材查看器 | 序列查看器 |
|
||||
// 检查器 row (project bin docked on the left), node editor + history
|
||||
// as tabs, timeline full width at the bottom.
|
||||
dock.update(cx, |dock, cx| {
|
||||
dock.add_panel(PanelHandle::new(project, cx), None, cx);
|
||||
dock.add_panel(
|
||||
PanelHandle::new(source_viewer, cx),
|
||||
Some(DropTarget {
|
||||
panel: Some(PROJECT),
|
||||
zone: DropZone::Right,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
dock.add_panel(
|
||||
PanelHandle::new(program_viewer, cx),
|
||||
Some(DropTarget {
|
||||
panel: Some(SOURCE_VIEWER),
|
||||
zone: DropZone::Right,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
dock.add_panel(
|
||||
PanelHandle::new(node_editor, cx),
|
||||
Some(DropTarget {
|
||||
panel: Some(PROGRAM_VIEWER),
|
||||
zone: DropZone::Center,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
dock.add_panel(
|
||||
PanelHandle::new(inspector, cx),
|
||||
Some(DropTarget {
|
||||
panel: Some(PROGRAM_VIEWER),
|
||||
zone: DropZone::Right,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
dock.add_panel(
|
||||
PanelHandle::new(history, cx),
|
||||
Some(DropTarget {
|
||||
panel: Some(INSPECTOR),
|
||||
zone: DropZone::Center,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
dock.add_panel(
|
||||
PanelHandle::new(timeline_panel, cx),
|
||||
Some(DropTarget {
|
||||
panel: None,
|
||||
zone: DropZone::Bottom,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
// Tune the default split ratios: top 70%, project bin 17% of the row.
|
||||
let mut layout: DockLayout = dock.read(cx).layout().clone();
|
||||
layout.resize_split(&NodePath(vec![]), 0.70);
|
||||
layout.resize_split(&NodePath(vec![0]), 0.17);
|
||||
dock.update(cx, |dock, cx| dock.set_layout(layout, cx));
|
||||
|
||||
// --- status bar ----------------------------------------------------
|
||||
let status_bar = cx.new(|cx| StatusBar::new(engine.clone(), program_clock.clone(), cx));
|
||||
|
||||
// Repaint the shell whenever the engine notifies.
|
||||
cx.observe(&engine, |this, _engine, cx| {
|
||||
cx.notify();
|
||||
let _ = this;
|
||||
})
|
||||
.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.
|
||||
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)"),
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
|
||||
// --- tick loop -----------------------------------------------------
|
||||
// Drives playback clocks, playhead sync and the audio meter at ~60Hz.
|
||||
let this = cx.weak_entity();
|
||||
window
|
||||
.spawn(cx, async move |cx: &mut AsyncWindowContext| loop {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(16))
|
||||
.await;
|
||||
let _ = cx.update(|_window, app| {
|
||||
if let Some(this) = this.upgrade() {
|
||||
this.update(app, |this, cx| this.tick(cx));
|
||||
}
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
engine,
|
||||
program_clock,
|
||||
timeline,
|
||||
meter,
|
||||
menu_bar,
|
||||
dock,
|
||||
status_bar,
|
||||
dark: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// One animation-frame tick: advance the engine, sync the timeline
|
||||
/// playhead to the program clock, and refresh the audio meter.
|
||||
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();
|
||||
self.timeline
|
||||
.update(cx, |timeline, cx| timeline.seek(frame, cx));
|
||||
self.meter.update(cx, |meter, cx| meter.update(cx));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Routes a menu action.
|
||||
fn on_menu(&mut self, item: usize, cx: &mut Context<Self>) {
|
||||
use menu_ids::*;
|
||||
match item {
|
||||
PLAY_PAUSE => {
|
||||
let playing = self.program_clock.read(cx).transport.is_playing();
|
||||
let monitor = Monitor::Program;
|
||||
self.engine.update(cx, |engine, cx| {
|
||||
if playing {
|
||||
engine.pause(monitor, cx);
|
||||
} else {
|
||||
engine.play(monitor, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
PREV_FRAME => {
|
||||
let monitor = Monitor::Program;
|
||||
self.engine
|
||||
.update(cx, |engine, cx| engine.step(monitor, -1, cx));
|
||||
}
|
||||
NEXT_FRAME => {
|
||||
let monitor = Monitor::Program;
|
||||
self.engine
|
||||
.update(cx, |engine, cx| engine.step(monitor, 1, cx));
|
||||
}
|
||||
TO_START => {
|
||||
let monitor = Monitor::Program;
|
||||
self.engine.update(cx, |engine, cx| {
|
||||
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),
|
||||
ADD_VIDEO_TRACK => {
|
||||
let kind = gpui::timeline::TrackKind::Video;
|
||||
self.engine
|
||||
.update(cx, |engine, cx| engine.add_track(kind, cx));
|
||||
}
|
||||
ADD_AUDIO_TRACK => {
|
||||
let kind = gpui::timeline::TrackKind::Audio;
|
||||
self.engine
|
||||
.update(cx, |engine, cx| engine.add_track(kind, cx));
|
||||
}
|
||||
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),
|
||||
FOCUS_NODE_EDITOR => self.focus_panel(NODE_EDITOR, cx),
|
||||
FOCUS_INSPECTOR => self.focus_panel(INSPECTOR, cx),
|
||||
FOCUS_HISTORY => self.focus_panel(HISTORY, cx),
|
||||
FOCUS_TIMELINE => self.focus_panel(TIMELINE, cx),
|
||||
other => println!("[menu] placeholder action for item {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
let dock = self.dock.clone();
|
||||
let _ = cx.update_window(*handle, move |_root, window, app| {
|
||||
dock.update(app, |dock, cx| dock.focus_panel(id, window, cx));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn switch_language(&mut self, language: crate::i18n::Language, cx: &mut Context<Self>) {
|
||||
crate::i18n::set_language(language);
|
||||
self.rebuild_menu_bar(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Replaces the `MenuBar` entity with one built from the current language
|
||||
/// and theme, re-subscribing to its trigger events.
|
||||
fn rebuild_menu_bar(&mut self, cx: &mut Context<Self>) {
|
||||
let windows = cx.windows();
|
||||
let Some(handle) = windows.first() else {
|
||||
return;
|
||||
};
|
||||
let dark = self.dark;
|
||||
let Ok(menu_bar) = cx.update_window(*handle, |_root, window, app| {
|
||||
app.new(|cx| MenuBar::new(1, make_menus(dark), window, cx))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
self.menu_bar = menu_bar;
|
||||
let menu_bar = self.menu_bar.clone();
|
||||
cx.subscribe(
|
||||
&menu_bar,
|
||||
|this, _menu: Entity<MenuBar>, event: &MenuBarEvent, cx| {
|
||||
if let MenuBarEvent::Triggered { item, .. } = event {
|
||||
this.on_menu(*item, cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for OakApp {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(self.menu_bar.clone())
|
||||
.child(div().flex_1().child(self.dock.clone()))
|
||||
.child(self.status_bar.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the menu bar entries (文件/编辑/视图/回放/序列/窗口/工具/帮助). All
|
||||
/// labels come from the [`crate::i18n`] tables, so rebuilding the menu bar
|
||||
/// after a language switch repaints it in the new language. `dark` drives the
|
||||
/// theme submenu's checkmark.
|
||||
fn make_menus(dark: bool) -> Vec<MenuBarEntry> {
|
||||
use crate::i18n::tr;
|
||||
use menu_ids::*;
|
||||
|
||||
let current = crate::i18n::language();
|
||||
|
||||
let theme_submenu = Menu::new(vec![
|
||||
MenuItem::new(THEME_DARK, tr("menu.view.theme.dark")).with_checked(dark),
|
||||
MenuItem::new(THEME_LIGHT, tr("menu.view.theme.light")).with_checked(!dark),
|
||||
]);
|
||||
|
||||
let language_submenu = Menu::new(vec![
|
||||
MenuItem::new(LANG_ZH, tr("menu.view.language.zh"))
|
||||
.with_checked(current == crate::i18n::Language::ZhCN),
|
||||
MenuItem::new(LANG_EN, tr("menu.view.language.en"))
|
||||
.with_checked(current == crate::i18n::Language::EnUs),
|
||||
]);
|
||||
|
||||
vec![
|
||||
MenuBarEntry::new(
|
||||
tr("menu.file"),
|
||||
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(QUIT, tr("menu.file.quit")).with_shortcut("⌘Q").separated(),
|
||||
]),
|
||||
),
|
||||
MenuBarEntry::new(
|
||||
tr("menu.edit"),
|
||||
Menu::new(vec![
|
||||
MenuItem::new(UNDO, tr("menu.edit.undo")).with_shortcut("⌘Z"),
|
||||
MenuItem::new(REDO, tr("menu.edit.redo")).with_shortcut("⇧⌘Z").separated(),
|
||||
MenuItem::new(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(),
|
||||
]),
|
||||
),
|
||||
MenuBarEntry::new(
|
||||
tr("menu.view"),
|
||||
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),
|
||||
]),
|
||||
),
|
||||
MenuBarEntry::new(
|
||||
tr("menu.playback"),
|
||||
Menu::new(vec![
|
||||
MenuItem::new(PLAY_PAUSE, tr("menu.playback.play_pause")).with_shortcut("空格"),
|
||||
MenuItem::new(PREV_FRAME, tr("menu.playback.prev_frame")).with_shortcut("←"),
|
||||
MenuItem::new(NEXT_FRAME, tr("menu.playback.next_frame"))
|
||||
.with_shortcut("→")
|
||||
.separated(),
|
||||
MenuItem::new(TO_START, tr("menu.playback.to_start")).with_shortcut("Home"),
|
||||
]),
|
||||
),
|
||||
MenuBarEntry::new(
|
||||
tr("menu.sequence"),
|
||||
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(),
|
||||
]),
|
||||
),
|
||||
MenuBarEntry::new(
|
||||
tr("menu.window"),
|
||||
Menu::new(vec![
|
||||
MenuItem::new(FOCUS_PROJECT, tr("menu.window.project")),
|
||||
MenuItem::new(FOCUS_SOURCE_VIEWER, tr("menu.window.source_viewer")),
|
||||
MenuItem::new(FOCUS_PROGRAM_VIEWER, tr("menu.window.program_viewer")),
|
||||
MenuItem::new(FOCUS_NODE_EDITOR, tr("menu.window.node_editor")),
|
||||
MenuItem::new(FOCUS_INSPECTOR, tr("menu.window.inspector")),
|
||||
MenuItem::new(FOCUS_HISTORY, tr("menu.window.history")),
|
||||
MenuItem::new(FOCUS_TIMELINE, tr("menu.window.timeline")),
|
||||
]),
|
||||
),
|
||||
MenuBarEntry::new(
|
||||
tr("menu.tools"),
|
||||
Menu::new(vec![
|
||||
MenuItem::new(701, tr("menu.tools.select")),
|
||||
MenuItem::new(702, tr("menu.tools.razor")),
|
||||
MenuItem::new(703, tr("menu.tools.snap")).with_checked(true),
|
||||
]),
|
||||
),
|
||||
MenuBarEntry::new(
|
||||
tr("menu.help"),
|
||||
Menu::new(vec![MenuItem::new(ABOUT, tr("menu.help.about"))]),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The crate entry point: applies the olive-dark theme and opens the main
|
||||
/// window.
|
||||
pub fn run() {
|
||||
gpui_platform::application().run(|cx: &mut App| {
|
||||
// Restore the persisted UI language (oakcommon 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);
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
..Default::default()
|
||||
},
|
||||
|window, cx| cx.new(|cx| OakApp::new(window, cx)),
|
||||
)
|
||||
.expect("failed to open the main window");
|
||||
|
||||
cx.activate(true);
|
||||
cx.on_window_closed(|cx, _| {
|
||||
if cx.windows().is_empty() {
|
||||
cx.quit();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The 视图/View menu carries a 语言/Language submenu whose items are
|
||||
/// labeled in their own language and whose checkmark follows the active
|
||||
/// language — and the whole menu bar flips language with `i18n`.
|
||||
#[test]
|
||||
fn language_menu_tracks_the_active_language() {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
|
||||
let view_entry = |dark: bool| -> MenuBarEntry {
|
||||
make_menus(dark)
|
||||
.into_iter()
|
||||
.find(|entry| entry.title == crate::i18n::tr("menu.view"))
|
||||
.expect("视图/View menu exists")
|
||||
};
|
||||
|
||||
// The language submenu sits under 视图/View.
|
||||
let language_item = |entry: &MenuBarEntry| -> Menu {
|
||||
entry
|
||||
.menu
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.id == menu_ids::LANG_ZH)
|
||||
.map(|item| item.submenu.clone().map(|m| *m).unwrap_or_default())
|
||||
.expect("语言/Language submenu exists")
|
||||
};
|
||||
|
||||
crate::i18n::set_language(crate::i18n::Language::EnUs);
|
||||
let submenu = language_item(&view_entry(true));
|
||||
let zh = submenu
|
||||
.items
|
||||
.iter()
|
||||
.find(|i| i.id == menu_ids::LANG_ZH)
|
||||
.expect("zh item");
|
||||
let en = submenu
|
||||
.items
|
||||
.iter()
|
||||
.find(|i| i.id == menu_ids::LANG_EN)
|
||||
.expect("en item");
|
||||
assert_eq!(zh.label, "简体中文");
|
||||
assert_eq!(en.label, "English");
|
||||
assert_eq!(zh.checked, Some(false));
|
||||
assert_eq!(en.checked, Some(true), "en-US is active → checked");
|
||||
|
||||
crate::i18n::set_language(crate::i18n::Language::ZhCN);
|
||||
let submenu = language_item(&view_entry(true));
|
||||
let zh = submenu
|
||||
.items
|
||||
.iter()
|
||||
.find(|i| i.id == menu_ids::LANG_ZH)
|
||||
.expect("zh item");
|
||||
let en = submenu
|
||||
.items
|
||||
.iter()
|
||||
.find(|i| i.id == menu_ids::LANG_EN)
|
||||
.expect("en item");
|
||||
assert_eq!(zh.checked, Some(true), "zh-CN is active → checked");
|
||||
assert_eq!(en.checked, Some(false));
|
||||
|
||||
// The menu titles themselves are localized.
|
||||
assert_eq!(view_entry(true).title, "视图(V)");
|
||||
crate::i18n::set_language(crate::i18n::Language::EnUs);
|
||||
assert_eq!(view_entry(true).title, "View(V)");
|
||||
}
|
||||
|
||||
/// The theme submenu's checkmark follows the `dark` flag.
|
||||
#[test]
|
||||
fn theme_menu_checkmark_follows_dark_flag() {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
|
||||
let dark_item = |dark: bool| -> gpui_widgets::menu::MenuItem {
|
||||
let entries = make_menus(dark);
|
||||
let view = entries
|
||||
.iter()
|
||||
.find(|entry| entry.title == crate::i18n::tr("menu.view"))
|
||||
.expect("视图/View menu");
|
||||
let theme = view
|
||||
.menu
|
||||
.items
|
||||
.iter()
|
||||
.find(|i| i.id == menu_ids::THEME_DARK)
|
||||
.and_then(|i| i.submenu.clone())
|
||||
.expect("theme submenu");
|
||||
theme
|
||||
.items
|
||||
.into_iter()
|
||||
.find(|i| i.id == menu_ids::THEME_DARK)
|
||||
.expect("Olive Dark item")
|
||||
};
|
||||
assert_eq!(dark_item(true).checked, Some(true));
|
||||
assert_eq!(dark_item(false).checked, Some(false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
// 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/>.
|
||||
|
||||
//! 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`).
|
||||
//!
|
||||
//! # The tables
|
||||
//!
|
||||
//! Plain key → string arrays (no serde, no build step). [`tr`] falls back
|
||||
//! from the active language to en-US and then to the key itself, so a
|
||||
//! missing key can never panic — it degrades to a visible-but-identifiable
|
||||
//! key string instead.
|
||||
//!
|
||||
//! # The language setting
|
||||
//!
|
||||
//! 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`.
|
||||
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
/// The languages shipped with the app.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Language {
|
||||
/// English (United States).
|
||||
EnUs,
|
||||
/// Simplified Chinese.
|
||||
ZhCN,
|
||||
}
|
||||
|
||||
impl Language {
|
||||
/// The stable config/table code, e.g. `"en-US"`.
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Language::EnUs => "en-US",
|
||||
Language::ZhCN => "zh-CN",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a stored config value into a language. Empty or unknown values
|
||||
/// fall back to en-US.
|
||||
fn from_code(code: &str) -> Self {
|
||||
let code = code.trim().to_ascii_lowercase();
|
||||
if code.starts_with("zh") {
|
||||
Language::ZhCN
|
||||
} else {
|
||||
Language::EnUs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The current language, as an atomic tag (0 = en-US, 1 = zh-CN).
|
||||
static CURRENT: AtomicU8 = AtomicU8::new(0);
|
||||
|
||||
/// The active language.
|
||||
pub fn language() -> Language {
|
||||
match CURRENT.load(Ordering::Relaxed) {
|
||||
1 => Language::ZhCN,
|
||||
_ => Language::EnUs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Switches the active language live and persists the choice through the
|
||||
/// oakcommon config C ABI (when the library is loadable).
|
||||
pub fn set_language(language: Language) {
|
||||
CURRENT.store(match language {
|
||||
Language::EnUs => 0,
|
||||
Language::ZhCN => 1,
|
||||
}, Ordering::Relaxed);
|
||||
persist_language(language);
|
||||
}
|
||||
|
||||
/// Loads the persisted language from the oakcommon config C ABI. Called once
|
||||
/// at startup. Never fails: without liboakcommon the default (en-US) stays.
|
||||
pub fn init() {
|
||||
let Some(store) = ConfigAbi::load() else {
|
||||
return;
|
||||
};
|
||||
match store.get("Language") {
|
||||
Some(code) if !code.is_empty() => set_language(Language::from_code(&code)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `language` back to the oakcommon config `Language` key.
|
||||
fn persist_language(language: Language) {
|
||||
if let Some(store) = ConfigAbi::load() {
|
||||
store.set("Language", language.code());
|
||||
}
|
||||
}
|
||||
|
||||
/// Translates `key` in the active language.
|
||||
///
|
||||
/// Never panics: unknown keys fall back to en-US and then to the key itself,
|
||||
/// so a typo'd key is visible in the UI instead of crashing it.
|
||||
pub fn tr(key: &'static str) -> &'static str {
|
||||
match language() {
|
||||
Language::EnUs => en(key),
|
||||
Language::ZhCN => zh(key).unwrap_or_else(|| en(key)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Looks `key` up in the en-US table.
|
||||
fn en(key: &'static str) -> &'static str {
|
||||
EN
|
||||
.iter()
|
||||
.find(|(k, _)| *k == key)
|
||||
.map(|(_, v)| *v)
|
||||
.unwrap_or(key)
|
||||
}
|
||||
|
||||
/// Looks `key` up in the zh-CN table.
|
||||
fn zh(key: &'static str) -> Option<&'static str> {
|
||||
ZH
|
||||
.iter()
|
||||
.find(|(k, _)| *k == key)
|
||||
.map(|(_, v)| *v)
|
||||
}
|
||||
|
||||
/// The en-US table. Every key must also exist in [`ZH`]; [`tr`] tolerates
|
||||
/// missing entries but the tests below enforce parity.
|
||||
const EN: &[(&str, &str)] = &[
|
||||
// --- menu bar titles ---
|
||||
("menu.file", "File(F)"),
|
||||
("menu.edit", "Edit(E)"),
|
||||
("menu.view", "View(V)"),
|
||||
("menu.playback", "Playback(P)"),
|
||||
("menu.sequence", "Sequence(S)"),
|
||||
("menu.window", "Window(W)"),
|
||||
("menu.tools", "Tools(T)"),
|
||||
("menu.help", "Help(H)"),
|
||||
// --- File ---
|
||||
("menu.file.new_project", "New Project…"),
|
||||
("menu.file.open_project", "Open Project…"),
|
||||
("menu.file.save", "Save"),
|
||||
("menu.file.export", "Export…"),
|
||||
("menu.file.quit", "Quit"),
|
||||
// --- Edit ---
|
||||
("menu.edit.undo", "Undo"),
|
||||
("menu.edit.redo", "Redo"),
|
||||
("menu.edit.cut", "Cut"),
|
||||
("menu.edit.copy", "Copy"),
|
||||
("menu.edit.paste", "Paste"),
|
||||
("menu.edit.delete", "Delete"),
|
||||
// --- View ---
|
||||
("menu.view.theme", "Theme"),
|
||||
("menu.view.theme.dark", "Olive Dark"),
|
||||
("menu.view.theme.light", "Olive Light"),
|
||||
("menu.view.language", "Language"),
|
||||
("menu.view.language.en", "English"),
|
||||
("menu.view.language.zh", "简体中文"),
|
||||
// --- Playback ---
|
||||
("menu.playback.play_pause", "Play/Pause"),
|
||||
("menu.playback.prev_frame", "Previous Frame"),
|
||||
("menu.playback.next_frame", "Next Frame"),
|
||||
("menu.playback.to_start", "Jump to Sequence Start"),
|
||||
// --- Sequence ---
|
||||
("menu.sequence.add_video_track", "Add Video Track"),
|
||||
("menu.sequence.add_audio_track", "Add Audio Track"),
|
||||
("menu.sequence.settings", "Sequence Settings…"),
|
||||
// --- Window ---
|
||||
("menu.window.project", "Project"),
|
||||
("menu.window.source_viewer", "Source Viewer"),
|
||||
("menu.window.program_viewer", "Program Viewer"),
|
||||
("menu.window.node_editor", "Node Editor"),
|
||||
("menu.window.inspector", "Inspector"),
|
||||
("menu.window.history", "History"),
|
||||
("menu.window.timeline", "Timeline"),
|
||||
// --- Tools ---
|
||||
("menu.tools.select", "Select"),
|
||||
("menu.tools.razor", "Razor"),
|
||||
("menu.tools.snap", "Snap"),
|
||||
// --- Help ---
|
||||
("menu.help.about", "About Oak…"),
|
||||
// --- dock panel titles ---
|
||||
("panel.project", "Project"),
|
||||
("panel.source_viewer", "Source Viewer"),
|
||||
("panel.program_viewer", "Program Viewer"),
|
||||
("panel.node_editor", "Node Editor"),
|
||||
("panel.inspector", "Inspector"),
|
||||
("panel.history", "History"),
|
||||
("panel.timeline", "Timeline"),
|
||||
// --- status bar ---
|
||||
("status.ready", "Ready"),
|
||||
("status.cache", "Cache: Enabled"),
|
||||
("status.proxy", "Proxy: Off"),
|
||||
("status.autosave", "Autosave: 3 min ago"),
|
||||
("status.untitled", "Untitled Project"),
|
||||
// --- timeline toolbar ---
|
||||
("timeline.tool.select", "Select"),
|
||||
("timeline.tool.razor", "Razor"),
|
||||
("timeline.tool.ripple", "Ripple"),
|
||||
("timeline.tool.slip", "Slip"),
|
||||
("timeline.tool.roll", "Roll"),
|
||||
("timeline.tool.zoom", "Zoom"),
|
||||
("timeline.tool.knife", "Knife"),
|
||||
("timeline.tool.marker", "Marker"),
|
||||
("timeline.zoom", "Zoom"),
|
||||
("timeline.track_height", "Track Height"),
|
||||
("timeline.snap", "Snap"),
|
||||
// --- node editor ---
|
||||
("node.zoom_in", "Zoom In"),
|
||||
("node.zoom_out", "Zoom Out"),
|
||||
("node.fit", "Fit"),
|
||||
("node.fit_window", "Fit Window"),
|
||||
("node.placeholder", "Node Editor · Placeholder — gpui::node_graph not wired up yet"),
|
||||
// --- viewer header chips ---
|
||||
("viewer.source", "Source Viewer · Source"),
|
||||
("viewer.program", "Program Viewer · Program"),
|
||||
// --- inspector ---
|
||||
("inspector.params", "Parameters (placeholder)"),
|
||||
];
|
||||
|
||||
/// The zh-CN table. Mirrors [`EN`] key-for-key.
|
||||
const ZH: &[(&str, &str)] = &[
|
||||
// --- menu bar titles ---
|
||||
("menu.file", "文件(F)"),
|
||||
("menu.edit", "编辑(E)"),
|
||||
("menu.view", "视图(V)"),
|
||||
("menu.playback", "回放(P)"),
|
||||
("menu.sequence", "序列(S)"),
|
||||
("menu.window", "窗口(W)"),
|
||||
("menu.tools", "工具(T)"),
|
||||
("menu.help", "帮助(H)"),
|
||||
// --- File ---
|
||||
("menu.file.new_project", "新建项目…"),
|
||||
("menu.file.open_project", "打开项目…"),
|
||||
("menu.file.save", "保存"),
|
||||
("menu.file.export", "导出…"),
|
||||
("menu.file.quit", "退出"),
|
||||
// --- Edit ---
|
||||
("menu.edit.undo", "撤销"),
|
||||
("menu.edit.redo", "重做"),
|
||||
("menu.edit.cut", "剪切"),
|
||||
("menu.edit.copy", "复制"),
|
||||
("menu.edit.paste", "粘贴"),
|
||||
("menu.edit.delete", "删除"),
|
||||
// --- View ---
|
||||
("menu.view.theme", "主题"),
|
||||
("menu.view.theme.dark", "Olive Dark"),
|
||||
("menu.view.theme.light", "Olive Light"),
|
||||
("menu.view.language", "语言"),
|
||||
("menu.view.language.en", "English"),
|
||||
("menu.view.language.zh", "简体中文"),
|
||||
// --- Playback ---
|
||||
("menu.playback.play_pause", "播放/暂停"),
|
||||
("menu.playback.prev_frame", "上一帧"),
|
||||
("menu.playback.next_frame", "下一帧"),
|
||||
("menu.playback.to_start", "跳到序列起点"),
|
||||
// --- Sequence ---
|
||||
("menu.sequence.add_video_track", "添加视频轨道"),
|
||||
("menu.sequence.add_audio_track", "添加音频轨道"),
|
||||
("menu.sequence.settings", "序列设置…"),
|
||||
// --- Window ---
|
||||
("menu.window.project", "项目"),
|
||||
("menu.window.source_viewer", "素材查看器"),
|
||||
("menu.window.program_viewer", "序列查看器"),
|
||||
("menu.window.node_editor", "节点编辑器"),
|
||||
("menu.window.inspector", "检查器"),
|
||||
("menu.window.history", "历史记录"),
|
||||
("menu.window.timeline", "时间线"),
|
||||
// --- Tools ---
|
||||
("menu.tools.select", "选择"),
|
||||
("menu.tools.razor", "剃刀"),
|
||||
("menu.tools.snap", "吸附"),
|
||||
// --- Help ---
|
||||
("menu.help.about", "关于 Oak…"),
|
||||
// --- dock panel titles ---
|
||||
("panel.project", "项目"),
|
||||
("panel.source_viewer", "素材查看器"),
|
||||
("panel.program_viewer", "序列查看器"),
|
||||
("panel.node_editor", "节点编辑器"),
|
||||
("panel.inspector", "检查器"),
|
||||
("panel.history", "历史记录"),
|
||||
("panel.timeline", "时间线"),
|
||||
// --- status bar ---
|
||||
("status.ready", "就绪"),
|
||||
("status.cache", "缓存:已启用"),
|
||||
("status.proxy", "代理:关"),
|
||||
("status.autosave", "自动保存:3分钟前"),
|
||||
("status.untitled", "未命名项目"),
|
||||
// --- timeline toolbar ---
|
||||
("timeline.tool.select", "选择"),
|
||||
("timeline.tool.razor", "剃刀"),
|
||||
("timeline.tool.ripple", "波纹"),
|
||||
("timeline.tool.slip", "滑动"),
|
||||
("timeline.tool.roll", "滚动"),
|
||||
("timeline.tool.zoom", "缩放"),
|
||||
("timeline.tool.knife", "刀"),
|
||||
("timeline.tool.marker", "标记"),
|
||||
("timeline.zoom", "缩放"),
|
||||
("timeline.track_height", "轨道高"),
|
||||
("timeline.snap", "吸附"),
|
||||
// --- node editor ---
|
||||
("node.zoom_in", "放大"),
|
||||
("node.zoom_out", "缩小"),
|
||||
("node.fit", "适配"),
|
||||
("node.fit_window", "适配窗口"),
|
||||
("node.placeholder", "节点编辑器 · 占位 — gpui::node_graph 尚未接入"),
|
||||
// --- viewer header chips ---
|
||||
("viewer.source", "素材查看器 · 源"),
|
||||
("viewer.program", "序列查看器 · 节目"),
|
||||
// --- inspector ---
|
||||
("inspector.params", "参数(占位)"),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// oakcommon config C ABI (runtime-resolved)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// Serializes every test that mutates the process-global language, so
|
||||
/// parallel tests (in this module and in [`crate::app`]) cannot race each
|
||||
/// other's `set_language` calls.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn lang_test_lock() -> &'static std::sync::Mutex<()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
&LOCK
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn lang_lock() -> &'static std::sync::Mutex<()> {
|
||||
lang_test_lock()
|
||||
}
|
||||
|
||||
/// Every key must exist in both tables, with a non-empty translation.
|
||||
#[test]
|
||||
fn every_key_exists_in_both_languages() {
|
||||
assert_eq!(EN.len(), ZH.len(), "tables must have identical key sets");
|
||||
for (key, en_value) in EN {
|
||||
assert!(!key.is_empty());
|
||||
assert!(!en_value.is_empty(), "empty en-US value for {key}");
|
||||
let zh_value = ZH
|
||||
.iter()
|
||||
.find(|(k, _)| *k == *key)
|
||||
.unwrap_or_else(|| panic!("zh-CN table is missing key {key}"));
|
||||
assert!(
|
||||
!zh_value.1.is_empty(),
|
||||
"empty zh-CN value for {key}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys are unique within each table (a duplicate would make `tr`'
|
||||
/// lookup order-dependent).
|
||||
#[test]
|
||||
fn keys_are_unique() {
|
||||
for table in [EN, ZH] {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (key, _) in table {
|
||||
assert!(seen.insert(*key), "duplicate key {key}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `tr` never panics, even for a key that is in neither table — the raw
|
||||
/// key comes back so the omission is visible.
|
||||
#[test]
|
||||
fn tr_never_panics_on_missing_key() {
|
||||
let _guard = lang_lock().lock().unwrap();
|
||||
for (language, sample) in [(Language::EnUs, "missing.key"), (Language::ZhCN, "missing.key")] {
|
||||
set_language(language);
|
||||
assert_eq!(tr(sample), sample);
|
||||
}
|
||||
}
|
||||
|
||||
/// `tr` returns the en-US string when a key exists in en-US only.
|
||||
#[test]
|
||||
fn tr_falls_back_to_en() {
|
||||
let _guard = lang_lock().lock().unwrap();
|
||||
set_language(Language::ZhCN);
|
||||
// All real keys exist in both tables, so force the fallback path via
|
||||
// a key that exists in EN but not ZH by temporarily shadowing… not
|
||||
// possible with const tables — instead verify the en-US default.
|
||||
assert_eq!(tr("status.ready"), "就绪");
|
||||
set_language(Language::EnUs);
|
||||
assert_eq!(tr("status.ready"), "Ready");
|
||||
}
|
||||
|
||||
/// Switching languages flips a sample string live.
|
||||
#[test]
|
||||
fn switching_flips_a_sample_string() {
|
||||
let _guard = lang_lock().lock().unwrap();
|
||||
set_language(Language::EnUs);
|
||||
assert_eq!(tr("menu.file.save"), "Save");
|
||||
set_language(Language::ZhCN);
|
||||
assert_eq!(tr("menu.file.save"), "保存");
|
||||
set_language(Language::EnUs);
|
||||
assert_eq!(tr("menu.file.save"), "Save");
|
||||
}
|
||||
|
||||
/// The config code round-trips.
|
||||
#[test]
|
||||
fn language_code_round_trips() {
|
||||
assert_eq!(Language::EnUs.code(), "en-US");
|
||||
assert_eq!(Language::ZhCN.code(), "zh-CN");
|
||||
assert_eq!(Language::from_code("en-US"), Language::EnUs);
|
||||
assert_eq!(Language::from_code("zh-CN"), Language::ZhCN);
|
||||
assert_eq!(Language::from_code("zh_CN"), Language::ZhCN);
|
||||
assert_eq!(Language::from_code(""), Language::EnUs);
|
||||
assert_eq!(Language::from_code("klingon"), Language::EnUs);
|
||||
}
|
||||
|
||||
/// The active language reads back what was set.
|
||||
#[test]
|
||||
fn language_state_tracks_set_language() {
|
||||
let _guard = lang_lock().lock().unwrap();
|
||||
set_language(Language::ZhCN);
|
||||
assert_eq!(language(), Language::ZhCN);
|
||||
set_language(Language::EnUs);
|
||||
assert_eq!(language(), Language::EnUs);
|
||||
}
|
||||
|
||||
/// `init()` (and the config path generally) must not panic when the
|
||||
/// oakcommon library is absent — which is the default under `cargo test`.
|
||||
#[test]
|
||||
fn init_does_not_panic_without_oakcommon() {
|
||||
let _guard = lang_lock().lock().unwrap();
|
||||
init();
|
||||
// The language remains whatever it was; only the ABI path was
|
||||
// exercised (falling back silently).
|
||||
assert!(matches!(language(), Language::EnUs | Language::ZhCN));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// 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/>.
|
||||
|
||||
//! `oakapp` — the Rust application layer of Oak, built on the `gpui` UI
|
||||
//! framework (the oak-gpui fork at `gpui/`).
|
||||
//!
|
||||
//! 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).
|
||||
//!
|
||||
//! # Layout
|
||||
//!
|
||||
//! * [`app`] — the window shell: menu bar, dock layout, status bar, tick
|
||||
//! loop.
|
||||
//! * [`panels`] — the dockable panels (viewers, timeline, inspector, ...).
|
||||
//! * [`oakui`] — the engine gateway trait, the mock implementation, and the
|
||||
//! pure view-state logic (timecode, transport).
|
||||
//!
|
||||
//! # Running
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --bin oakapp # the demo window
|
||||
//! cargo test # unit tests (timecode, transport)
|
||||
//! ```
|
||||
|
||||
pub mod app;
|
||||
pub mod i18n;
|
||||
pub mod oakui;
|
||||
pub mod panels;
|
||||
|
||||
/// The application entry point (called from `main.rs`).
|
||||
pub fn run() {
|
||||
app::run();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// 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 `oakapp` binary: opens the Oak main window (see [`oakapp::app`]).
|
||||
|
||||
fn main() {
|
||||
oakapp::run();
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// 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 engine gateway: the narrow Rust API through which the app layer talks
|
||||
//! to the engine.
|
||||
//!
|
||||
//! # Why a gateway trait
|
||||
//!
|
||||
//! The UI must never depend on *how* the engine is implemented. Today the
|
||||
//! only implementation is the mock ([`super::mock::MockEngine`]) feeding demo
|
||||
//! data; later a real backend will bind the `liboakengine` C ABI
|
||||
//! (`src/facade/rust`, the frozen `oakengine_*` exports) behind the *same*
|
||||
//! trait. Swapping backends then touches only the wiring in
|
||||
//! [`crate::app`] — the panels, the widgets and the view state stay as they
|
||||
//! are.
|
||||
//!
|
||||
//! The trait is intentionally narrow: open a project, inspect the current
|
||||
//! sequence, and drive the transport (play / pause / step / seek). Timeline
|
||||
//! edits arrive as widget request events and are applied by the host through
|
||||
//! methods on the engine type itself (see the `MockEngine` docs for the
|
||||
//! current mapping), so they do not need to be part of this seam yet.
|
||||
//!
|
||||
//! 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;
|
||||
|
||||
/// A monitor the transport can address.
|
||||
///
|
||||
/// Oak has two independent transports: the source monitor plays the clip
|
||||
/// shown in the source viewer (素材查看器), the program monitor plays the
|
||||
/// sequence shown in the program viewer (序列查看器).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Monitor {
|
||||
/// The source (footage) monitor.
|
||||
Source,
|
||||
/// The program (sequence) monitor.
|
||||
Program,
|
||||
}
|
||||
|
||||
/// A video format: resolution plus frame rate.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct VideoFormat {
|
||||
/// Width in pixels.
|
||||
pub width: u32,
|
||||
/// Height in pixels.
|
||||
pub height: u32,
|
||||
/// The frame rate (rational, e.g. 30000/1001 for NTSC 29.97).
|
||||
pub rate: FrameRate,
|
||||
}
|
||||
|
||||
impl VideoFormat {
|
||||
/// The classic HD television format: 1920×1080 at 25 fps.
|
||||
pub fn hd_1080p25() -> Self {
|
||||
Self {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
rate: FrameRate::new(25, 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A project open in the engine.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Project {
|
||||
/// The project's display name.
|
||||
pub name: String,
|
||||
/// The project file on disk (`.ove`).
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// The sequence currently open in the project.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Sequence {
|
||||
/// The sequence's display name.
|
||||
pub name: String,
|
||||
/// The sequence's video format.
|
||||
pub format: VideoFormat,
|
||||
/// The sequence length in frames.
|
||||
pub length: Frame,
|
||||
}
|
||||
|
||||
/// The engine gateway.
|
||||
///
|
||||
/// Implementations own the "engine" side of the app: project state, the
|
||||
/// current sequence, and the transport. Query methods are pure reads;
|
||||
/// mutating methods take a gpui [`Context`](gpui::Context) so the backend can
|
||||
/// update its observable entities (clocks, models) and notify them.
|
||||
pub trait EngineGateway: Sized {
|
||||
/// The currently open project, or `None` before any project is opened.
|
||||
fn project(&self) -> Option<&Project>;
|
||||
|
||||
/// The current sequence of the open project, if any.
|
||||
fn current_sequence(&self) -> Option<&Sequence>;
|
||||
|
||||
/// Open a project file. The backend loads it and becomes the source of
|
||||
/// truth for [`project`](EngineGateway::project) /
|
||||
/// [`current_sequence`](EngineGateway::current_sequence).
|
||||
fn open_project(&mut self, path: PathBuf, cx: &mut gpui::Context<Self>);
|
||||
|
||||
/// Seek `monitor` to `frame` (clamped to the sequence).
|
||||
fn request_frame(&mut self, monitor: Monitor, frame: Frame, cx: &mut gpui::Context<Self>);
|
||||
|
||||
/// Start playback on `monitor`.
|
||||
fn play(&mut self, monitor: Monitor, cx: &mut gpui::Context<Self>);
|
||||
|
||||
/// Pause playback on `monitor`, leaving the playhead where it is.
|
||||
fn pause(&mut self, monitor: Monitor, cx: &mut gpui::Context<Self>);
|
||||
|
||||
/// Step `monitor`'s playhead by `delta` frames (negative steps back).
|
||||
fn step(&mut self, monitor: Monitor, delta: i64, cx: &mut gpui::Context<Self>);
|
||||
|
||||
/// Advance the playback clocks by one wall-clock tick. Called on a
|
||||
/// periodic timer while any monitor is playing.
|
||||
fn tick(&mut self, cx: &mut gpui::Context<Self>);
|
||||
}
|
||||
@@ -0,0 +1,802 @@
|
||||
// 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 mock engine: a gpui entity that implements every data-source trait
|
||||
//! the widgets read, and feeds them demo data.
|
||||
//!
|
||||
//! # One entity, many roles
|
||||
//!
|
||||
//! [`MockEngine`] is the single source of truth for the demo project and
|
||||
//! implements, all on the same entity:
|
||||
//!
|
||||
//! * [`EngineGateway`] — the app's transport/project seam (see the
|
||||
//! [module docs](super::engine));
|
||||
//! * [`TimelineDataSource`] — the sequence model behind the timeline widget;
|
||||
//! * [`EffectStackDataSource`] — the 媒体 → 变换 → OCIO LUT → 输出 stack;
|
||||
//! * [`ProjectDataSource`] — the material bin tree;
|
||||
//! * [`AudioMeterDataSource`] — the two program-master channels.
|
||||
//!
|
||||
//! Transport state lives in two [`MockClock`] entities (one per
|
||||
//! [`Monitor`]); the engine owns them and advances them on every
|
||||
//! [`EngineGateway::tick`].
|
||||
//!
|
||||
//! The real engine will later implement the same gateway over the
|
||||
//! `liboakengine` C ABI; only the wiring in [`crate::app`] changes.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use gpui::effect_stack::{
|
||||
EffectCardKind, EffectData, EffectId, EffectStackDataSource, EffectStackEvent,
|
||||
};
|
||||
use gpui::timeline::{
|
||||
ClipData, ClipId, Frame, FrameRange, FrameRate, TimelineDataSource, TrackData, TrackKind,
|
||||
};
|
||||
use gpui::{prelude::*, px, App, Context, Entity, Hsla, Pixels, SharedString};
|
||||
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::transport::TransportState;
|
||||
|
||||
/// The demo sequence length: 00:04:18:18 at 25 fps.
|
||||
const SEQUENCE_LENGTH: i64 = 6468;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A transport clock: the playhead plus the wall-clock anchor used while
|
||||
/// playing. This is the object the viewer widgets poll through
|
||||
/// [`PlaybackClock`].
|
||||
pub struct MockClock {
|
||||
/// The transport state (play/pause, playhead, loop range).
|
||||
pub transport: TransportState,
|
||||
/// The clock's frame rate.
|
||||
pub rate: FrameRate,
|
||||
/// Wall-clock anchor `(started_at, anchored_frame)` while playing.
|
||||
started: Option<(Instant, Frame)>,
|
||||
}
|
||||
|
||||
impl MockClock {
|
||||
/// A stopped clock at frame zero running at `rate`.
|
||||
pub fn new(rate: FrameRate) -> Self {
|
||||
Self {
|
||||
transport: TransportState::new(),
|
||||
rate,
|
||||
started: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts playback from the current playhead.
|
||||
pub fn play(&mut self) {
|
||||
self.transport.play();
|
||||
self.started = Some((Instant::now(), self.transport.frame()));
|
||||
}
|
||||
|
||||
/// Pauses playback, keeping the playhead.
|
||||
pub fn pause(&mut self) {
|
||||
self.transport.pause();
|
||||
self.started = None;
|
||||
}
|
||||
|
||||
/// Advances the playhead from the wall clock while playing, looping at
|
||||
/// `length`. No-op when stopped.
|
||||
pub fn tick(&mut self, length: Frame) {
|
||||
let Some((started, anchored)) = self.started else {
|
||||
return;
|
||||
};
|
||||
let elapsed = started.elapsed();
|
||||
let mut frame = anchored
|
||||
+ Frame(
|
||||
(elapsed.as_secs_f64() * self.rate.num as f64 / self.rate.den as f64).round()
|
||||
as i64,
|
||||
);
|
||||
if length.0 > 0 && frame.0 >= length.0 {
|
||||
// Loop back to the start of the sequence for the demo.
|
||||
frame = Frame(frame.0 % length.0);
|
||||
}
|
||||
self.transport.seek(frame, length);
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackClock for MockClock {
|
||||
fn current_frame(&self) -> Frame {
|
||||
self.transport.frame()
|
||||
}
|
||||
|
||||
fn is_playing(&self) -> bool {
|
||||
self.transport.is_playing()
|
||||
}
|
||||
|
||||
fn frame_rate(&self) -> FrameRate {
|
||||
self.rate
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timeline model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A clip on the demo timeline.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockClip {
|
||||
id: ClipId,
|
||||
range: FrameRange,
|
||||
media_in: Frame,
|
||||
label: SharedString,
|
||||
color: Hsla,
|
||||
}
|
||||
|
||||
impl ClipData for MockClip {
|
||||
fn id(&self) -> ClipId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn range(&self) -> FrameRange {
|
||||
self.range
|
||||
}
|
||||
|
||||
fn media_in(&self) -> Frame {
|
||||
self.media_in
|
||||
}
|
||||
|
||||
fn label(&self) -> SharedString {
|
||||
self.label.clone()
|
||||
}
|
||||
|
||||
fn color(&self) -> Option<Hsla> {
|
||||
Some(self.color)
|
||||
}
|
||||
}
|
||||
|
||||
/// A track on the demo timeline (snapshot handed to the timeline widget).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockTrack {
|
||||
kind: TrackKind,
|
||||
name: SharedString,
|
||||
height: Pixels,
|
||||
locked: bool,
|
||||
muted: bool,
|
||||
solo: bool,
|
||||
visible: bool,
|
||||
clips: Vec<MockClip>,
|
||||
}
|
||||
|
||||
impl TrackData for MockTrack {
|
||||
type Clip = MockClip;
|
||||
|
||||
fn kind(&self) -> TrackKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
fn name(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn is_locked(&self) -> bool {
|
||||
self.locked
|
||||
}
|
||||
|
||||
fn is_muted(&self) -> bool {
|
||||
self.muted
|
||||
}
|
||||
|
||||
fn is_solo(&self) -> bool {
|
||||
self.solo
|
||||
}
|
||||
|
||||
fn is_visible(&self) -> bool {
|
||||
self.visible
|
||||
}
|
||||
|
||||
fn height(&self) -> Pixels {
|
||||
self.height
|
||||
}
|
||||
|
||||
fn clips(&self) -> &[Self::Clip] {
|
||||
&self.clips
|
||||
}
|
||||
}
|
||||
|
||||
/// An effect card in the demo stack.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockEffect {
|
||||
id: EffectId,
|
||||
kind: EffectCardKind,
|
||||
title: SharedString,
|
||||
subtitle: Option<SharedString>,
|
||||
enabled: bool,
|
||||
expanded: bool,
|
||||
badge: Option<usize>,
|
||||
}
|
||||
|
||||
impl EffectData for MockEffect {
|
||||
fn id(&self) -> EffectId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn kind(&self) -> EffectCardKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
fn title(&self) -> SharedString {
|
||||
self.title.clone()
|
||||
}
|
||||
|
||||
fn subtitle(&self) -> Option<SharedString> {
|
||||
self.subtitle.clone()
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
fn is_expanded(&self) -> bool {
|
||||
self.expanded
|
||||
}
|
||||
|
||||
fn badge_count(&self) -> Option<usize> {
|
||||
self.badge
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The engine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The mock engine implementing every data-source trait over demo data.
|
||||
pub struct MockEngine {
|
||||
project: Project,
|
||||
sequence: Sequence,
|
||||
/// The source monitor's clock.
|
||||
pub source_clock: Entity<MockClock>,
|
||||
/// The program monitor's clock.
|
||||
pub program_clock: Entity<MockClock>,
|
||||
/// The timeline tracks.
|
||||
tracks: Vec<MockTrack>,
|
||||
/// The effect stack cards.
|
||||
effects: Vec<MockEffect>,
|
||||
/// Id allocator for effects added at runtime.
|
||||
next_effect_id: u64,
|
||||
/// Whether the program monitor is playing (mirrors the clock; kept here
|
||||
/// because the audio-meter data source has no `App` to read the clock).
|
||||
program_playing: bool,
|
||||
/// The selected item in the material bin (demo state).
|
||||
selected_item: Option<u64>,
|
||||
/// Phase counter driving the demo audio levels.
|
||||
meter_phase: u32,
|
||||
}
|
||||
|
||||
impl MockEngine {
|
||||
/// Builds the demo project: 第一稿.ove, one HD sequence, four tracks.
|
||||
pub fn demo(cx: &mut Context<Self>) -> Self {
|
||||
let rate = VideoFormat::hd_1080p25().rate;
|
||||
let clip =
|
||||
|id: u64, start: i64, end: i64, media_in: i64, label: &str, color: Hsla| MockClip {
|
||||
id: ClipId(id),
|
||||
range: FrameRange::new(Frame(start), Frame(end)),
|
||||
media_in: Frame(media_in),
|
||||
label: label.into(),
|
||||
color,
|
||||
};
|
||||
let video = |h: f32| Hsla {
|
||||
h,
|
||||
s: 0.55,
|
||||
l: 0.45,
|
||||
a: 1.0,
|
||||
};
|
||||
let audio = |h: f32| Hsla {
|
||||
h,
|
||||
s: 0.45,
|
||||
l: 0.55,
|
||||
a: 1.0,
|
||||
};
|
||||
|
||||
Self {
|
||||
project: Project {
|
||||
name: "第一稿".into(),
|
||||
path: PathBuf::from("/home/mikesolar/Videos/aaa.ove"),
|
||||
},
|
||||
sequence: Sequence {
|
||||
name: "第一稿".into(),
|
||||
format: VideoFormat::hd_1080p25(),
|
||||
length: Frame(SEQUENCE_LENGTH),
|
||||
},
|
||||
source_clock: cx.new(|_cx| MockClock::new(rate)),
|
||||
program_clock: cx.new(|_cx| MockClock::new(rate)),
|
||||
tracks: vec![
|
||||
MockTrack {
|
||||
kind: TrackKind::Video,
|
||||
name: "V2 视频轨道1".into(),
|
||||
height: px(64.0),
|
||||
locked: false,
|
||||
muted: false,
|
||||
solo: false,
|
||||
visible: true,
|
||||
clips: vec![clip(10, 120, 300, 0, "标题.mov", video(0.55))],
|
||||
},
|
||||
MockTrack {
|
||||
kind: TrackKind::Video,
|
||||
name: "V1 视频轨道0".into(),
|
||||
height: px(64.0),
|
||||
locked: false,
|
||||
muted: false,
|
||||
solo: false,
|
||||
visible: true,
|
||||
clips: vec![
|
||||
clip(11, 0, 240, 0, "开场.mov", video(0.60)),
|
||||
clip(12, 240, 600, 100, "B-roll.mp4", video(0.50)),
|
||||
],
|
||||
},
|
||||
MockTrack {
|
||||
kind: TrackKind::Audio,
|
||||
name: "A1 音频轨道0".into(),
|
||||
height: px(48.0),
|
||||
locked: false,
|
||||
muted: false,
|
||||
solo: false,
|
||||
visible: true,
|
||||
clips: vec![clip(13, 0, 600, 0, "对白.wav", audio(0.05))],
|
||||
},
|
||||
MockTrack {
|
||||
kind: TrackKind::Audio,
|
||||
name: "A2 音频轨道1".into(),
|
||||
height: px(48.0),
|
||||
locked: false,
|
||||
muted: false,
|
||||
solo: false,
|
||||
visible: true,
|
||||
clips: vec![clip(14, 0, 480, 0, "配乐.flac", audio(0.62))],
|
||||
},
|
||||
],
|
||||
effects: vec![
|
||||
MockEffect {
|
||||
id: EffectId(0),
|
||||
kind: EffectCardKind::Source,
|
||||
title: "媒体".into(),
|
||||
subtitle: Some("第一稿.mp4".into()),
|
||||
enabled: true,
|
||||
expanded: false,
|
||||
badge: None,
|
||||
},
|
||||
MockEffect {
|
||||
id: EffectId(1),
|
||||
kind: EffectCardKind::Effect,
|
||||
title: "变换".into(),
|
||||
subtitle: Some("缩放 100% · 旋转 0°".into()),
|
||||
enabled: true,
|
||||
expanded: true,
|
||||
badge: Some(2),
|
||||
},
|
||||
MockEffect {
|
||||
id: EffectId(2),
|
||||
kind: EffectCardKind::Effect,
|
||||
title: "OCIO LUT".into(),
|
||||
subtitle: Some("filmic_to_display.cube".into()),
|
||||
enabled: true,
|
||||
expanded: false,
|
||||
badge: None,
|
||||
},
|
||||
MockEffect {
|
||||
id: EffectId(3),
|
||||
kind: EffectCardKind::Output,
|
||||
title: "输出".into(),
|
||||
subtitle: None,
|
||||
enabled: true,
|
||||
expanded: false,
|
||||
badge: None,
|
||||
},
|
||||
],
|
||||
next_effect_id: 4,
|
||||
program_playing: false,
|
||||
selected_item: None,
|
||||
meter_phase: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The current sequence length (also used by the gateway).
|
||||
fn sequence_length(&self) -> Frame {
|
||||
self.sequence.length
|
||||
}
|
||||
|
||||
/// Resolves the clock entity for a monitor.
|
||||
fn clock(&self, monitor: Monitor) -> &Entity<MockClock> {
|
||||
match monitor {
|
||||
Monitor::Source => &self.source_clock,
|
||||
Monitor::Program => &self.program_clock,
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies an edit request from the effect stack to the model.
|
||||
pub fn apply_effect_event(&mut self, event: &EffectStackEvent, cx: &mut Context<Self>) {
|
||||
match event {
|
||||
EffectStackEvent::EnableToggled { effect, enabled } => {
|
||||
if let Some(effect) = self.effects.iter_mut().find(|e| e.id == *effect) {
|
||||
effect.enabled = *enabled;
|
||||
}
|
||||
}
|
||||
EffectStackEvent::ExpansionToggled { effect, expanded } => {
|
||||
if let Some(effect) = self.effects.iter_mut().find(|e| e.id == *effect) {
|
||||
effect.expanded = *expanded;
|
||||
}
|
||||
}
|
||||
EffectStackEvent::RemoveRequested(id) => {
|
||||
if let Some(index) = self
|
||||
.effects
|
||||
.iter()
|
||||
.position(|e| e.id == *id && e.is_removable())
|
||||
{
|
||||
self.effects.remove(index);
|
||||
}
|
||||
}
|
||||
EffectStackEvent::ReorderRequested { effect, new_index } => {
|
||||
let Some(from) = self.effects.iter().position(|e| e.id == *effect) else {
|
||||
return;
|
||||
};
|
||||
// `new_index` is an insertion index in the post-removal list.
|
||||
let card = self.effects.remove(from);
|
||||
let to = (*new_index).min(self.effects.len());
|
||||
self.effects.insert(to, card);
|
||||
}
|
||||
EffectStackEvent::AddRequested { index } => {
|
||||
let id = EffectId(self.next_effect_id);
|
||||
self.next_effect_id += 1;
|
||||
let card = MockEffect {
|
||||
id,
|
||||
kind: EffectCardKind::Effect,
|
||||
title: "新效果".into(),
|
||||
subtitle: None,
|
||||
enabled: true,
|
||||
expanded: false,
|
||||
badge: None,
|
||||
};
|
||||
let index = (*index).min(self.effects.len());
|
||||
self.effects.insert(index, card);
|
||||
}
|
||||
// The app owns the context menu; the mock ignores it.
|
||||
EffectStackEvent::ContextMenuRequested { .. }
|
||||
| EffectStackEvent::ParameterChanged { .. } => {}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Sets the row height of every timeline track (demo toolbar).
|
||||
pub fn set_track_height(&mut self, height: Pixels, cx: &mut Context<Self>) {
|
||||
for track in &mut self.tracks {
|
||||
track.height = height;
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Adds a new empty track of the given kind (demo "序列" menu).
|
||||
pub fn add_track(&mut self, kind: TrackKind, cx: &mut Context<Self>) {
|
||||
let index = self.tracks.len();
|
||||
let (name, height) = match kind {
|
||||
TrackKind::Video => (format!("V{} 视频轨道{}", index / 2 + 1, index), px(64.0)),
|
||||
TrackKind::Audio => (format!("A{} 音频轨道{}", index / 2 + 1, index), px(48.0)),
|
||||
TrackKind::Subtitle => (format!("S{} 字幕轨道", index + 1), px(32.0)),
|
||||
};
|
||||
self.tracks.push(MockTrack {
|
||||
kind,
|
||||
name: name.into(),
|
||||
height,
|
||||
locked: false,
|
||||
muted: false,
|
||||
solo: false,
|
||||
visible: true,
|
||||
clips: Vec::new(),
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The demo audio levels: animated while the program monitor plays.
|
||||
fn meter_levels(&self) -> Vec<f32> {
|
||||
if !self.program_playing {
|
||||
return vec![0.03, 0.03];
|
||||
}
|
||||
let t = (self.meter_phase % 120) as f32 / 120.0 * std::f32::consts::TAU;
|
||||
let level = 0.25 + 0.55 * (t.sin() * 0.6 + (2.0 * t).sin() * 0.4).abs();
|
||||
vec![level, level * 0.8]
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EngineGateway
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl EngineGateway for MockEngine {
|
||||
fn project(&self) -> Option<&Project> {
|
||||
Some(&self.project)
|
||||
}
|
||||
|
||||
fn current_sequence(&self) -> Option<&Sequence> {
|
||||
Some(&self.sequence)
|
||||
}
|
||||
|
||||
fn open_project(&mut self, path: PathBuf, cx: &mut Context<Self>) {
|
||||
self.project.path = path;
|
||||
if let Some(name) = self.project.path.file_stem() {
|
||||
self.project.name = name.to_string_lossy().into_owned();
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn request_frame(&mut self, monitor: Monitor, frame: Frame, cx: &mut Context<Self>) {
|
||||
let length = self.sequence_length();
|
||||
let clock = self.clock(monitor).clone();
|
||||
clock.update(cx, |clock, cx| {
|
||||
clock.transport.seek(frame, length);
|
||||
// Re-anchor so resuming continues from the new position.
|
||||
if clock.transport.is_playing() {
|
||||
clock.play();
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn play(&mut self, monitor: Monitor, cx: &mut Context<Self>) {
|
||||
if monitor == Monitor::Program {
|
||||
self.program_playing = true;
|
||||
}
|
||||
let clock = self.clock(monitor).clone();
|
||||
clock.update(cx, |clock, cx| {
|
||||
clock.play();
|
||||
cx.notify();
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn pause(&mut self, monitor: Monitor, cx: &mut Context<Self>) {
|
||||
if monitor == Monitor::Program {
|
||||
self.program_playing = false;
|
||||
}
|
||||
let clock = self.clock(monitor).clone();
|
||||
clock.update(cx, |clock, cx| {
|
||||
clock.pause();
|
||||
cx.notify();
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn step(&mut self, monitor: Monitor, delta: i64, cx: &mut Context<Self>) {
|
||||
let length = self.sequence_length();
|
||||
let clock = self.clock(monitor).clone();
|
||||
clock.update(cx, |clock, cx| {
|
||||
clock.transport.step(delta, length);
|
||||
if clock.transport.is_playing() {
|
||||
clock.play();
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn tick(&mut self, cx: &mut Context<Self>) {
|
||||
let length = self.sequence_length();
|
||||
for clock in [&self.source_clock, &self.program_clock] {
|
||||
let clock = clock.clone();
|
||||
clock.update(cx, |clock, cx| {
|
||||
clock.tick(length);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
self.meter_phase = self.meter_phase.wrapping_add(1);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data-source traits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl TimelineDataSource for MockEngine {
|
||||
type Track = MockTrack;
|
||||
|
||||
fn frame_rate(&self) -> FrameRate {
|
||||
self.sequence.format.rate
|
||||
}
|
||||
|
||||
fn sequence_length(&self) -> Frame {
|
||||
self.sequence.length
|
||||
}
|
||||
|
||||
fn track_count(&self) -> usize {
|
||||
self.tracks.len()
|
||||
}
|
||||
|
||||
fn track(&self, index: usize) -> Option<Self::Track> {
|
||||
self.tracks.get(index).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl EffectStackDataSource for MockEngine {
|
||||
fn effects(&self) -> Vec<Arc<dyn EffectData>> {
|
||||
self.effects
|
||||
.iter()
|
||||
.map(|effect| {
|
||||
Arc::new(MockEffect {
|
||||
id: effect.id,
|
||||
kind: effect.kind,
|
||||
title: effect.title.clone(),
|
||||
subtitle: effect.subtitle.clone(),
|
||||
enabled: effect.enabled,
|
||||
expanded: effect.expanded,
|
||||
badge: effect.badge,
|
||||
}) as Arc<dyn EffectData>
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn target_label(&self) -> Option<SharedString> {
|
||||
Some("第一稿.mp4 · 00:00:00:00–00:04:18:18".into())
|
||||
}
|
||||
}
|
||||
|
||||
impl ProjectDataSource for MockEngine {
|
||||
fn roots(&self) -> Vec<ProjectEntry> {
|
||||
vec![
|
||||
ProjectEntry::new(1, "素材", true),
|
||||
ProjectEntry::new(2, "音乐", true),
|
||||
ProjectEntry::new(3, "第一稿.mp4", false),
|
||||
ProjectEntry::new(4, "aaa.ove", false),
|
||||
]
|
||||
}
|
||||
|
||||
fn children(&self, parent_id: u64) -> Vec<ProjectEntry> {
|
||||
match parent_id {
|
||||
1 => vec![
|
||||
ProjectEntry::new(10, "intro.mov", false),
|
||||
ProjectEntry::new(11, "b-roll.mp4", false),
|
||||
ProjectEntry::new(12, "interview.mov", false),
|
||||
],
|
||||
2 => vec![
|
||||
ProjectEntry::new(20, "track-01.wav", false),
|
||||
ProjectEntry::new(21, "track-02.wav", false),
|
||||
],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioMeterDataSource for MockEngine {
|
||||
fn levels(&self) -> Vec<f32> {
|
||||
self.meter_levels()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience accessors used by panels and the status bar.
|
||||
impl MockEngine {
|
||||
/// The selected material-bin entry id (demo state).
|
||||
pub fn selected_item(&self) -> Option<u64> {
|
||||
self.selected_item
|
||||
}
|
||||
|
||||
/// Selects a material-bin entry (demo "open" action).
|
||||
pub fn select_item(&mut self, id: u64, cx: &mut Context<Self>) {
|
||||
self.selected_item = Some(id);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Reads the current frame of a monitor's clock.
|
||||
pub fn clock_frame(&self, monitor: Monitor, cx: &App) -> Frame {
|
||||
self.clock(monitor).read(cx).transport.frame()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::TestAppContext;
|
||||
|
||||
fn demo_engine(app: &mut gpui::App) -> Entity<MockEngine> {
|
||||
app.new(|cx| MockEngine::demo(cx))
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn demo_project_has_a_sequence_and_four_tracks(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
let engine = engine.read(app);
|
||||
let sequence = engine.current_sequence().expect("demo sequence");
|
||||
assert_eq!(sequence.name, "第一稿");
|
||||
assert_eq!(sequence.length, Frame(SEQUENCE_LENGTH));
|
||||
assert_eq!(sequence.format.width, 1920);
|
||||
assert_eq!(sequence.format.height, 1080);
|
||||
assert_eq!(engine.track_count(), 4);
|
||||
assert_eq!(engine.track(0).expect("V2").kind(), TrackKind::Video);
|
||||
assert_eq!(engine.track(2).expect("A1").kind(), TrackKind::Audio);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn gateway_play_starts_the_program_clock(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
engine.update(app, |engine, cx| {
|
||||
EngineGateway::play(engine, Monitor::Program, cx);
|
||||
});
|
||||
let clock = engine.read(app).program_clock.read(app);
|
||||
assert!(clock.transport.is_playing());
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn gateway_step_moves_the_clock_within_the_sequence(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.step(Monitor::Program, 25, cx);
|
||||
});
|
||||
let frame = engine.read(app).clock_frame(Monitor::Program, app);
|
||||
assert_eq!(frame, Frame(25));
|
||||
|
||||
// Stepping far past the end clamps to the last frame.
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.step(Monitor::Program, 1_000_000, cx);
|
||||
});
|
||||
let frame = engine.read(app).clock_frame(Monitor::Program, app);
|
||||
assert_eq!(frame, Frame(SEQUENCE_LENGTH - 1));
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn gateway_request_frame_seeks_and_pauses_stays(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.request_frame(Monitor::Source, Frame(42), cx);
|
||||
});
|
||||
let frame = engine.read(app).clock_frame(Monitor::Source, app);
|
||||
assert_eq!(frame, Frame(42));
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn effect_stack_edit_applies_to_the_model(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
// Remove the OCIO LUT card (id 2).
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.apply_effect_event(&EffectStackEvent::RemoveRequested(EffectId(2)), cx);
|
||||
});
|
||||
let stack = engine.read(app).effects();
|
||||
assert_eq!(stack.len(), 3);
|
||||
let titles: Vec<_> = stack.iter().map(|e| e.title().to_string()).collect();
|
||||
assert!(!titles.contains(&"OCIO LUT".to_string()));
|
||||
// Source and output cards are pinned and not removable.
|
||||
assert_eq!(titles.first().map(String::as_str), Some("媒体"));
|
||||
assert_eq!(titles.last().map(String::as_str), Some("输出"));
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn timeline_edits_are_requests_not_applied(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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// 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/>.
|
||||
|
||||
//! `oakui` — the app layer's engine seam and view-state logic.
|
||||
//!
|
||||
//! This module is what the UI talks to when it needs something from "the
|
||||
//! engine", plus the pure view-state logic the panels build on:
|
||||
//!
|
||||
//! * [`engine`] — the [`EngineGateway`](engine::EngineGateway) trait and the
|
||||
//! project/sequence model. The seam itself: panels and the shell hold an
|
||||
//! `Entity` whose type implements this trait and never care about the
|
||||
//! backend.
|
||||
//! * [`mock`] — [`MockEngine`](mock::MockEngine) and
|
||||
//! [`MockClock`](mock::MockClock), the demo implementation feeding every
|
||||
//! widget's data-source trait.
|
||||
//! * [`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 mock;
|
||||
pub mod timecode;
|
||||
pub mod transport;
|
||||
|
||||
pub use engine::{EngineGateway, Monitor, Project, Sequence, VideoFormat};
|
||||
pub use mock::{MockClock, MockEngine};
|
||||
pub use transport::{PlayState, TransportState};
|
||||
@@ -0,0 +1,180 @@
|
||||
// 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/>.
|
||||
|
||||
//! App-level time display: timecode, duration, frame-rate and resolution
|
||||
//! labels for the viewers, the timeline and the status bar.
|
||||
//!
|
||||
//! Canonical time stays in frames ([`Frame`]); formatting to human-readable
|
||||
//! text happens only here, at the display edge. This module is pure Rust
|
||||
//! (only the gpui *types* [`Frame`]/[`FrameRate`] are used) so it is fully
|
||||
//! unit-testable.
|
||||
//!
|
||||
//! # Timecode convention
|
||||
//!
|
||||
//! `HH:MM:SS:FF`, non-drop-frame, matching `gpui::timeline::format_timecode`
|
||||
//! and Oak's existing viewer widgets. Drop-frame timecode for NTSC rates is
|
||||
//! future work (see `gpui::timeline::time`).
|
||||
|
||||
use gpui::timeline::{Frame, FrameRate};
|
||||
|
||||
/// Formats `frame` as non-drop-frame timecode `HH:MM:SS:FF`.
|
||||
///
|
||||
/// The frame component uses two digits for rates below 100 fps (the nominal
|
||||
/// integer fps of the rate — 25 for 25/1, 30 for 30000/1001), matching the
|
||||
/// broadcast non-drop-frame convention. Negative frames get a leading `-`.
|
||||
///
|
||||
/// # 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");
|
||||
/// ```
|
||||
pub fn format_timecode(frame: Frame, rate: FrameRate) -> String {
|
||||
let negative = frame.0 < 0;
|
||||
let mut n = frame.0.unsigned_abs();
|
||||
let fps = nominal_fps(rate);
|
||||
let frames = n % fps;
|
||||
n /= fps;
|
||||
let seconds = n % 60;
|
||||
n /= 60;
|
||||
let minutes = n % 60;
|
||||
let hours = n / 60;
|
||||
format!(
|
||||
"{}{:02}:{:02}:{:02}:{:02}",
|
||||
if negative { "-" } else { "" },
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
frames
|
||||
)
|
||||
}
|
||||
|
||||
/// Formats a duration (a number of frames) as `HH:MM:SS:FF`.
|
||||
///
|
||||
/// This is [`format_timecode`] under a duration-shaped name, so call sites
|
||||
/// read as what they mean (the status bar shows "timecode / duration").
|
||||
pub fn format_duration(frames: Frame, rate: FrameRate) -> String {
|
||||
format_timecode(frames, rate)
|
||||
}
|
||||
|
||||
/// Formats a frame rate as its conventional label: `25` for whole rates,
|
||||
/// `29.97` for NTSC 30000/1001.
|
||||
///
|
||||
/// # 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");
|
||||
/// ```
|
||||
pub fn format_fps(rate: FrameRate) -> String {
|
||||
let fps = rate.num as f64 / rate.den as f64;
|
||||
if (fps - fps.round()).abs() < 1e-9 {
|
||||
format!("{:.0}", fps)
|
||||
} else {
|
||||
format!("{:.2}", fps)
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a resolution as `WIDTH×HEIGHT` (U+00D7 multiplication sign),
|
||||
/// matching the design's "1920×1080" chips.
|
||||
pub fn format_resolution(width: u32, height: u32) -> String {
|
||||
format!("{width}×{height}")
|
||||
}
|
||||
|
||||
/// The nominal integer frames-per-second used by non-drop-frame timecode:
|
||||
/// the rounded frame rate (`25` for 25/1, `30` for 30000/1001).
|
||||
fn nominal_fps(rate: FrameRate) -> u64 {
|
||||
rate.as_f64().round().max(1.0) as u64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn zero_is_zeros() {
|
||||
assert_eq!(
|
||||
format_timecode(Frame(0), FrameRate::new(25, 1)),
|
||||
"00:00:00:00"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_seconds_at_25fps() {
|
||||
let rate = FrameRate::new(25, 1);
|
||||
assert_eq!(format_timecode(Frame(25), rate), "00:00:01:00");
|
||||
assert_eq!(format_timecode(Frame(60 * 25), rate), "00:01:00:00");
|
||||
assert_eq!(format_timecode(Frame(3600 * 25), rate), "01:00:00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn design_reference_duration() {
|
||||
// The design's sequence duration chip: 00:04:18:18 @ 25 fps.
|
||||
assert_eq!(
|
||||
format_timecode(Frame(6468), FrameRate::new(25, 1)),
|
||||
"00:04:18:18"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_frame_component() {
|
||||
// Frame 23 of the 24th second: 00:00:23:23 @ 25 fps.
|
||||
let rate = FrameRate::new(25, 1);
|
||||
assert_eq!(format_timecode(Frame(23 * 25 + 23), rate), "00:00:23:23");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_frames_get_a_leading_minus() {
|
||||
let rate = FrameRate::new(25, 1);
|
||||
assert_eq!(format_timecode(Frame(-25), rate), "-00:00:01:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ntsc_uses_nominal_30fps_frames() {
|
||||
// At 30000/1001 the nominal rate is 30, so one second is frame 30.
|
||||
let rate = FrameRate::NTSC_2997;
|
||||
assert_eq!(format_timecode(Frame(30), rate), "00:00:01:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_aliases_timecode() {
|
||||
let rate = FrameRate::new(25, 1);
|
||||
assert_eq!(
|
||||
format_duration(Frame(6468), rate),
|
||||
format_timecode(Frame(6468), rate)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fps_labels() {
|
||||
assert_eq!(format_fps(FrameRate::new(25, 1)), "25");
|
||||
assert_eq!(format_fps(FrameRate::new(30, 1)), "30");
|
||||
assert_eq!(format_fps(FrameRate::NTSC_2997), "29.97");
|
||||
assert_eq!(format_fps(FrameRate::NTSC_23976), "23.98");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_labels() {
|
||||
assert_eq!(format_resolution(1920, 1080), "1920×1080");
|
||||
assert_eq!(format_resolution(1280, 720), "1280×720");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// 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 transport state machine: play/pause/step/seek semantics shared by the
|
||||
//! transport drives of both monitors.
|
||||
//!
|
||||
//! Pure logic, no gpui dependency — the engine clocks and the widgets only
|
||||
//! read the resulting state ([`TransportState::frame`],
|
||||
//! [`TransportState::is_playing`]). Keeping this free of gpui types is what
|
||||
//! makes the whole machine unit-testable with plain `#[test]`.
|
||||
|
||||
use gpui::timeline::Frame;
|
||||
|
||||
/// Whether the transport is rolling.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PlayState {
|
||||
/// Stopped; the playhead is stationary.
|
||||
Stopped,
|
||||
/// Playing; the playhead advances with the wall clock.
|
||||
Playing,
|
||||
}
|
||||
|
||||
/// The transport state of one monitor.
|
||||
///
|
||||
/// Invariants:
|
||||
/// * `frame` is always `>= Frame(0)`; it is clamped to `[0, length)` by every
|
||||
/// mutating method that takes a sequence `length`.
|
||||
/// * `in_point` / `out_point`, when both are set, satisfy
|
||||
/// `in_point <= out_point`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TransportState {
|
||||
state: PlayState,
|
||||
frame: Frame,
|
||||
in_point: Option<Frame>,
|
||||
out_point: Option<Frame>,
|
||||
}
|
||||
|
||||
impl Default for TransportState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportState {
|
||||
/// A stopped transport at frame zero with no loop range.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: PlayState::Stopped,
|
||||
frame: Frame::ZERO,
|
||||
in_point: None,
|
||||
out_point: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The current playhead position.
|
||||
pub fn frame(&self) -> Frame {
|
||||
self.frame
|
||||
}
|
||||
|
||||
/// The current play state.
|
||||
pub fn state(&self) -> PlayState {
|
||||
self.state
|
||||
}
|
||||
|
||||
/// Whether playback is rolling.
|
||||
pub fn is_playing(&self) -> bool {
|
||||
self.state == PlayState::Playing
|
||||
}
|
||||
|
||||
/// The loop range, if both points are set.
|
||||
pub fn loop_range(&self) -> Option<(Frame, Frame)> {
|
||||
match (self.in_point, self.out_point) {
|
||||
(Some(in_point), Some(out_point)) => Some((in_point, out_point)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts playback. Idempotent: playing a playing transport is a no-op.
|
||||
pub fn play(&mut self) {
|
||||
self.state = PlayState::Playing;
|
||||
}
|
||||
|
||||
/// Stops playback, leaving the playhead where it is. Idempotent.
|
||||
pub fn pause(&mut self) {
|
||||
self.state = PlayState::Stopped;
|
||||
}
|
||||
|
||||
/// Toggles between playing and stopped.
|
||||
pub fn toggle(&mut self) {
|
||||
self.state = match self.state {
|
||||
PlayState::Stopped => PlayState::Playing,
|
||||
PlayState::Playing => PlayState::Stopped,
|
||||
};
|
||||
}
|
||||
|
||||
/// Seeks to `frame`, clamped to `[0, length)`. Keeps the play state.
|
||||
pub fn seek(&mut self, frame: Frame, length: Frame) {
|
||||
self.frame = clamp_frame(frame, length);
|
||||
}
|
||||
|
||||
/// Steps the playhead by `delta` frames, clamped to `[0, length)`.
|
||||
/// Keeps the play state (stepping while playing is jogging).
|
||||
pub fn step(&mut self, delta: i64, length: Frame) {
|
||||
self.seek(self.frame + Frame(delta), length);
|
||||
}
|
||||
|
||||
/// Sets the loop-in point at the current playhead.
|
||||
pub fn set_in_point(&mut self) {
|
||||
self.in_point = Some(self.frame);
|
||||
}
|
||||
|
||||
/// Sets the loop-out point at the current playhead.
|
||||
pub fn set_out_point(&mut self) {
|
||||
self.out_point = Some(self.frame);
|
||||
// Keep the range well-formed: an out point before the in point is
|
||||
// collapsed to the in point.
|
||||
if let (Some(in_point), Some(out_point)) = (self.in_point, self.out_point) {
|
||||
if out_point < in_point {
|
||||
self.out_point = Some(in_point);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears the loop range.
|
||||
pub fn clear_range(&mut self) {
|
||||
self.in_point = None;
|
||||
self.out_point = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamps `frame` into `[0, length)`. A zero-length sequence clamps to zero.
|
||||
fn clamp_frame(frame: Frame, length: Frame) -> Frame {
|
||||
let length = length.0.max(0);
|
||||
Frame(if length == 0 {
|
||||
0
|
||||
} else {
|
||||
frame.0.clamp(0, length - 1)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn initial_state_is_stopped_at_zero() {
|
||||
let transport = TransportState::new();
|
||||
assert_eq!(transport.state(), PlayState::Stopped);
|
||||
assert!(!transport.is_playing());
|
||||
assert_eq!(transport.frame(), Frame(0));
|
||||
assert_eq!(transport.loop_range(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn play_then_pause_keeps_the_frame() {
|
||||
let mut transport = TransportState::new();
|
||||
transport.play();
|
||||
assert!(transport.is_playing());
|
||||
transport.seek(Frame(42), Frame(100));
|
||||
transport.pause();
|
||||
assert_eq!(transport.state(), PlayState::Stopped);
|
||||
assert_eq!(transport.frame(), Frame(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn play_is_idempotent() {
|
||||
let mut transport = TransportState::new();
|
||||
transport.play();
|
||||
transport.play();
|
||||
assert!(transport.is_playing());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_switches_state() {
|
||||
let mut transport = TransportState::new();
|
||||
transport.toggle();
|
||||
assert!(transport.is_playing());
|
||||
transport.toggle();
|
||||
assert_eq!(transport.state(), PlayState::Stopped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_clamps_at_the_sequence_edges() {
|
||||
let mut transport = TransportState::new();
|
||||
transport.step(-5, Frame(100));
|
||||
assert_eq!(transport.frame(), Frame(0));
|
||||
|
||||
transport.seek(Frame(99), Frame(100));
|
||||
transport.step(5, Frame(100));
|
||||
assert_eq!(transport.frame(), Frame(99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_while_playing_is_jogging() {
|
||||
let mut transport = TransportState::new();
|
||||
transport.play();
|
||||
transport.step(1, Frame(100));
|
||||
assert!(transport.is_playing());
|
||||
assert_eq!(transport.frame(), Frame(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seek_clamps_to_length() {
|
||||
let mut transport = TransportState::new();
|
||||
transport.seek(Frame(150), Frame(100));
|
||||
assert_eq!(transport.frame(), Frame(99));
|
||||
transport.seek(Frame(-10), Frame(100));
|
||||
assert_eq!(transport.frame(), Frame(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_length_sequence_clamps_to_zero() {
|
||||
let mut transport = TransportState::new();
|
||||
transport.seek(Frame(5), Frame(0));
|
||||
assert_eq!(transport.frame(), Frame(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loop_points_set_and_clear() {
|
||||
let mut transport = TransportState::new();
|
||||
assert_eq!(transport.loop_range(), None);
|
||||
|
||||
transport.seek(Frame(10), Frame(100));
|
||||
transport.set_in_point();
|
||||
transport.seek(Frame(20), Frame(100));
|
||||
transport.set_out_point();
|
||||
assert_eq!(transport.loop_range(), Some((Frame(10), Frame(20))));
|
||||
|
||||
transport.clear_range();
|
||||
assert_eq!(transport.loop_range(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_point_before_in_point_collapses_to_in_point() {
|
||||
let mut transport = TransportState::new();
|
||||
transport.seek(Frame(30), Frame(100));
|
||||
transport.set_in_point();
|
||||
transport.seek(Frame(5), Frame(100));
|
||||
transport.set_out_point();
|
||||
assert_eq!(transport.loop_range(), Some((Frame(30), Frame(30))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// 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 history panel (历史记录): a placeholder list of undo entries, sharing
|
||||
//! the inspector's dock group per the design.
|
||||
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::dock::{DockPanel, PanelEvent};
|
||||
use gpui::{div, prelude::*, AnyElement, App, Context, EventEmitter, Render, SharedString, Window};
|
||||
|
||||
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)>,
|
||||
}
|
||||
|
||||
impl HistoryPanel {
|
||||
/// Creates the panel with demo history entries.
|
||||
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"),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for HistoryPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
let mut list = div()
|
||||
.id("history-list")
|
||||
.flex_1()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.py_1()
|
||||
.overflow_y_scroll();
|
||||
for (label, timestamp) in &self.entries {
|
||||
list = list.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.px_3()
|
||||
.py_1()
|
||||
.text_color(colors.text)
|
||||
.child(div().child(*label))
|
||||
.child(div().text_color(colors.disabled).child(*timestamp)),
|
||||
);
|
||||
}
|
||||
div().size_full().flex().flex_col().child(list)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for HistoryPanel {}
|
||||
|
||||
impl DockPanel for HistoryPanel {
|
||||
fn panel_id(&self) -> gpui::dock::PanelId {
|
||||
HISTORY
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> SharedString {
|
||||
crate::i18n::tr("panel.history").into()
|
||||
}
|
||||
|
||||
fn tab_content(&self, _cx: &App) -> AnyElement {
|
||||
div()
|
||||
.child(crate::i18n::tr("panel.history"))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// 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 inspector panel (检查器·效果栈): the `EffectStackView` over the
|
||||
//! engine's node chain, shown as linear cards (媒体 → 变换 → OCIO LUT →
|
||||
//! 输出) with add / remove / reorder — no engine, the mock applies the edits
|
||||
//! to its own model.
|
||||
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::dock::{DockPanel, PanelEvent};
|
||||
use gpui::effect_stack::{EffectStackEvent, EffectStackView};
|
||||
use gpui::{
|
||||
div, prelude::*, AnyElement, App, Context, Entity, EventEmitter, Render, SharedString, Window,
|
||||
};
|
||||
|
||||
use crate::oakui::MockEngine;
|
||||
use crate::panels::ids::INSPECTOR;
|
||||
|
||||
/// The inspector / effect stack panel.
|
||||
pub struct InspectorPanel {
|
||||
stack: Entity<EffectStackView<MockEngine>>,
|
||||
engine: Entity<MockEngine>,
|
||||
}
|
||||
|
||||
impl InspectorPanel {
|
||||
/// Builds the stack over `engine`'s effect model.
|
||||
pub fn new(engine: Entity<MockEngine>, _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())
|
||||
});
|
||||
// The "edits are requests" loop: forward each request to the engine,
|
||||
// which applies it to its model and notifies.
|
||||
cx.subscribe(&stack, |this, _stack, event: &EffectStackEvent, cx| {
|
||||
this.engine
|
||||
.update(cx, |engine, cx| engine.apply_effect_event(event, cx));
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self { stack, engine }
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for InspectorPanel {
|
||||
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 DockPanel for InspectorPanel {
|
||||
fn panel_id(&self) -> gpui::dock::PanelId {
|
||||
INSPECTOR
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> SharedString {
|
||||
crate::i18n::tr("panel.inspector").into()
|
||||
}
|
||||
|
||||
fn tab_content(&self, _cx: &App) -> AnyElement {
|
||||
div()
|
||||
.child(crate::i18n::tr("panel.inspector"))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
/// Placeholder parameter view rendered inside expanded effect cards.
|
||||
/// A real app builds the effect's controls here and calls
|
||||
/// [`EffectStackView::notify_parameter_changed`] after edits.
|
||||
struct ParamPlaceholder;
|
||||
|
||||
impl Render for ParamPlaceholder {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
div()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.text_color(colors.disabled)
|
||||
.child(crate::i18n::tr("inspector.params"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// 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 dockable panels of the main window.
|
||||
//!
|
||||
//! Each panel is a gpui view implementing [`DockPanel`](gpui::dock::DockPanel)
|
||||
//! so it can live inside the [`DockArea`](gpui::dock::DockArea) shell. Panels
|
||||
//! own their widgets (created in their constructors), hold the
|
||||
//! [`MockEngine`](crate::oakui::MockEngine) entity so requests can be routed
|
||||
//! to "the engine", and never mutate engine state directly — every edit is a
|
||||
//! widget request event that the panel forwards through the gateway.
|
||||
|
||||
pub mod history;
|
||||
pub mod inspector;
|
||||
pub mod node_editor;
|
||||
pub mod program_viewer;
|
||||
pub mod project_explorer;
|
||||
pub mod source_viewer;
|
||||
pub mod status_bar;
|
||||
pub mod timeline;
|
||||
|
||||
pub use gpui::dock::PanelId;
|
||||
|
||||
/// Stable panel ids, unique within the dock area.
|
||||
pub mod ids {
|
||||
use super::PanelId;
|
||||
/// The material bin (项目).
|
||||
pub const PROJECT: PanelId = PanelId::new(1);
|
||||
/// The source viewer (素材查看器).
|
||||
pub const SOURCE_VIEWER: PanelId = PanelId::new(2);
|
||||
/// The program viewer (序列查看器).
|
||||
pub const PROGRAM_VIEWER: PanelId = PanelId::new(3);
|
||||
/// The node editor placeholder (节点编辑器).
|
||||
pub const NODE_EDITOR: PanelId = PanelId::new(4);
|
||||
/// The inspector / effect stack (检查器·效果栈).
|
||||
pub const INSPECTOR: PanelId = PanelId::new(5);
|
||||
/// The undo history (历史记录).
|
||||
pub const HISTORY: PanelId = PanelId::new(6);
|
||||
/// The timeline (时间线).
|
||||
pub const TIMELINE: PanelId = PanelId::new(7);
|
||||
}
|
||||
|
||||
/// A small info chip used in viewer headers and the status bar: muted
|
||||
/// background, thin border, small text. `colors` comes from the caller's
|
||||
/// `cx.default_colors()` so the chip follows the active theme.
|
||||
pub(crate) fn chip(colors: &gpui::colors::Colors, label: impl gpui::IntoElement) -> gpui::Div {
|
||||
use gpui::prelude::*;
|
||||
gpui::div()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded_sm()
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.container)
|
||||
.text_color(colors.text)
|
||||
.child(label)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// 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 node editor panel (节点编辑器): a placeholder tab sharing the program
|
||||
//! viewer's dock group.
|
||||
//!
|
||||
//! The design puts the node editor in the center, switchable with the program
|
||||
//! viewer. The real `gpui::node_graph` widget exists in the gpui submodule
|
||||
//! but is not wired up yet — this panel is a placeholder surface with the
|
||||
//! zoom controls the design specifies (+ / − / fit).
|
||||
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::dock::{DockPanel, PanelEvent};
|
||||
use gpui::{
|
||||
div, prelude::*, AnyElement, App, ClickEvent, Context, EventEmitter, Render, SharedString,
|
||||
Window,
|
||||
};
|
||||
|
||||
use crate::panels::ids::NODE_EDITOR;
|
||||
|
||||
/// The node editor placeholder panel.
|
||||
pub struct NodeEditorPanel;
|
||||
|
||||
impl NodeEditorPanel {
|
||||
/// Creates the placeholder.
|
||||
pub fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for NodeEditorPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.border_b_1()
|
||||
.border_color(colors.border)
|
||||
.child(zoom_button(
|
||||
cx,
|
||||
"node-zoom-in",
|
||||
"+",
|
||||
crate::i18n::tr("node.zoom_in"),
|
||||
))
|
||||
.child(zoom_button(
|
||||
cx,
|
||||
"node-zoom-out",
|
||||
"−",
|
||||
crate::i18n::tr("node.zoom_out"),
|
||||
))
|
||||
.child(zoom_button(
|
||||
cx,
|
||||
"node-zoom-fit",
|
||||
crate::i18n::tr("node.fit"),
|
||||
crate::i18n::tr("node.fit_window"),
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_color(colors.disabled)
|
||||
.child(crate::i18n::tr("node.placeholder")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A small toolbar button (the design's `+`/`−`/`适配` controls).
|
||||
fn zoom_button(
|
||||
cx: &mut Context<NodeEditorPanel>,
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
title: &'static str,
|
||||
) -> impl gpui::IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
let container = colors.container;
|
||||
div()
|
||||
.id(id)
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.text_color(colors.text)
|
||||
.cursor_pointer()
|
||||
.hover(move |style| style.bg(container))
|
||||
.on_click(
|
||||
cx.listener(move |_this, _event: &ClickEvent, _window, _cx| {
|
||||
println!("[node editor] {title} (placeholder)");
|
||||
}),
|
||||
)
|
||||
.child(label)
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for NodeEditorPanel {}
|
||||
|
||||
impl DockPanel for NodeEditorPanel {
|
||||
fn panel_id(&self) -> gpui::dock::PanelId {
|
||||
NODE_EDITOR
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> SharedString {
|
||||
crate::i18n::tr("panel.node_editor").into()
|
||||
}
|
||||
|
||||
fn tab_content(&self, _cx: &App) -> AnyElement {
|
||||
div()
|
||||
.child(crate::i18n::tr("panel.node_editor"))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// 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 program viewer panel (序列查看器): the `ViewerWidget` over the
|
||||
//! program monitor's clock, with a 26px audio level strip attached to its
|
||||
//! right edge (the design's WP6 layout).
|
||||
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::dock::{DockPanel, PanelEvent};
|
||||
use gpui::{
|
||||
div, prelude::*, px, AnyElement, App, Context, Entity, EventEmitter, Render, SharedString,
|
||||
Window,
|
||||
};
|
||||
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::panels::chip;
|
||||
use crate::panels::ids::PROGRAM_VIEWER;
|
||||
|
||||
/// Width of the audio level strip, per the design (26px).
|
||||
const METER_WIDTH: f32 = 26.0;
|
||||
|
||||
/// The program viewer panel.
|
||||
pub struct ProgramViewerPanel {
|
||||
viewer: Entity<ViewerWidget<MockClock>>,
|
||||
meter: Entity<AudioLevelMeter<MockEngine>>,
|
||||
engine: Entity<MockEngine>,
|
||||
}
|
||||
|
||||
impl ProgramViewerPanel {
|
||||
/// 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>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let viewer = cx.new(|cx| ViewerWidget::new(3, clock, window, cx));
|
||||
// Route every transport request to the engine's program monitor.
|
||||
cx.subscribe(&viewer, |this, _viewer, event: &ViewerEvent, cx| {
|
||||
let monitor = Monitor::Program;
|
||||
this.engine.update(cx, |engine, cx| match event {
|
||||
ViewerEvent::PlayRequested { .. } => engine.play(monitor, cx),
|
||||
ViewerEvent::PauseRequested { .. } => engine.pause(monitor, cx),
|
||||
ViewerEvent::StepRequested { delta, .. } => engine.step(monitor, *delta, cx),
|
||||
other => println!("[program viewer] request: {other:?}"),
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
viewer,
|
||||
meter,
|
||||
engine,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ProgramViewerPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
let format = self
|
||||
.engine
|
||||
.read(cx)
|
||||
.current_sequence()
|
||||
.map(|sequence| sequence.format)
|
||||
.unwrap_or(crate::oakui::VideoFormat::hd_1080p25());
|
||||
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.border_b_1()
|
||||
.border_color(colors.border)
|
||||
.child(chip(&colors, crate::i18n::tr("viewer.program")))
|
||||
.child(chip(
|
||||
&colors,
|
||||
format_resolution(format.width, format.height),
|
||||
))
|
||||
.child(chip(&colors, format_fps(format.rate))),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.child(div().flex_1().child(self.viewer.clone()))
|
||||
.child(
|
||||
div()
|
||||
.w(px(METER_WIDTH))
|
||||
.border_l_1()
|
||||
.border_color(colors.border)
|
||||
.child(self.meter.clone()),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for ProgramViewerPanel {}
|
||||
|
||||
impl DockPanel for ProgramViewerPanel {
|
||||
fn panel_id(&self) -> gpui::dock::PanelId {
|
||||
PROGRAM_VIEWER
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> SharedString {
|
||||
crate::i18n::tr("panel.program_viewer").into()
|
||||
}
|
||||
|
||||
fn tab_content(&self, _cx: &App) -> AnyElement {
|
||||
div()
|
||||
.child(crate::i18n::tr("panel.program_viewer"))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// 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 material bin panel (项目): the `ProjectExplorer` widget over the
|
||||
//! engine's project data.
|
||||
|
||||
use gpui::dock::{DockPanel, PanelEvent};
|
||||
use gpui::{
|
||||
div, prelude::*, AnyElement, App, Context, Entity, EventEmitter, Render, SharedString, Window,
|
||||
};
|
||||
use gpui_widgets::project_explorer::{ProjectExplorer, ProjectExplorerEvent};
|
||||
|
||||
use crate::oakui::MockEngine;
|
||||
use crate::panels::ids::PROJECT;
|
||||
|
||||
/// The material bin panel.
|
||||
pub struct ProjectExplorerPanel {
|
||||
explorer: Entity<ProjectExplorer<MockEngine>>,
|
||||
engine: Entity<MockEngine>,
|
||||
}
|
||||
|
||||
impl ProjectExplorerPanel {
|
||||
/// Builds the explorer over `engine`'s project data.
|
||||
pub fn new(engine: Entity<MockEngine>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let explorer = cx.new(|cx| ProjectExplorer::new(1, engine.clone(), window, cx));
|
||||
cx.subscribe(
|
||||
&explorer,
|
||||
|this, _explorer, event: &ProjectExplorerEvent, cx| match event {
|
||||
ProjectExplorerEvent::OpenRequested { id, .. } => {
|
||||
// Demo "open": select the item in the engine's model.
|
||||
this.engine
|
||||
.update(cx, |engine, cx| engine.select_item(*id, cx));
|
||||
}
|
||||
other => println!("[project explorer] request: {other:?}"),
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
|
||||
Self { explorer, engine }
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ProjectExplorerPanel {
|
||||
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 DockPanel for ProjectExplorerPanel {
|
||||
fn panel_id(&self) -> gpui::dock::PanelId {
|
||||
PROJECT
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> SharedString {
|
||||
crate::i18n::tr("panel.project").into()
|
||||
}
|
||||
|
||||
fn tab_content(&self, _cx: &App) -> AnyElement {
|
||||
div()
|
||||
.child(crate::i18n::tr("panel.project"))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// 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 source viewer panel (素材查看器): the `ViewerWidget` over the source
|
||||
//! monitor's clock, with its own transport and format chips.
|
||||
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::dock::{DockPanel, PanelEvent};
|
||||
use gpui::{
|
||||
div, prelude::*, AnyElement, App, Context, Entity, EventEmitter, Render, SharedString, Window,
|
||||
};
|
||||
use gpui_widgets::viewer::{ViewerEvent, ViewerWidget};
|
||||
|
||||
use crate::oakui::timecode::{format_fps, format_resolution};
|
||||
use crate::oakui::{EngineGateway, MockClock, MockEngine, 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>,
|
||||
}
|
||||
|
||||
impl SourceViewerPanel {
|
||||
/// Builds a viewer over `clock` (the source monitor's clock).
|
||||
pub fn new(
|
||||
engine: Entity<MockEngine>,
|
||||
clock: Entity<MockClock>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let viewer = cx.new(|cx| ViewerWidget::new(2, clock, window, cx));
|
||||
// Route every transport request to the engine's source monitor.
|
||||
cx.subscribe(&viewer, |this, _viewer, event: &ViewerEvent, cx| {
|
||||
let monitor = Monitor::Source;
|
||||
this.engine.update(cx, |engine, cx| match event {
|
||||
ViewerEvent::PlayRequested { .. } => engine.play(monitor, cx),
|
||||
ViewerEvent::PauseRequested { .. } => engine.pause(monitor, cx),
|
||||
ViewerEvent::StepRequested { delta, .. } => engine.step(monitor, *delta, cx),
|
||||
other => println!("[source viewer] request: {other:?}"),
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self { viewer, engine }
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SourceViewerPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
let format = self
|
||||
.engine
|
||||
.read(cx)
|
||||
.current_sequence()
|
||||
.map(|sequence| sequence.format)
|
||||
.unwrap_or(crate::oakui::VideoFormat::hd_1080p25());
|
||||
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.border_b_1()
|
||||
.border_color(colors.border)
|
||||
.child(chip(&colors, crate::i18n::tr("viewer.source")))
|
||||
.child(chip(
|
||||
&colors,
|
||||
format_resolution(format.width, format.height),
|
||||
))
|
||||
.child(chip(&colors, format_fps(format.rate))),
|
||||
)
|
||||
.child(div().flex_1().child(self.viewer.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for SourceViewerPanel {}
|
||||
|
||||
impl DockPanel for SourceViewerPanel {
|
||||
fn panel_id(&self) -> gpui::dock::PanelId {
|
||||
SOURCE_VIEWER
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> SharedString {
|
||||
crate::i18n::tr("panel.source_viewer").into()
|
||||
}
|
||||
|
||||
fn tab_content(&self, _cx: &App) -> AnyElement {
|
||||
div()
|
||||
.child(crate::i18n::tr("panel.source_viewer"))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// 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 global status bar (状态栏): ready state, cache, proxy and autosave
|
||||
//! info on the left; current timecode / duration, frame rate and resolution
|
||||
//! on the right.
|
||||
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::timeline::Frame;
|
||||
use gpui::{div, prelude::*, Context, Entity, Render, Window};
|
||||
|
||||
use crate::oakui::timecode::{format_duration, format_fps, format_resolution, format_timecode};
|
||||
use crate::oakui::{EngineGateway, MockClock, MockEngine};
|
||||
|
||||
/// The global status bar.
|
||||
pub struct StatusBar {
|
||||
engine: Entity<MockEngine>,
|
||||
program_clock: Entity<MockClock>,
|
||||
}
|
||||
|
||||
impl StatusBar {
|
||||
/// Builds the status bar over the engine and the program clock.
|
||||
pub fn new(
|
||||
engine: Entity<MockEngine>,
|
||||
program_clock: Entity<MockClock>,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
Self {
|
||||
engine,
|
||||
program_clock,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for StatusBar {
|
||||
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 sequence = engine.current_sequence();
|
||||
let format = sequence
|
||||
.map(|s| s.format)
|
||||
.unwrap_or(crate::oakui::VideoFormat::hd_1080p25());
|
||||
let length = sequence.map(|s| s.length).unwrap_or(Frame(0));
|
||||
let project = engine
|
||||
.project()
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_else(|| crate::i18n::tr("status.untitled").to_string());
|
||||
|
||||
let segment = |colors: &gpui::colors::Colors, text: String| {
|
||||
div().px_2().py_1().text_color(colors.text).child(text)
|
||||
};
|
||||
|
||||
div()
|
||||
.h_6()
|
||||
.flex()
|
||||
.items_center()
|
||||
.border_t_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.container)
|
||||
.text_xs()
|
||||
.child(segment(&colors, crate::i18n::tr("status.ready").into()))
|
||||
.child(segment(
|
||||
&colors,
|
||||
crate::i18n::tr("status.cache").into(),
|
||||
))
|
||||
.child(segment(
|
||||
&colors,
|
||||
crate::i18n::tr("status.proxy").into(),
|
||||
))
|
||||
.child(segment(
|
||||
&colors,
|
||||
crate::i18n::tr("status.autosave").into(),
|
||||
))
|
||||
.child(div().flex_1())
|
||||
.child(segment(
|
||||
&colors,
|
||||
format!(
|
||||
"{timecode}/{duration}",
|
||||
timecode = format_timecode(frame, format.rate),
|
||||
duration = format_duration(length, format.rate),
|
||||
),
|
||||
))
|
||||
.child(segment(&colors, format_fps(format.rate)))
|
||||
.child(segment(
|
||||
&colors,
|
||||
format_resolution(format.width, format.height),
|
||||
))
|
||||
.child(div().px_2().text_color(colors.disabled).child(project))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
// 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 timeline panel (时间线): the design's 31px toolbar (tools, snap
|
||||
//! toggle) above the full-width [`TimelineView`](gpui::timeline::TimelineView)
|
||||
//! over the engine's sequence model.
|
||||
//!
|
||||
//! # Layout (fixed 2026-08)
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌─────────────────────────────────────────┬─────────────┐
|
||||
//! │ toolbar row (fixed 31px): tools + − ⏵ │ │
|
||||
//! ├─────────────────────────────────────────┤ right-side │
|
||||
//! │ timeline (ruler takes remaining width, │ controls │
|
||||
//! │ clip area below) │ (fixed 140px│
|
||||
//! │ │ zoom / │
|
||||
//! │ │ track hgt) │
|
||||
//! └─────────────────────────────────────────┴─────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! The zoom and track-height sliders used to sit at the right end of the
|
||||
//! toolbar, where they overflowed into the ruler's timecode labels (the
|
||||
//! toolbar is exactly 31px but the sliders' value rows are taller, and at
|
||||
//! narrow widths the sliders squeezed into the ruler's right side). They
|
||||
//! now live in a fixed-width trailing slot beside the timeline body, and the
|
||||
//! timeline wrapper is `min_w_0` so the ruler always keeps the remaining
|
||||
//! space — no overlap at 1600×900 or down to ~1100px wide. On hidpi (2x)
|
||||
//! displays the render compensates for a gpui view-positioning quirk with a
|
||||
//! top padding on the timeline canvas (see the note in [`Render::render`]).
|
||||
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::dock::{DockPanel, PanelEvent};
|
||||
use gpui::timeline::TimelineView;
|
||||
use gpui::{div, 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::value::ValueKind;
|
||||
|
||||
use crate::i18n;
|
||||
use crate::oakui::MockEngine;
|
||||
use crate::panels::ids::TIMELINE;
|
||||
|
||||
/// Toolbar height, per the design (31px).
|
||||
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 timeline panel.
|
||||
pub struct TimelinePanel {
|
||||
timeline: Entity<TimelineView<MockEngine>>,
|
||||
engine: Entity<MockEngine>,
|
||||
zoom: Entity<Slider>,
|
||||
height: Entity<Slider>,
|
||||
snap: Entity<CheckBox>,
|
||||
/// The currently selected tool (visual only).
|
||||
selected_tool: usize,
|
||||
}
|
||||
|
||||
impl TimelinePanel {
|
||||
/// 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>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let zoom = cx.new(|cx| {
|
||||
Slider::new(
|
||||
10,
|
||||
SliderModel::new(ValueKind::Float, 0.5, 8.0, 0.1, 2.0),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let height = cx.new(|cx| {
|
||||
Slider::new(
|
||||
11,
|
||||
SliderModel::new(ValueKind::Float, 24.0, 160.0, 8.0, 64.0),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let snap =
|
||||
cx.new(|cx| CheckBox::new(12, CheckState::Checked, window, cx));
|
||||
|
||||
// Zoom slider → timeline zoom (pixels per frame).
|
||||
cx.subscribe(&zoom, |this, _zoom, event: &SliderEvent, cx| {
|
||||
if let SliderEvent::ValueChanged { value, .. } = event {
|
||||
let zoom = value.to_f64() as f32;
|
||||
this.timeline.update(cx, |timeline, cx| {
|
||||
timeline.state.set_zoom(zoom, px(0.0));
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Track-height slider → engine model (persisted per sequence).
|
||||
cx.subscribe(&height, |this, _height, event: &SliderEvent, cx| {
|
||||
if let SliderEvent::ValueChanged { value, .. } = event {
|
||||
let height = value.to_f64() as f32;
|
||||
this.engine
|
||||
.update(cx, |engine, cx| engine.set_track_height(px(height), cx));
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Snap toggle → timeline view state.
|
||||
cx.subscribe(&snap, |this, _snap, event: &CheckBoxEvent, cx| {
|
||||
let CheckBoxEvent::Toggled { state, .. } = event;
|
||||
let enabled = *state == CheckState::Checked;
|
||||
this.timeline.update(cx, |timeline, cx| {
|
||||
timeline.state.snap_enabled = enabled;
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
timeline,
|
||||
engine,
|
||||
zoom,
|
||||
height,
|
||||
snap,
|
||||
selected_tool: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for TimelinePanel {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
|
||||
// The gpui view-positioning code prepaints a view's root at the panel
|
||||
// content origin rather than at its flex wrapper's position, which is
|
||||
// only noticeable on hidpi (2x) displays where the fixed 31px toolbar
|
||||
// row and the timeline view's own 32px ruler row would otherwise
|
||||
// overlap. The canvas wrapper carries a compensating top padding on
|
||||
// hidpi so the ruler lands just below the toolbar; at 1x the layout
|
||||
// is already correct and no padding is applied.
|
||||
let view_offset = if window.scale_factor() > 1.5 {
|
||||
TOOLBAR_HEIGHT + 2.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// --- toolbar row (fixed 31px, above the ruler) --------------------
|
||||
let mut toolbar = div()
|
||||
.debug_selector(|| "timeline-toolbar".into())
|
||||
.h(px(TOOLBAR_HEIGHT))
|
||||
.flex_shrink_0()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.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);
|
||||
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| {
|
||||
div()
|
||||
.id(id)
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded_sm()
|
||||
.cursor_pointer()
|
||||
.text_color(text)
|
||||
.hover(move |style| style.bg(container))
|
||||
.child(label)
|
||||
};
|
||||
|
||||
// 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.
|
||||
let snap_row = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.text_color(colors.text)
|
||||
.child(div().child(i18n::tr("timeline.snap")))
|
||||
.child(self.snap.clone());
|
||||
|
||||
let toolbar = toolbar
|
||||
.child(tool_btn("toolbar-zoom-in", "+"))
|
||||
.child(tool_btn("toolbar-zoom-out", "−"))
|
||||
.child(
|
||||
div()
|
||||
.w_1()
|
||||
.h_full()
|
||||
.border_l_1()
|
||||
.border_color(colors.border),
|
||||
)
|
||||
.child(snap_row);
|
||||
|
||||
// --- trailing controls slot (fixed width, right of the body) -------
|
||||
let right_controls = div()
|
||||
.debug_selector(|| "timeline-right-controls".into())
|
||||
.w(px(RIGHT_CONTROLS_WIDTH))
|
||||
.flex_shrink_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.justify_center()
|
||||
.gap_1()
|
||||
.px_2()
|
||||
.border_l_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.container)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_color(colors.disabled)
|
||||
.child(i18n::tr("timeline.zoom"))
|
||||
.child(self.zoom.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_color(colors.disabled)
|
||||
.child(i18n::tr("timeline.track_height"))
|
||||
.child(self.height.clone()),
|
||||
);
|
||||
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.overflow_hidden()
|
||||
.child(toolbar)
|
||||
.child(
|
||||
div()
|
||||
.debug_selector(|| "timeline-body".into())
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.flex()
|
||||
.flex_row()
|
||||
.child(
|
||||
div()
|
||||
.debug_selector(|| "timeline-canvas".into())
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.pt(px(view_offset))
|
||||
.child(self.timeline.clone()),
|
||||
)
|
||||
.child(right_controls),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for TimelinePanel {}
|
||||
|
||||
impl DockPanel for TimelinePanel {
|
||||
fn panel_id(&self) -> gpui::dock::PanelId {
|
||||
TIMELINE
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> SharedString {
|
||||
i18n::tr("panel.timeline").into()
|
||||
}
|
||||
|
||||
fn tab_content(&self, _cx: &App) -> AnyElement {
|
||||
div().child(i18n::tr("panel.timeline")).into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::{TestAppContext, VisualTestContext, px, size};
|
||||
|
||||
/// Builds a `TimelinePanel` in a window of the given logical size and
|
||||
/// returns a `VisualTestContext` for bounds assertions.
|
||||
fn panel_window(
|
||||
cx: &mut TestAppContext,
|
||||
width: f32,
|
||||
height: f32,
|
||||
) -> (&'static mut VisualTestContext, Entity<TimelinePanel>) {
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(width), px(height)), |window, cx| {
|
||||
let engine = cx.new(|cx| crate::oakui::MockEngine::demo(cx));
|
||||
let timeline =
|
||||
cx.new(|cx| TimelineView::new(engine.clone(), window, cx).zoom(2.0));
|
||||
TimelinePanel::new(engine, timeline, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let panel = window.root(cx).expect("timeline panel root");
|
||||
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
|
||||
(cx, panel)
|
||||
}
|
||||
|
||||
/// The toolbar row must sit entirely above the timeline body, the right
|
||||
/// controls must sit to the right of the timeline canvas (never
|
||||
/// overlapping it), and the controls slot must keep its fixed width — at
|
||||
/// the default 1600×900 and down to ~1100px wide.
|
||||
#[gpui::test]
|
||||
async fn toolbar_ruler_and_right_controls_never_overlap(cx: &mut TestAppContext) {
|
||||
for width in [1600.0, 1280.0, 1100.0] {
|
||||
let (cx, _panel) = panel_window(cx, width, 900.0);
|
||||
|
||||
let toolbar = cx
|
||||
.debug_bounds("timeline-toolbar")
|
||||
.expect("toolbar row rendered");
|
||||
let body = cx
|
||||
.debug_bounds("timeline-body")
|
||||
.expect("timeline body row rendered");
|
||||
let canvas = cx
|
||||
.debug_bounds("timeline-canvas")
|
||||
.expect("timeline canvas rendered");
|
||||
let right = cx
|
||||
.debug_bounds("timeline-right-controls")
|
||||
.expect("right controls slot rendered");
|
||||
|
||||
// The toolbar is exactly 31px tall and ends where the body starts.
|
||||
assert!(
|
||||
(f32::from(toolbar.size.height) - TOOLBAR_HEIGHT).abs() < 0.5,
|
||||
"toolbar height {width}: {} != {TOOLBAR_HEIGHT}",
|
||||
toolbar.size.height
|
||||
);
|
||||
assert!(
|
||||
toolbar.bottom() <= body.top(),
|
||||
"toolbar overlaps the body at width {width}"
|
||||
);
|
||||
|
||||
// The controls slot is fixed-width and never overlaps the canvas.
|
||||
assert!(
|
||||
(f32::from(right.size.width) - RIGHT_CONTROLS_WIDTH).abs() < 0.5,
|
||||
"right slot width {width}: {} != {RIGHT_CONTROLS_WIDTH}",
|
||||
right.size.width
|
||||
);
|
||||
assert!(
|
||||
canvas.right() <= right.left(),
|
||||
"right controls overlap the timeline canvas at width {width}"
|
||||
);
|
||||
|
||||
// The timeline (ruler) keeps the remaining width: canvas right
|
||||
// edge equals the slot's left edge exactly.
|
||||
assert!(
|
||||
(f32::from(canvas.right()) - f32::from(right.left())).abs() < 0.5,
|
||||
"canvas and controls slot are not flush at width {width}"
|
||||
);
|
||||
|
||||
// The right controls are inside the body's vertical bounds.
|
||||
assert!(
|
||||
right.top() >= body.top() && right.bottom() <= body.bottom(),
|
||||
"right controls escape the body at width {width}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resizing a window keeps the same invariants (the timeline body shrinks
|
||||
/// while the toolbar and the right slot stay fixed).
|
||||
#[gpui::test]
|
||||
async fn resizing_keeps_toolbar_and_right_slot_fixed(cx: &mut TestAppContext) {
|
||||
let (cx, _panel) = panel_window(cx, 1600.0, 900.0);
|
||||
|
||||
let before = cx
|
||||
.debug_bounds("timeline-right-controls")
|
||||
.expect("right controls rendered");
|
||||
assert!((f32::from(before.size.width) - RIGHT_CONTROLS_WIDTH).abs() < 0.5);
|
||||
|
||||
cx.simulate_resize(size(px(1100.0), px(900.0)));
|
||||
cx.run_until_parked();
|
||||
|
||||
let after = cx
|
||||
.debug_bounds("timeline-right-controls")
|
||||
.expect("right controls rendered after resize");
|
||||
let canvas = cx
|
||||
.debug_bounds("timeline-canvas")
|
||||
.expect("timeline canvas after resize");
|
||||
assert!((f32::from(after.size.width) - RIGHT_CONTROLS_WIDTH).abs() < 0.5);
|
||||
assert!(canvas.right() <= after.left());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user