feat(app): P5 — async full-res render, full preferences, shortcut map
- viewers show the 480px proxy immediately and a background thread fills the sequence-resolution frame (per-monitor in-flight job, generation-based staleness, playback skips full-res) - preferences dialog complete: cache dir (now consumed by default_disk_cache_path), proxy policy/divider, snapshot interval (write-through era autosave), default transition length, audio in/out devices (new facade device-enumeration exports; audio init from config — playback was never creating the audio instance), language/theme/renderer backend, all persisted via config - shortcut map (src/shortcuts.rs): space/J/K/L, I/O, S split, A/^A, ⌘Z/⌘⇧Z, ⌘N/⌘O/⌘S/⌘E/⌘Q, frame step, Home, track zoom; dispatch shares the menu action path and stays silent over modals - screenshots: preferences dialog zh/en captured and reviewed
This commit is contained in:
+7
-1
@@ -15,6 +15,10 @@ CmakeSettings.json
|
||||
# clangd's index and likely other things that need not be in the repository
|
||||
.cache/
|
||||
|
||||
# Generated packaging assets (icons/icon.png is produced from Oak_Icon.svg
|
||||
# by rsvg-convert in the CD workflow; see .github/workflows/cd.yml)
|
||||
/icons/
|
||||
|
||||
# macOS General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
@@ -114,4 +118,6 @@ otio-install/
|
||||
# Rust
|
||||
**/target/
|
||||
tarpaulin-out/
|
||||
.env
|
||||
.env
|
||||
# CD packaging artifacts
|
||||
/*.dmg
|
||||
|
||||
@@ -244,7 +244,11 @@ impl ManagerInner {
|
||||
self.output_device = device;
|
||||
self.output_started = false;
|
||||
self.output_buffer.clear();
|
||||
// The stream reopens with the new device on the next push.
|
||||
// The stream reopens with the new device on the next push. Drop the
|
||||
// cached params too: `push_to_output` only re-opens the stream on a
|
||||
// params change, so without this the switch would stay silent until
|
||||
// the format changed.
|
||||
self.output_params = None;
|
||||
self.output_device_stream.close();
|
||||
Ok(())
|
||||
}
|
||||
@@ -440,6 +444,48 @@ pub fn find_device_by_name_s_or_default(name: &String, _is_output_device: bool)
|
||||
|
||||
}
|
||||
|
||||
/// The host's output device names in enumeration order; the list index is
|
||||
/// the device index [`ManagerInner::set_output_device`] takes. The default
|
||||
/// device is NOT marked — callers prepend their own "system default" entry
|
||||
/// (index -1). Static (needs no manager instance).
|
||||
pub fn output_device_names() -> Vec<String> {
|
||||
device_names(true)
|
||||
}
|
||||
|
||||
/// The host's input device names in enumeration order (see
|
||||
/// [`output_device_names`]).
|
||||
pub fn input_device_names() -> Vec<String> {
|
||||
device_names(false)
|
||||
}
|
||||
|
||||
/// Enumerates the default host's devices, keeping only the ones that
|
||||
/// support the requested direction; unnamed devices are skipped.
|
||||
fn device_names(output: bool) -> Vec<String> {
|
||||
let host = cpal::default_host();
|
||||
let devices = if output {
|
||||
host.output_devices()
|
||||
} else {
|
||||
host.input_devices()
|
||||
};
|
||||
let Ok(devices) = devices else {
|
||||
return Vec::new();
|
||||
};
|
||||
devices
|
||||
.filter_map(|d| d.id().ok().map(|id| id.id().to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The enumeration index of the device named `name` (`None` when absent);
|
||||
/// the inverse of [`output_device_names`]/[`input_device_names`].
|
||||
pub fn device_index_by_name(name: &str, output: bool) -> Option<i32> {
|
||||
let names = if output {
|
||||
output_device_names()
|
||||
} else {
|
||||
input_device_names()
|
||||
};
|
||||
names.iter().position(|n| n == name).map(|i| i as i32)
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -926,10 +926,20 @@ mod tests {
|
||||
/// The default disk cache directory (C++ `DiskManager::
|
||||
/// get_default_disk_cache_path`): `<configuration location>/mediacache`.
|
||||
///
|
||||
/// The `DiskCachePath` config key overrides the location when set (the
|
||||
/// preferences dialog's cache-directory setting); an empty/absent value
|
||||
/// keeps the default.
|
||||
///
|
||||
/// Single-lib unification: this used to live in the oakrender crate's
|
||||
/// `bridge::common` fallback (see `docs/zh/plans/riir/single-lib.md`);
|
||||
/// oaknode and oakrender both call it directly now.
|
||||
pub fn default_disk_cache_path() -> String {
|
||||
// A configured override wins (whitespace-only counts as absent).
|
||||
if let Ok(custom) = crate::configstore::ConfigStore::instance().get(None, "DiskCachePath") {
|
||||
if !custom.trim().is_empty() {
|
||||
return custom;
|
||||
}
|
||||
}
|
||||
Path::new(
|
||||
&FileFunctions::new()
|
||||
.get_configuration_location()
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 455 KiB After Width: | Height: | Size: 456 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 439 KiB After Width: | Height: | Size: 440 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 490 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 466 KiB |
@@ -25,9 +25,10 @@
|
||||
- **扁平化现代 UI**,图标用 C++ 版素材(assets/icons/{dark,light},
|
||||
16px 网格),双主题,中英双语(src/i18n.rs + gpui i18n 钩子)。
|
||||
- **平台**:macOS(Apple Silicon)优先;Linux 链接已通(build.rs
|
||||
rpath+--export-dynamic);Windows 卡在 DLL 不允许未定义符号
|
||||
(oakcore_* 宿主导入),需 stub import lib 或 delay-load(见
|
||||
§5 风险表)。
|
||||
rpath+--export-dynamic);Windows 的 DLL 未定义符号问题已解决
|
||||
(M12 P5:oakcore_audioparams_* 收编进 dylib,DLL 无未定义符号,
|
||||
oakengine/cli/worker 可链接;app 的 win32 支持仍待 gpui,见 §5
|
||||
风险表)。
|
||||
- **插件 GUI**(OFX Interact/Dialog、第三方语言插件自绘窗口)走
|
||||
方案 2(已立项时的决议):引擎侧离屏渲染,app 侧贴图;窗口类
|
||||
需求由插件自建窗口、app 不嵌套。对应 M11 第 4 期(UI 类 suite)。
|
||||
@@ -64,7 +65,7 @@
|
||||
| G5 | 全分辨率异步渲染(facade worker 进程面)未绑 | 代理分辨率外的画质 | oakengine::worker NDJSON |
|
||||
| G6 | 时间线音频波形未显示 | 波形提取已是真实现(oakaudio/ffmpeg-next) | src/panels/timeline.rs |
|
||||
| G7 | move_clip 跨轨 | facade 签名冻结无目标轨参数(需新增导出) | crates/oakengine |
|
||||
| G8 | Windows 构建 | DLL 未定义符号问题 | 根 build.rs 注释 |
|
||||
| G8 | Windows 构建 | ~~DLL 未定义符号~~ 已解决(M12 P5:oakcore_audioparams_* 收编进 dylib);剩余为 app 侧 win32(gpui) | 根 build.rs 注释 + §5 风险表 |
|
||||
|
||||
## 2. 分期
|
||||
|
||||
@@ -127,8 +128,8 @@
|
||||
2. 偏好设置完整化(缓存目录、代理策略、自动保存间隔、默认
|
||||
过渡等,全部落 oakengine_config_*)。
|
||||
3. 键盘快捷键表(对齐 C++ 版快捷键,i18n 无关)。
|
||||
4. Windows 支持:解决 DLL 未定义符号(stub import lib 或把
|
||||
oakcore_* 收进 dylib 本体),CI windows job 转绿。
|
||||
4. Windows 支持:oakcore_* 已收编进 dylib(M12 P5,DLL 无未定义符号,
|
||||
oakengine/cli/worker 可链接);CI windows job 转绿待工具链到位。
|
||||
5. CD 实跑验证六个包(deb/rpm/pkg.tar.zst/AppImage/NSIS/dmg)
|
||||
能装能起。
|
||||
|
||||
@@ -153,7 +154,7 @@
|
||||
|
||||
| 风险 | 影响 | 缓解 |
|
||||
|------|------|------|
|
||||
| Windows DLL 未定义符号 | Windows 无法出包 | oakcore_* 收编进 dylib 或生成 stub import lib;CI 先保 Linux/macOS |
|
||||
| Windows DLL 未定义符号(oakcore_* 宿主导入) | Windows 无法出包 | 已解决(M12 P5):oakcore_audioparams_* 收编进 dylib,DLL 无未定义符号;CI 先保 Linux/macOS |
|
||||
| gpui fork 与上游分叉扩大 | 维护成本 | 只在 gpui_widgets 层扩展;上游同步按季度评审 |
|
||||
| 音频回调实时性(PortAudio 回调里禁锁/分配) | 爆音 | 回调只读写无锁环形缓冲;oakaudio PreviewAudioDevice 已是此形态 |
|
||||
| OFX 插件 GUI(方案 2)交互延迟 | 插件调参手感 | P5 后单独立项评审(M11 第 4 期) |
|
||||
|
||||
@@ -50,6 +50,8 @@ const OUT_ZH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-windo
|
||||
const OUT_EN: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-window-en.png");
|
||||
const OUT_MGR_ZH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-manager.png");
|
||||
const OUT_MGR_EN: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-manager-en.png");
|
||||
const OUT_PREF_ZH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-preferences.png");
|
||||
const OUT_PREF_EN: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-preferences-en.png");
|
||||
|
||||
/// Logical y of the timeline toolbar row, which sits at the top of the
|
||||
/// bottom dock panel in the default layout: the dock starts at y 27.5 (the
|
||||
@@ -86,6 +88,7 @@ fn main() -> Result<()> {
|
||||
image.save(OUT_ZH)?;
|
||||
println!("wrote {OUT_ZH} ({}×{})", image.width(), image.height());
|
||||
assert_toolbar(&image, "zh-CN");
|
||||
capture_preferences(&mut cx, handle, &root, OUT_PREF_ZH)?;
|
||||
capture_manager(&mut cx, handle, &root, OUT_MGR_ZH)?;
|
||||
}
|
||||
i18n::set_language(Language::EnUs);
|
||||
@@ -96,6 +99,7 @@ fn main() -> Result<()> {
|
||||
image.save(OUT_EN)?;
|
||||
println!("wrote {OUT_EN} ({}×{})", image.width(), image.height());
|
||||
assert_toolbar(&image, "en-US");
|
||||
capture_preferences(&mut cx, handle, &root, OUT_PREF_EN)?;
|
||||
capture_manager(&mut cx, handle, &root, OUT_MGR_EN)?;
|
||||
}
|
||||
i18n::set_language(original);
|
||||
@@ -155,6 +159,28 @@ fn settle(cx: &mut VisualTestAppContext, handle: AnyWindowHandle) {
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// Opens the preferences dialog on the shell (M12 P5b) and captures it: the
|
||||
/// modal lists the grouped settings (general / rendering / cache / proxy /
|
||||
/// project / audio). Drives the root ENTITY (not the window handle — see
|
||||
/// [`capture_manager`]). The dialog closes afterwards so the manager
|
||||
/// capture starts from a clean shell.
|
||||
fn capture_preferences(
|
||||
cx: &mut VisualTestAppContext,
|
||||
handle: gpui::WindowHandle<OakApp<MockEngine>>,
|
||||
root: &Entity<OakApp<MockEngine>>,
|
||||
out: &str,
|
||||
) -> Result<()> {
|
||||
root.update(cx, |app, cx| app.open_preferences(cx));
|
||||
settle(cx, handle.into());
|
||||
let image = cx.capture_screenshot(handle.into())?;
|
||||
image.save(out)?;
|
||||
println!("wrote {out} ({}×{})", image.width(), image.height());
|
||||
// Close the dialog (the Close button path: commit + dismiss).
|
||||
root.update(cx, |app, cx| app.close_modal(cx));
|
||||
cx.run_until_parked();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens the project manager on the shell (M13 D4) and captures it: the
|
||||
/// modal card lists the mock library with its per-project stats. Drives the
|
||||
/// root ENTITY (not the window handle — a window update borrows the window,
|
||||
|
||||
+396
-26
@@ -68,7 +68,7 @@ use crate::panels::status_bar::StatusBar;
|
||||
use crate::panels::timeline::TimelinePanel;
|
||||
|
||||
// Menu item ids (unique per menu).
|
||||
mod menu_ids {
|
||||
pub(crate) mod menu_ids {
|
||||
pub const NEW_PROJECT: usize = 101;
|
||||
pub const OPEN_PROJECT: usize = 102;
|
||||
pub const EXPORT_PROJECT: usize = 103;
|
||||
@@ -86,17 +86,24 @@ mod menu_ids {
|
||||
pub const PASTE: usize = 205;
|
||||
pub const DELETE: usize = 206;
|
||||
pub const RIPPLE_DELETE: usize = 207;
|
||||
pub const SELECT_ALL: usize = 208;
|
||||
|
||||
pub const THEME_DARK: usize = 301;
|
||||
pub const THEME_LIGHT: usize = 302;
|
||||
pub const LANG_ZH: usize = 303;
|
||||
pub const LANG_EN: usize = 304;
|
||||
pub const PREFERENCES: usize = 305;
|
||||
pub const ZOOM_IN: usize = 306;
|
||||
pub const ZOOM_OUT: usize = 307;
|
||||
|
||||
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 PLAY: usize = 405;
|
||||
pub const PAUSE: usize = 406;
|
||||
pub const SET_IN_POINT: usize = 407;
|
||||
pub const SET_OUT_POINT: usize = 408;
|
||||
|
||||
pub const ADD_VIDEO_TRACK: usize = 501;
|
||||
pub const ADD_AUDIO_TRACK: usize = 502;
|
||||
@@ -289,6 +296,10 @@ pub struct OakApp<E: AppEngine> {
|
||||
dark: bool,
|
||||
/// The modal currently shown on top of the shell, if any.
|
||||
modal: ModalState<E>,
|
||||
/// The shell's own focus handle: the root element tracks it so the
|
||||
/// keyboard shortcut layer (the root's `on_key_down`) sits on the
|
||||
/// dispatch path even before any panel takes focus.
|
||||
shell_focus: gpui::FocusHandle,
|
||||
/// The running export session, if any.
|
||||
export: Option<ExportRun>,
|
||||
/// The library row pending an export save dialog (manager 导出).
|
||||
@@ -299,7 +310,13 @@ impl<E: AppEngine> OakApp<E> {
|
||||
/// Builds the whole shell. `initial_path` (a CLI argument) is opened
|
||||
/// after the layout is up.
|
||||
pub fn new(window: &mut Window, initial_path: Option<PathBuf>, cx: &mut Context<Self>) -> Self {
|
||||
apply_theme(cx, &OakTheme::olive_dark());
|
||||
// The persisted theme (config `Theme`) wins; the app defaults to dark.
|
||||
let dark = crate::oakui::real::theme_is_dark();
|
||||
if dark {
|
||||
apply_theme(cx, &OakTheme::olive_dark());
|
||||
} else {
|
||||
apply_theme(cx, &OakTheme::olive_light());
|
||||
}
|
||||
crate::oakui::icons::init(cx);
|
||||
|
||||
// --- engine and shared state ---------------------------------------
|
||||
@@ -322,7 +339,7 @@ impl<E: AppEngine> OakApp<E> {
|
||||
});
|
||||
|
||||
// --- menu bar ------------------------------------------------------
|
||||
let menu_bar = cx.new(|cx| MenuBar::new(1, make_menus(true), window, cx));
|
||||
let menu_bar = cx.new(|cx| MenuBar::new(1, make_menus(dark), window, cx));
|
||||
cx.subscribe(
|
||||
&menu_bar,
|
||||
|this, _menu: Entity<MenuBar>, event: &MenuBarEvent, cx| {
|
||||
@@ -483,6 +500,10 @@ impl<E: AppEngine> OakApp<E> {
|
||||
})
|
||||
.detach();
|
||||
|
||||
let shell_focus = cx.focus_handle();
|
||||
// The shell starts focused so the shortcut layer works before any
|
||||
// panel grabs focus.
|
||||
window.focus(&shell_focus, cx);
|
||||
let shell = Self {
|
||||
engine,
|
||||
program_clock,
|
||||
@@ -491,8 +512,9 @@ impl<E: AppEngine> OakApp<E> {
|
||||
menu_bar,
|
||||
dock,
|
||||
status_bar,
|
||||
dark: true,
|
||||
dark,
|
||||
modal: ModalState::None,
|
||||
shell_focus,
|
||||
export: None,
|
||||
pending_export: None,
|
||||
};
|
||||
@@ -548,22 +570,21 @@ impl<E: AppEngine> OakApp<E> {
|
||||
REDO => self.engine.update(cx, |engine, cx| engine.redo(cx)),
|
||||
DELETE => self.delete_timeline_selection(false, cx),
|
||||
RIPPLE_DELETE => self.delete_timeline_selection(true, cx),
|
||||
SELECT_ALL => self.select_all_clips(cx),
|
||||
CUT | COPY | PASTE => {
|
||||
println!("[menu] clipboard action {item} not wired yet");
|
||||
}
|
||||
// --- View ------------------------------------------------------
|
||||
THEME_DARK => {
|
||||
self.dark = true;
|
||||
apply_theme(cx, &OakTheme::olive_dark());
|
||||
self.rebuild_menu_bar(cx);
|
||||
cx.notify();
|
||||
crate::oakui::real::set_theme_dark(true);
|
||||
self.apply_dark(true, cx);
|
||||
}
|
||||
THEME_LIGHT => {
|
||||
self.dark = false;
|
||||
apply_theme(cx, &OakTheme::olive_light());
|
||||
self.rebuild_menu_bar(cx);
|
||||
cx.notify();
|
||||
crate::oakui::real::set_theme_dark(false);
|
||||
self.apply_dark(false, cx);
|
||||
}
|
||||
ZOOM_IN => self.zoom_timeline(1.25, cx),
|
||||
ZOOM_OUT => self.zoom_timeline(0.8, cx),
|
||||
LANG_ZH => self.switch_language(crate::i18n::Language::ZhCN, cx),
|
||||
LANG_EN => self.switch_language(crate::i18n::Language::EnUs, cx),
|
||||
PREFERENCES => self.open_preferences(cx),
|
||||
@@ -589,12 +610,24 @@ impl<E: AppEngine> OakApp<E> {
|
||||
self.engine
|
||||
.update(cx, |engine, cx| engine.step(monitor, 1, cx));
|
||||
}
|
||||
PLAY => {
|
||||
let monitor = Monitor::Program;
|
||||
self.engine
|
||||
.update(cx, |engine, cx| engine.play(monitor, cx));
|
||||
}
|
||||
PAUSE => {
|
||||
let monitor = Monitor::Program;
|
||||
self.engine
|
||||
.update(cx, |engine, cx| engine.pause(monitor, cx));
|
||||
}
|
||||
TO_START => {
|
||||
let monitor = Monitor::Program;
|
||||
self.engine.update(cx, |engine, cx| {
|
||||
engine.request_frame(monitor, Frame::ZERO, cx)
|
||||
});
|
||||
}
|
||||
SET_IN_POINT => self.set_point_at_playhead(true, cx),
|
||||
SET_OUT_POINT => self.set_point_at_playhead(false, cx),
|
||||
// --- Sequence --------------------------------------------------
|
||||
ADD_VIDEO_TRACK => {
|
||||
let kind = gpui::timeline::TrackKind::Video;
|
||||
@@ -661,6 +694,98 @@ impl<E: AppEngine> OakApp<E> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 编辑 → 全选: selects every timeline clip and forwards the selection
|
||||
/// to the engine (the effect stack's target), mirroring what the
|
||||
/// timeline's own `SelectionChanged` subscription does.
|
||||
fn select_all_clips(&mut self, cx: &mut Context<Self>) {
|
||||
let ids: Vec<ClipId> = {
|
||||
let engine = self.engine.read(cx);
|
||||
let mut ids = Vec::new();
|
||||
for index in 0..engine.track_count() {
|
||||
if let Some(track) = engine.track(index) {
|
||||
ids.extend(track.clips().iter().map(|clip| clip.id()));
|
||||
}
|
||||
}
|
||||
ids
|
||||
};
|
||||
self.timeline.update(cx, |view, cx| {
|
||||
view.state.select_range(ids.iter().copied());
|
||||
cx.notify();
|
||||
});
|
||||
self.engine
|
||||
.update(cx, |engine, cx| engine.set_selected_clips(ids, cx));
|
||||
}
|
||||
|
||||
/// 视图 → 放大/缩小: scales the timeline zoom around its left edge
|
||||
/// (`TimelineState::set_zoom` clamps to the widget's zoom range).
|
||||
fn zoom_timeline(&mut self, factor: f32, cx: &mut Context<Self>) {
|
||||
self.timeline.update(cx, |view, cx| {
|
||||
let zoom = view.state.zoom * factor;
|
||||
view.state.set_zoom(zoom, px(0.));
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
/// 回放 → 设置入点/出点: moves the work area's start (`in_point`) or end
|
||||
/// to the program playhead as ONE undoable entry (the same commit the
|
||||
/// ruler drag and 序列 → 设置工作区 use). Without an existing work area
|
||||
/// the far edge falls back to the sequence bounds.
|
||||
fn set_point_at_playhead(&mut self, in_point: bool, cx: &mut Context<Self>) {
|
||||
let playhead = self.program_clock.read(cx).current_frame();
|
||||
let seq_len = self
|
||||
.engine
|
||||
.read(cx)
|
||||
.current_sequence()
|
||||
.map(|s| s.length)
|
||||
.unwrap_or(Frame(playhead.0 + 1));
|
||||
let (old_start, old_end) = self
|
||||
.engine
|
||||
.read(cx)
|
||||
.workarea()
|
||||
.unwrap_or((Frame::ZERO, seq_len));
|
||||
let (start, end) = if in_point {
|
||||
(playhead, old_end.max(Frame(playhead.0 + 1)))
|
||||
} else {
|
||||
(old_start.min(Frame((playhead.0 - 1).max(0))), playhead)
|
||||
};
|
||||
if end.0 <= start.0 {
|
||||
println!("[playback] set in/out point: empty range, ignored");
|
||||
return;
|
||||
}
|
||||
self.engine.update(cx, |engine, cx| {
|
||||
engine.commit_workarea(old_start, old_end, start, end, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Applies the dark/light theme and rebuilds the menu bar (the theme
|
||||
/// checkmark moves). The caller persists the choice through
|
||||
/// [`crate::oakui::real::set_theme_dark`].
|
||||
fn apply_dark(&mut self, dark: bool, cx: &mut Context<Self>) {
|
||||
self.dark = dark;
|
||||
if dark {
|
||||
apply_theme(cx, &OakTheme::olive_dark());
|
||||
} else {
|
||||
apply_theme(cx, &OakTheme::olive_light());
|
||||
}
|
||||
self.rebuild_menu_bar(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The shell's keyboard shortcut entry point: maps the keystroke through
|
||||
/// [`crate::shortcuts`] and dispatches the matched menu action. While a
|
||||
/// modal dialog is open the shell stays keyboard-quiet, so the dialogs'
|
||||
/// text fields never trigger editing actions.
|
||||
fn on_shortcut(&mut self, keystroke: &gpui::Keystroke, cx: &mut Context<Self>) {
|
||||
if !matches!(self.modal, ModalState::None) {
|
||||
return;
|
||||
}
|
||||
let Some(action) = crate::shortcuts::action_for(keystroke) else {
|
||||
return;
|
||||
};
|
||||
cx.stop_propagation();
|
||||
self.on_menu(action, cx);
|
||||
}
|
||||
|
||||
/// Removes the first track selected in the timeline header.
|
||||
fn remove_selected_track(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(&index) = self.timeline.read(cx).selected_tracks().iter().next() else {
|
||||
@@ -986,7 +1111,7 @@ impl<E: AppEngine> OakApp<E> {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Closes the current modal.
|
||||
fn close_modal(&mut self, cx: &mut Context<Self>) {
|
||||
pub fn close_modal(&mut self, cx: &mut Context<Self>) {
|
||||
self.modal = ModalState::None;
|
||||
cx.notify();
|
||||
}
|
||||
@@ -1158,14 +1283,16 @@ impl<E: AppEngine> OakApp<E> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the preferences dialog.
|
||||
fn open_preferences(&mut self, cx: &mut Context<Self>) {
|
||||
/// Opens the preferences dialog. Theme/language selections emit
|
||||
/// [`crate::dialogs::PreferencesEvent`]s, applied to the shell chrome
|
||||
/// immediately; the typed cache directory commits when the dialog closes.
|
||||
pub fn open_preferences(&mut self, cx: &mut Context<Self>) {
|
||||
self.spawn_modal(cx, |window, app| {
|
||||
let content = app.new(|cx| PreferencesContent::new(window, cx));
|
||||
let modal = app.new(|cx| {
|
||||
Modal::new(
|
||||
modal_ids::PREFERENCES,
|
||||
ModalOptions::new(crate::i18n::tr("preferences.title"), px(380.0))
|
||||
ModalOptions::new(crate::i18n::tr("preferences.title"), px(480.0))
|
||||
.with_button(DialogButton::primary(crate::i18n::tr("dialog.close"))),
|
||||
window,
|
||||
cx,
|
||||
@@ -1174,6 +1301,30 @@ impl<E: AppEngine> OakApp<E> {
|
||||
});
|
||||
ModalState::Preferences { modal, content }
|
||||
});
|
||||
if let ModalState::Preferences { content, .. } = &self.modal {
|
||||
let content = content.clone();
|
||||
cx.subscribe(
|
||||
&content,
|
||||
|this, _content, event: &crate::dialogs::PreferencesEvent, cx| match *event {
|
||||
crate::dialogs::PreferencesEvent::ThemeChanged(dark) => {
|
||||
this.apply_dark(dark, cx);
|
||||
}
|
||||
crate::dialogs::PreferencesEvent::LanguageChanged => {
|
||||
this.rebuild_menu_bar(cx);
|
||||
cx.notify();
|
||||
}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
/// Commits the preferences dialog's free-text fields (the cache
|
||||
/// directory path) before the modal closes.
|
||||
fn commit_preferences(&mut self, cx: &mut Context<Self>) {
|
||||
if let ModalState::Preferences { content, .. } = &self.modal {
|
||||
content.update(cx, |content, cx| content.commit_cache_dir(cx));
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the export dialog.
|
||||
@@ -1328,7 +1479,10 @@ impl<E: AppEngine> OakApp<E> {
|
||||
self.cancel_export(cx);
|
||||
}
|
||||
}
|
||||
modal_ids::PREFERENCES => self.close_modal(cx),
|
||||
modal_ids::PREFERENCES => {
|
||||
self.commit_preferences(cx);
|
||||
self.close_modal(cx);
|
||||
}
|
||||
modal_ids::MANAGER => self.close_modal(cx),
|
||||
modal_ids::MANAGER_RENAME => {
|
||||
if *button == 0 {
|
||||
@@ -1356,6 +1510,10 @@ impl<E: AppEngine> OakApp<E> {
|
||||
modal_ids::MANAGER_RENAME | modal_ids::MANAGER_DELETE => {
|
||||
self.back_to_manager(cx);
|
||||
}
|
||||
modal_ids::PREFERENCES => {
|
||||
self.commit_preferences(cx);
|
||||
self.close_modal(cx);
|
||||
}
|
||||
_ => self.close_modal(cx),
|
||||
},
|
||||
}
|
||||
@@ -1363,11 +1521,18 @@ impl<E: AppEngine> OakApp<E> {
|
||||
}
|
||||
|
||||
impl<E: AppEngine> Render for OakApp<E> {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let mut root = div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.track_focus(&self.shell_focus)
|
||||
// The shell's keyboard shortcut layer: keys not consumed by a
|
||||
// focused widget bubble up here and dispatch through the
|
||||
// shortcut table (see `crate::shortcuts`).
|
||||
.on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, _window, cx| {
|
||||
this.on_shortcut(&event.keystroke, cx);
|
||||
}))
|
||||
.child(self.menu_bar.clone())
|
||||
.child(div().flex_1().min_h_0().child(self.dock.clone()))
|
||||
.child(self.status_bar.clone());
|
||||
@@ -1431,10 +1596,11 @@ fn make_menus(dark: bool) -> Vec<MenuBarEntry> {
|
||||
MenuItem::new(CUT, tr("menu.edit.cut")).with_shortcut("⌘X"),
|
||||
MenuItem::new(COPY, tr("menu.edit.copy")).with_shortcut("⌘C"),
|
||||
MenuItem::new(PASTE, tr("menu.edit.paste")).with_shortcut("⌘V"),
|
||||
MenuItem::new(DELETE, tr("menu.edit.delete"))
|
||||
.with_shortcut("⌫")
|
||||
MenuItem::new(DELETE, tr("menu.edit.delete")).with_shortcut("⌫"),
|
||||
MenuItem::new(RIPPLE_DELETE, tr("menu.edit.ripple_delete"))
|
||||
.with_shortcut("⇧⌫")
|
||||
.separated(),
|
||||
MenuItem::new(RIPPLE_DELETE, tr("menu.edit.ripple_delete")),
|
||||
MenuItem::new(SELECT_ALL, tr("menu.edit.select_all")).with_shortcut("A"),
|
||||
]),
|
||||
),
|
||||
MenuBarEntry::new(
|
||||
@@ -1442,18 +1608,28 @@ fn make_menus(dark: bool) -> Vec<MenuBarEntry> {
|
||||
Menu::new(vec![
|
||||
MenuItem::new(THEME_DARK, tr("menu.view.theme")).with_submenu(theme_submenu),
|
||||
MenuItem::new(LANG_ZH, tr("menu.view.language")).with_submenu(language_submenu),
|
||||
MenuItem::new(PREFERENCES, tr("menu.view.preferences")).separated(),
|
||||
MenuItem::new(ZOOM_IN, tr("menu.view.zoom_in"))
|
||||
.with_shortcut("+")
|
||||
.separated(),
|
||||
MenuItem::new(ZOOM_OUT, tr("menu.view.zoom_out")).with_shortcut("-"),
|
||||
MenuItem::new(PREFERENCES, tr("menu.view.preferences"))
|
||||
.with_shortcut("⌘,")
|
||||
.separated(),
|
||||
]),
|
||||
),
|
||||
MenuBarEntry::new(
|
||||
tr("menu.playback"),
|
||||
Menu::new(vec![
|
||||
MenuItem::new(PLAY_PAUSE, tr("menu.playback.play_pause")).with_shortcut("空格"),
|
||||
MenuItem::new(PLAY, tr("menu.playback.play")).with_shortcut("L"),
|
||||
MenuItem::new(PAUSE, tr("menu.playback.pause")).with_shortcut("K"),
|
||||
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"),
|
||||
MenuItem::new(SET_IN_POINT, tr("menu.playback.set_in_point")).with_shortcut("I"),
|
||||
MenuItem::new(SET_OUT_POINT, tr("menu.playback.set_out_point")).with_shortcut("O"),
|
||||
]),
|
||||
),
|
||||
MenuBarEntry::new(
|
||||
@@ -1462,7 +1638,8 @@ fn make_menus(dark: bool) -> Vec<MenuBarEntry> {
|
||||
MenuItem::new(ADD_VIDEO_TRACK, tr("menu.sequence.add_video_track")),
|
||||
MenuItem::new(ADD_AUDIO_TRACK, tr("menu.sequence.add_audio_track")),
|
||||
MenuItem::new(REMOVE_TRACK, tr("menu.sequence.remove_track")).separated(),
|
||||
MenuItem::new(SPLIT_AT_PLAYHEAD, tr("menu.sequence.split_at_playhead")),
|
||||
MenuItem::new(SPLIT_AT_PLAYHEAD, tr("menu.sequence.split_at_playhead"))
|
||||
.with_shortcut("S"),
|
||||
MenuItem::new(ADD_MARKER, tr("menu.sequence.add_marker")).with_shortcut("M"),
|
||||
MenuItem::new(REMOVE_MARKER, tr("menu.sequence.remove_marker")).separated(),
|
||||
MenuItem::new(SET_WORKAREA, tr("menu.sequence.set_workarea")),
|
||||
@@ -1561,6 +1738,10 @@ pub fn run() {
|
||||
fn run_with<E: AppEngine>(args: AppArgs) {
|
||||
let initial = args.project.clone();
|
||||
gpui_platform::application().run(move |cx: &mut App| {
|
||||
// Load the persisted preferences (config.ini) before anything reads
|
||||
// them — the language, the theme, the storage backend and the audio
|
||||
// devices all come from the config store.
|
||||
crate::oakui::real::config_load();
|
||||
// Restore the persisted UI language (config `Language` key) before the
|
||||
// first window renders.
|
||||
crate::i18n::init();
|
||||
@@ -1568,6 +1749,9 @@ fn run_with<E: AppEngine>(args: AppArgs) {
|
||||
// default location) unless the user configured the backend
|
||||
// explicitly.
|
||||
crate::oakui::real::configure_storage();
|
||||
// Bring up the audio manager and apply the persisted device choices
|
||||
// (without an instance, playback pushes fail silently).
|
||||
crate::oakui::real::audio_init_from_config();
|
||||
cx.init_colors();
|
||||
let bounds = Bounds::centered(None, size(px(1600.0), px(900.0)), cx);
|
||||
let initial = initial.clone();
|
||||
@@ -1607,9 +1791,10 @@ fn run_with<E: AppEngine>(args: AppArgs) {
|
||||
cx.activate(true);
|
||||
cx.on_window_closed(|cx, _| {
|
||||
if cx.windows().is_empty() {
|
||||
// Exit path (plan M13 §2): drain the write-through backlog
|
||||
// (save + snapshot of every still-bound project) and stop
|
||||
// the facade's snapshot thread before quitting.
|
||||
// Persist the preferences (config.ini), then drain the
|
||||
// write-through backlog (save + snapshot of every still-bound
|
||||
// project) and stop the facade's snapshot thread.
|
||||
crate::oakui::real::config_save();
|
||||
crate::oakui::real::storage_flush();
|
||||
cx.quit();
|
||||
}
|
||||
@@ -1622,6 +1807,7 @@ fn run_with<E: AppEngine>(args: AppArgs) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::oakui::EngineGateway as _;
|
||||
use gpui::timeline::TimelineDataSource as _;
|
||||
use gpui::{px, size, ExternalPaths, FileDropEvent, TestAppContext, VisualTestContext};
|
||||
|
||||
/// The 视图/View menu carries a 语言/Language submenu whose items are
|
||||
@@ -1801,6 +1987,190 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Keyboard shortcuts (M12 P5c)
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// Every shortcut-table action exists as a menu item (recursing into
|
||||
/// submenus), so a key press can never dispatch a dead action.
|
||||
#[test]
|
||||
fn every_shortcut_maps_to_a_menu_item() {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
crate::i18n::set_language(crate::i18n::Language::EnUs);
|
||||
|
||||
fn collect(menu: &Menu, out: &mut Vec<usize>) {
|
||||
for item in &menu.items {
|
||||
out.push(item.id);
|
||||
if let Some(sub) = &item.submenu {
|
||||
collect(sub, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ids = Vec::new();
|
||||
for entry in make_menus(true) {
|
||||
collect(&entry.menu, &mut ids);
|
||||
}
|
||||
for shortcut in crate::shortcuts::SHORTCUTS {
|
||||
assert!(
|
||||
ids.contains(&shortcut.action),
|
||||
"shortcut {} → action {} has no menu item",
|
||||
shortcut.keystroke,
|
||||
shortcut.action
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu's shortcut labels mirror the shortcut table's display
|
||||
/// strings (a drift between them would show the user the wrong key).
|
||||
#[test]
|
||||
fn menu_shortcut_labels_match_the_table() {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
crate::i18n::set_language(crate::i18n::Language::EnUs);
|
||||
|
||||
fn walk(menu: &Menu) -> Vec<(usize, Option<gpui::SharedString>)> {
|
||||
let mut out = Vec::new();
|
||||
for item in &menu.items {
|
||||
out.push((item.id, item.shortcut.clone()));
|
||||
if let Some(sub) = &item.submenu {
|
||||
out.extend(walk(sub));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
for entry in make_menus(true) {
|
||||
for (id, label) in walk(&entry.menu) {
|
||||
let Some(label) = label else { continue };
|
||||
let expected = crate::shortcuts::display_for(id)
|
||||
.unwrap_or_else(|| panic!("menu item {id} shows {label} but has no shortcut"));
|
||||
assert_eq!(
|
||||
label.as_ref(),
|
||||
expected,
|
||||
"shortcut label drift on menu item {id}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pressing space on the shell toggles program playback (the keystroke
|
||||
/// bubbles to the shell's key listener and dispatches 回放 → 播放/暂停).
|
||||
#[gpui::test]
|
||||
async fn space_toggles_program_playback(cx: &mut TestAppContext) {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let (window, root) = mock_shell(cx);
|
||||
|
||||
let playing = cx.read(|app| root.read(app).program_clock.read(app).is_playing());
|
||||
assert!(!playing, "the shell starts paused");
|
||||
|
||||
cx.dispatch_keystroke(
|
||||
window.into(),
|
||||
gpui::Keystroke::parse("space").unwrap(),
|
||||
);
|
||||
cx.run_until_parked();
|
||||
let playing = cx.read(|app| root.read(app).program_clock.read(app).is_playing());
|
||||
assert!(playing, "space starts program playback");
|
||||
|
||||
cx.dispatch_keystroke(
|
||||
window.into(),
|
||||
gpui::Keystroke::parse("space").unwrap(),
|
||||
);
|
||||
cx.run_until_parked();
|
||||
let playing = cx.read(|app| root.read(app).program_clock.read(app).is_playing());
|
||||
assert!(!playing, "space again pauses program playback");
|
||||
}
|
||||
|
||||
/// ⌘Z on the shell dispatches 编辑 → 撤销 to the engine, and "s" splits
|
||||
/// at the playhead.
|
||||
#[gpui::test]
|
||||
async fn edit_shortcuts_dispatch_to_the_engine(cx: &mut TestAppContext) {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let (window, root) = mock_shell(cx);
|
||||
|
||||
// Move the playhead inside the first clip (the → shortcut steps one
|
||||
// frame), then split with "s": the mock sequence grows by one clip.
|
||||
for _ in 0..10 {
|
||||
cx.dispatch_keystroke(window.into(), gpui::Keystroke::parse("right").unwrap());
|
||||
}
|
||||
cx.run_until_parked();
|
||||
let playhead = cx.read(|app| root.read(app).program_clock.read(app).current_frame());
|
||||
assert_eq!(playhead, Frame(10), "→ steps the playhead");
|
||||
|
||||
let clips_before: usize = cx.read(|app| {
|
||||
let engine = root.read(app).engine.read(app);
|
||||
(0..engine.track_count())
|
||||
.filter_map(|i| engine.track(i))
|
||||
.map(|t| t.clips().len())
|
||||
.sum()
|
||||
});
|
||||
cx.dispatch_keystroke(window.into(), gpui::Keystroke::parse("s").unwrap());
|
||||
cx.run_until_parked();
|
||||
let clips_after: usize = cx.read(|app| {
|
||||
let engine = root.read(app).engine.read(app);
|
||||
(0..engine.track_count())
|
||||
.filter_map(|i| engine.track(i))
|
||||
.map(|t| t.clips().len())
|
||||
.sum()
|
||||
});
|
||||
assert!(
|
||||
clips_after > clips_before,
|
||||
"split at the playhead adds a clip ({clips_before} → {clips_after})"
|
||||
);
|
||||
|
||||
// ⌘Z / ⌘⇧Z reach the engine's undo/redo (the mock counts the calls).
|
||||
cx.dispatch_keystroke(
|
||||
window.into(),
|
||||
gpui::Keystroke::parse("secondary-z").unwrap(),
|
||||
);
|
||||
cx.dispatch_keystroke(
|
||||
window.into(),
|
||||
gpui::Keystroke::parse("secondary-shift-z").unwrap(),
|
||||
);
|
||||
cx.run_until_parked();
|
||||
let (undo, redo) = cx.read(|app| root.read(app).engine.read(app).undo_redo_calls());
|
||||
assert_eq!((undo, redo), (1, 1), "⌘Z/⌘⇧Z dispatch undo/redo");
|
||||
}
|
||||
|
||||
/// While a modal dialog is open the shell's shortcuts are inert (the
|
||||
/// dialog's text fields must never trigger editing actions).
|
||||
#[gpui::test]
|
||||
async fn shortcuts_are_suppressed_while_a_modal_is_open(cx: &mut TestAppContext) {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let (window, root) = mock_shell(cx);
|
||||
|
||||
cx.update(|app| root.update(app, |app, cx| app.on_menu(menu_ids::PREFERENCES, cx)));
|
||||
cx.run_until_parked();
|
||||
|
||||
// "s" would split at the playhead without the modal guard.
|
||||
let clips_before: usize = cx.read(|app| {
|
||||
let engine = root.read(app).engine.read(app);
|
||||
(0..engine.track_count())
|
||||
.filter_map(|i| engine.track(i))
|
||||
.map(|t| t.clips().len())
|
||||
.sum()
|
||||
});
|
||||
cx.dispatch_keystroke(window.into(), gpui::Keystroke::parse("s").unwrap());
|
||||
cx.dispatch_keystroke(
|
||||
window.into(),
|
||||
gpui::Keystroke::parse("space").unwrap(),
|
||||
);
|
||||
cx.run_until_parked();
|
||||
|
||||
let (clips_after, playing) = cx.read(|app| {
|
||||
let root = root.read(app);
|
||||
let clips: usize = (0..root.engine.read(app).track_count())
|
||||
.filter_map(|i| root.engine.read(app).track(i))
|
||||
.map(|t| t.clips().len())
|
||||
.sum();
|
||||
(clips, root.program_clock.read(app).is_playing())
|
||||
});
|
||||
assert_eq!(clips_after, clips_before, "no split while the modal is open");
|
||||
assert!(!playing, "no playback toggle while the modal is open");
|
||||
assert!(
|
||||
cx.read(|app| matches!(root.read(app).modal, ModalState::Preferences { .. })),
|
||||
"the modal is still open"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// 文件 → 导入素材… opens the *platform* path picker (not the in-window
|
||||
/// file dialog) and routes the picked path to the engine's import; the
|
||||
/// mock engine records it, so the async round trip is observable.
|
||||
|
||||
+403
-14
@@ -14,44 +14,98 @@
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! The content views of the app's modal dialogs: preferences (renderer
|
||||
//! backend + language) and export (format + output path).
|
||||
//! The content views of the app's modal dialogs: preferences (the settings
|
||||
//! panel mirroring the C++ tabbed preferences: general / rendering / cache
|
||||
//! / proxy / project / audio) and export (format + output path).
|
||||
//!
|
||||
//! Each view owns its widgets and emits nothing itself — the host
|
||||
//! (`crate::app::OakApp`) reads the state (format / path) when a dialog
|
||||
//! button is clicked, and the preferences view writes its choices straight
|
||||
//! through the config C ABI on selection.
|
||||
//! through the config C ABI on selection. Theme/language changes
|
||||
//! additionally emit a [`PreferencesEvent`] so the host can re-apply the
|
||||
//! shell chrome immediately.
|
||||
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{div, App, Context, Entity, Render, SharedString, Window};
|
||||
use gpui::{div, px, App, Context, Entity, Render, SharedString, Window};
|
||||
use gpui_elements::editable_text::{text_input, EditableTextState, StringStorage};
|
||||
use gpui_widgets::checkbox::{CheckBox, CheckBoxEvent, CheckState};
|
||||
use gpui_widgets::combo_box::{ComboBox, ComboBoxEvent, ComboBoxOption};
|
||||
use gpui_widgets::slider::SliderModel;
|
||||
use gpui_widgets::spinbox::{SpinBox, SpinBoxEvent};
|
||||
use gpui_widgets::value::{SliderValue, ValueKind};
|
||||
|
||||
use crate::i18n;
|
||||
use crate::oakui::real::{
|
||||
config_get_string, config_set_string, encoding_formats, renderer_backends,
|
||||
CONFIG_KEY_RENDERER_BACKEND, EXPORT_FORMAT_MP4,
|
||||
audio_input_device, audio_input_devices, audio_output_device, audio_output_devices,
|
||||
config_get_bool, config_get_int, config_get_string, config_set_bool, config_set_int,
|
||||
config_set_string, encoding_formats, proxy_dividers, renderer_backends,
|
||||
set_audio_input_device, set_audio_output_device, set_theme_dark, theme_is_dark,
|
||||
CONFIG_KEY_DEFAULT_TRANSITION_SEC, CONFIG_KEY_DISK_CACHE_PATH, CONFIG_KEY_PROXY_DIVIDER,
|
||||
CONFIG_KEY_RENDERER_BACKEND, CONFIG_KEY_SNAPSHOT_INTERVAL_SEC, CONFIG_KEY_USE_PROXY,
|
||||
DEFAULT_SNAPSHOT_INTERVAL_SEC, DEFAULT_TRANSITION_SEC, EXPORT_FORMAT_MP4,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preferences
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The preferences dialog content: the renderer backend and the language
|
||||
/// dropdowns. Both write through the config C ABI on selection, so the
|
||||
/// choices survive restarts.
|
||||
/// A request the preferences dialog emits for the host shell (the settings
|
||||
/// themselves are written through the config C ABI directly; these need
|
||||
/// shell chrome — the menu bar / theme — to re-render).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PreferencesEvent {
|
||||
/// The theme dropdown changed (the payload is the new dark flag).
|
||||
ThemeChanged(bool),
|
||||
/// The language dropdown changed (already applied to the i18n global).
|
||||
LanguageChanged,
|
||||
}
|
||||
|
||||
impl gpui::EventEmitter<PreferencesEvent> for PreferencesContent {}
|
||||
|
||||
/// The preferences dialog content, mirroring the C++ tabbed preferences as
|
||||
/// one grouped panel:
|
||||
///
|
||||
/// * **常规 General** — language, theme.
|
||||
/// * **渲染 Rendering** — the renderer backend.
|
||||
/// * **缓存 Cache** — the disk cache directory (`DiskCachePath`).
|
||||
/// * **代理 Proxy** — use proxy media (`UseProxyMedia`), proxy resolution
|
||||
/// divider (`ProxyDivider`).
|
||||
/// * **项目 Project** — the snapshot interval (`Storage/SnapshotIntervalSec`,
|
||||
/// the write-through era's auto-save interval) and the default transition
|
||||
/// length (`DefaultTransitionLength`).
|
||||
/// * **音频 Audio** — the output / input devices (`AudioOutput` /
|
||||
/// `AudioInput`, applied live through the audio facade).
|
||||
///
|
||||
/// Every row writes through the config C ABI on selection, so the choices
|
||||
/// survive restarts (the app loads the config at startup and saves it on
|
||||
/// exit).
|
||||
pub struct PreferencesContent {
|
||||
backend: Entity<ComboBox>,
|
||||
language: Entity<ComboBox>,
|
||||
theme: Entity<ComboBox>,
|
||||
cache_dir: Entity<PathField>,
|
||||
use_proxy: Entity<CheckBox>,
|
||||
proxy_divider: Entity<ComboBox>,
|
||||
snapshot_interval: Entity<SpinBox>,
|
||||
transition_length: Entity<SpinBox>,
|
||||
audio_output: Entity<ComboBox>,
|
||||
audio_input: Entity<ComboBox>,
|
||||
/// The backend options, in display order.
|
||||
backends: Vec<&'static str>,
|
||||
/// The proxy divider options, in display order (1 = full resolution).
|
||||
dividers: Vec<i64>,
|
||||
/// The output device names (dropdown order; index 0 is system default).
|
||||
output_devices: Vec<String>,
|
||||
/// The input device names (dropdown order; index 0 is system default).
|
||||
input_devices: Vec<String>,
|
||||
}
|
||||
|
||||
impl PreferencesContent {
|
||||
/// Builds the content: reads the current config values and seeds the
|
||||
/// dropdowns.
|
||||
/// Builds the content: reads the current config values and seeds every
|
||||
/// widget.
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
// --- 渲染 Rendering: the renderer backend --------------------------
|
||||
let backends = renderer_backends();
|
||||
let current_backend = config_get_string(CONFIG_KEY_RENDERER_BACKEND);
|
||||
let backend_selected = backends
|
||||
@@ -81,6 +135,7 @@ impl PreferencesContent {
|
||||
combo.set_selected(Some(backend_selected), cx)
|
||||
});
|
||||
|
||||
// --- 常规 General: language + theme --------------------------------
|
||||
let language_options = vec![
|
||||
ComboBoxOption::new(0, "English (en-US)"),
|
||||
ComboBoxOption::new(1, "简体中文 (zh-CN)"),
|
||||
@@ -100,20 +155,272 @@ impl PreferencesContent {
|
||||
_ => crate::i18n::Language::EnUs,
|
||||
};
|
||||
crate::i18n::set_language(language);
|
||||
cx.emit(PreferencesEvent::LanguageChanged);
|
||||
}
|
||||
let _ = cx;
|
||||
})
|
||||
.detach();
|
||||
language.update(cx, |combo, cx| {
|
||||
combo.set_selected(Some(language_selected), cx)
|
||||
});
|
||||
|
||||
let theme_options = vec![
|
||||
ComboBoxOption::new(0, i18n::tr("preferences.theme.dark")),
|
||||
ComboBoxOption::new(1, i18n::tr("preferences.theme.light")),
|
||||
];
|
||||
let theme = cx.new(|cx| ComboBox::new(3, theme_options, window, cx));
|
||||
cx.subscribe(&theme, |_this, _combo, event: &ComboBoxEvent, cx| {
|
||||
if let ComboBoxEvent::Selected { value, .. } = event {
|
||||
let dark = *value == 0;
|
||||
set_theme_dark(dark);
|
||||
cx.emit(PreferencesEvent::ThemeChanged(dark));
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
theme.update(cx, |combo, cx| {
|
||||
combo.set_selected(Some(if theme_is_dark() { 0 } else { 1 }), cx)
|
||||
});
|
||||
|
||||
// --- 缓存 Cache: the disk cache directory --------------------------
|
||||
let cache_dir = cx.new(|cx| {
|
||||
let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
|
||||
PathField { editor }
|
||||
});
|
||||
let configured_cache = config_get_string(CONFIG_KEY_DISK_CACHE_PATH);
|
||||
cache_dir.update(cx, |field, cx| field.set_path(configured_cache, cx));
|
||||
|
||||
// --- 代理 Proxy -----------------------------------------------------
|
||||
let use_proxy = cx.new(|cx| {
|
||||
CheckBox::new(
|
||||
7,
|
||||
if config_get_bool(CONFIG_KEY_USE_PROXY, true) {
|
||||
CheckState::Checked
|
||||
} else {
|
||||
CheckState::Unchecked
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.with_label(i18n::tr("preferences.proxy.enable"))
|
||||
});
|
||||
cx.subscribe(&use_proxy, |_this, check, event: &CheckBoxEvent, cx| {
|
||||
if let CheckBoxEvent::Toggled { state, .. } = event {
|
||||
let enabled = *state == CheckState::Checked;
|
||||
config_set_bool(CONFIG_KEY_USE_PROXY, enabled);
|
||||
check.update(cx, |check, cx| check.set_state(*state, cx));
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
let dividers = proxy_dividers();
|
||||
let divider_options = dividers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, d)| {
|
||||
if *d <= 1 {
|
||||
ComboBoxOption::new(i, i18n::tr("preferences.proxy.full"))
|
||||
} else {
|
||||
ComboBoxOption::new(i, format!("1/{d}"))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let proxy_divider = cx.new(|cx| ComboBox::new(4, divider_options, window, cx));
|
||||
cx.subscribe(&proxy_divider, |this, _combo, event: &ComboBoxEvent, cx| {
|
||||
if let ComboBoxEvent::Selected { value, .. } = event {
|
||||
if let Some(divider) = this.dividers.get(*value) {
|
||||
config_set_int(CONFIG_KEY_PROXY_DIVIDER, *divider);
|
||||
}
|
||||
}
|
||||
let _ = cx;
|
||||
})
|
||||
.detach();
|
||||
let current_divider = config_get_int(CONFIG_KEY_PROXY_DIVIDER, 1);
|
||||
let divider_selected = dividers
|
||||
.iter()
|
||||
.position(|d| *d == current_divider)
|
||||
.unwrap_or(0);
|
||||
proxy_divider.update(cx, |combo, cx| {
|
||||
combo.set_selected(Some(divider_selected), cx)
|
||||
});
|
||||
|
||||
// --- 项目 Project: snapshot interval + default transition ----------
|
||||
let snapshot_interval = cx.new(|cx| {
|
||||
let current =
|
||||
config_get_int(CONFIG_KEY_SNAPSHOT_INTERVAL_SEC, DEFAULT_SNAPSHOT_INTERVAL_SEC);
|
||||
SpinBox::new(
|
||||
8,
|
||||
SliderModel::new(ValueKind::Integer, 0.0, 86400.0, 10.0, current as f64),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.subscribe(
|
||||
&snapshot_interval,
|
||||
|_this, _spin, event: &SpinBoxEvent, cx| {
|
||||
let value = match event {
|
||||
SpinBoxEvent::ValueChanged { value, .. }
|
||||
| SpinBoxEvent::EditCommitted { value, .. } => value.to_f64() as i64,
|
||||
_ => return,
|
||||
};
|
||||
config_set_int(CONFIG_KEY_SNAPSHOT_INTERVAL_SEC, value);
|
||||
let _ = cx;
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
|
||||
let transition_length = cx.new(|cx| {
|
||||
let current = config_get_string(CONFIG_KEY_DEFAULT_TRANSITION_SEC)
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|v| *v >= 0.0)
|
||||
.unwrap_or_else(|| DEFAULT_TRANSITION_SEC.parse().unwrap());
|
||||
SpinBox::new(
|
||||
9,
|
||||
SliderModel::new(ValueKind::Float, 0.0, 60.0, 0.5, current),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.subscribe(
|
||||
&transition_length,
|
||||
|_this, _spin, event: &SpinBoxEvent, cx| {
|
||||
let value = match event {
|
||||
SpinBoxEvent::ValueChanged { value, .. }
|
||||
| SpinBoxEvent::EditCommitted { value, .. } => value.to_f64(),
|
||||
_ => return,
|
||||
};
|
||||
config_set_string(CONFIG_KEY_DEFAULT_TRANSITION_SEC, &format!("{value}"));
|
||||
let _ = cx;
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
|
||||
// --- 音频 Audio: output / input devices -----------------------------
|
||||
// The enumeration goes through the facade even on the mock engine;
|
||||
// the config choice applies the moment the device dropdown changes.
|
||||
let (audio_output, output_devices) =
|
||||
device_combo(5, true, window, cx);
|
||||
let (audio_input, input_devices) =
|
||||
device_combo(6, false, window, cx);
|
||||
cx.subscribe(&audio_output, |this, _combo, event: &ComboBoxEvent, cx| {
|
||||
if let ComboBoxEvent::Selected { value, .. } = event {
|
||||
// Option 0 is the system default; the devices start at 1.
|
||||
let name = value
|
||||
.checked_sub(1)
|
||||
.and_then(|i| this.output_devices.get(i))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
set_audio_output_device(&name);
|
||||
}
|
||||
let _ = cx;
|
||||
})
|
||||
.detach();
|
||||
cx.subscribe(&audio_input, |this, _combo, event: &ComboBoxEvent, cx| {
|
||||
if let ComboBoxEvent::Selected { value, .. } = event {
|
||||
let name = value
|
||||
.checked_sub(1)
|
||||
.and_then(|i| this.input_devices.get(i))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
set_audio_input_device(&name);
|
||||
}
|
||||
let _ = cx;
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
backend,
|
||||
language,
|
||||
theme,
|
||||
cache_dir,
|
||||
use_proxy,
|
||||
proxy_divider,
|
||||
snapshot_interval,
|
||||
transition_length,
|
||||
audio_output,
|
||||
audio_input,
|
||||
backends,
|
||||
dividers,
|
||||
output_devices,
|
||||
input_devices,
|
||||
}
|
||||
}
|
||||
|
||||
/// The cache directory currently entered.
|
||||
pub fn cache_dir(&self, cx: &App) -> SharedString {
|
||||
self.cache_dir.read(cx).path(cx)
|
||||
}
|
||||
|
||||
/// Commits the cache directory field to the config (called by the host
|
||||
/// when the dialog closes, so a typed-but-unbrowsed path still lands).
|
||||
pub fn commit_cache_dir(&self, cx: &App) {
|
||||
let path = self.cache_dir(cx).trim().to_string();
|
||||
config_set_string(CONFIG_KEY_DISK_CACHE_PATH, &path);
|
||||
}
|
||||
|
||||
/// Opens the platform directory picker and lands the choice in the cache
|
||||
/// directory field (committed with the field, on dialog close).
|
||||
fn browse_cache_dir(&mut self, cx: &mut Context<Self>) {
|
||||
let receiver = cx.prompt_for_paths(gpui::PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some(i18n::tr("preferences.cache.browse").into()),
|
||||
});
|
||||
cx.spawn(async move |this, cx| {
|
||||
let Ok(Ok(Some(paths))) = receiver.await else {
|
||||
return;
|
||||
};
|
||||
let Some(path) = paths.first() else {
|
||||
return;
|
||||
};
|
||||
this.update(cx, |this, cx| {
|
||||
this.cache_dir.update(cx, |field, cx| {
|
||||
field.set_path(path.to_string_lossy().into_owned(), cx)
|
||||
});
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a device dropdown for the output (`output = true`) or input side:
|
||||
/// option 0 is the system default, the rest are the enumerated devices, and
|
||||
/// the current config value (validated against the enumeration) is
|
||||
/// preselected. Returns the combo and the option→device-name list.
|
||||
fn device_combo(
|
||||
control: usize,
|
||||
output: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<PreferencesContent>,
|
||||
) -> (Entity<ComboBox>, Vec<String>) {
|
||||
let devices = if output {
|
||||
audio_output_devices()
|
||||
} else {
|
||||
audio_input_devices()
|
||||
};
|
||||
let current = if output {
|
||||
audio_output_device()
|
||||
} else {
|
||||
audio_input_device()
|
||||
};
|
||||
let mut options = vec![ComboBoxOption::new(0, i18n::tr("preferences.audio.default"))];
|
||||
for (i, name) in devices.iter().enumerate() {
|
||||
options.push(ComboBoxOption::new(i + 1, name.clone()));
|
||||
}
|
||||
let selected = devices
|
||||
.iter()
|
||||
.position(|n| *n == current)
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(0);
|
||||
let placeholder = if output {
|
||||
i18n::tr("preferences.audio.output.placeholder")
|
||||
} else {
|
||||
i18n::tr("preferences.audio.input.placeholder")
|
||||
};
|
||||
let combo = cx.new(|cx| ComboBox::new(control, options, window, cx).with_placeholder(placeholder));
|
||||
combo.update(cx, |combo, cx| combo.set_selected(Some(selected), cx));
|
||||
(combo, devices)
|
||||
}
|
||||
|
||||
/// A display label for a renderer backend id.
|
||||
@@ -142,23 +449,104 @@ fn form_row(
|
||||
.child(widget)
|
||||
}
|
||||
|
||||
/// A group header separating the preference sections.
|
||||
fn section_header(colors: &gpui::colors::Colors, label: SharedString) -> gpui::Div {
|
||||
div()
|
||||
.pt_2()
|
||||
.text_color(colors.disabled)
|
||||
.text_xs()
|
||||
.child(label)
|
||||
}
|
||||
|
||||
impl Render for PreferencesContent {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
// The full settings list is taller than a 900px window at the design
|
||||
// density, so the content scrolls inside the modal card.
|
||||
div()
|
||||
.id("preferences-content")
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.w_full()
|
||||
.max_h(px(720.0))
|
||||
.overflow_y_scroll()
|
||||
// 常规 General
|
||||
.child(section_header(&colors, i18n::tr("preferences.section.general").into()))
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("preferences.language").into(),
|
||||
self.language.clone(),
|
||||
))
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("preferences.theme").into(),
|
||||
self.theme.clone(),
|
||||
))
|
||||
// 渲染 Rendering
|
||||
.child(section_header(&colors, i18n::tr("preferences.section.render").into()))
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("preferences.backend").into(),
|
||||
self.backend.clone(),
|
||||
))
|
||||
// 缓存 Cache
|
||||
.child(section_header(&colors, i18n::tr("preferences.section.cache").into()))
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("preferences.language").into(),
|
||||
self.language.clone(),
|
||||
i18n::tr("preferences.cache.dir").into(),
|
||||
div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.child(div().flex_1().child(self.cache_dir.clone()))
|
||||
.child(
|
||||
div()
|
||||
.id("preferences-cache-browse")
|
||||
.px_3()
|
||||
.py_1()
|
||||
.rounded_md()
|
||||
.bg(colors.background)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.text_color(colors.text)
|
||||
.cursor_pointer()
|
||||
.child(i18n::tr("preferences.cache.browse"))
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.browse_cache_dir(cx);
|
||||
})),
|
||||
),
|
||||
))
|
||||
// 代理 Proxy
|
||||
.child(section_header(&colors, i18n::tr("preferences.section.proxy").into()))
|
||||
.child(self.use_proxy.clone())
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("preferences.proxy.resolution").into(),
|
||||
self.proxy_divider.clone(),
|
||||
))
|
||||
// 项目 Project
|
||||
.child(section_header(&colors, i18n::tr("preferences.section.project").into()))
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("preferences.snapshot.interval").into(),
|
||||
self.snapshot_interval.clone(),
|
||||
))
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("preferences.transition.default").into(),
|
||||
self.transition_length.clone(),
|
||||
))
|
||||
// 音频 Audio
|
||||
.child(section_header(&colors, i18n::tr("preferences.section.audio").into()))
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("preferences.audio.output").into(),
|
||||
self.audio_output.clone(),
|
||||
))
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("preferences.audio.input").into(),
|
||||
self.audio_input.clone(),
|
||||
))
|
||||
.child(
|
||||
div()
|
||||
@@ -169,6 +557,7 @@ impl Render for PreferencesContent {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+58
-2
@@ -196,6 +196,7 @@ const EN: &[(&str, &str)] = &[
|
||||
("menu.edit.paste", "Paste"),
|
||||
("menu.edit.delete", "Delete"),
|
||||
("menu.edit.ripple_delete", "Ripple Delete"),
|
||||
("menu.edit.select_all", "Select All"),
|
||||
// --- View ---
|
||||
("menu.view.theme", "Theme"),
|
||||
("menu.view.theme.dark", "Olive Dark"),
|
||||
@@ -203,12 +204,18 @@ const EN: &[(&str, &str)] = &[
|
||||
("menu.view.language", "Language"),
|
||||
("menu.view.language.en", "English"),
|
||||
("menu.view.language.zh", "简体中文"),
|
||||
("menu.view.zoom_in", "Zoom In"),
|
||||
("menu.view.zoom_out", "Zoom Out"),
|
||||
("menu.view.preferences", "Preferences…"),
|
||||
// --- Playback ---
|
||||
("menu.playback.play_pause", "Play/Pause"),
|
||||
("menu.playback.play", "Play"),
|
||||
("menu.playback.pause", "Pause"),
|
||||
("menu.playback.prev_frame", "Previous Frame"),
|
||||
("menu.playback.next_frame", "Next Frame"),
|
||||
("menu.playback.to_start", "Jump to Sequence Start"),
|
||||
("menu.playback.set_in_point", "Set In Point"),
|
||||
("menu.playback.set_out_point", "Set Out Point"),
|
||||
// --- Sequence ---
|
||||
("menu.sequence.add_video_track", "Add Video Track"),
|
||||
("menu.sequence.add_audio_track", "Add Audio Track"),
|
||||
@@ -328,11 +335,32 @@ const EN: &[(&str, &str)] = &[
|
||||
("file.open.title", "Open Project"),
|
||||
("file.import_footage.title", "Import Footage"),
|
||||
("preferences.title", "Preferences"),
|
||||
("preferences.section.general", "General"),
|
||||
("preferences.section.render", "Rendering"),
|
||||
("preferences.section.cache", "Cache"),
|
||||
("preferences.section.proxy", "Proxy"),
|
||||
("preferences.section.project", "Project"),
|
||||
("preferences.section.audio", "Audio"),
|
||||
("preferences.backend", "Renderer backend"),
|
||||
("preferences.backend.placeholder", "Select a backend…"),
|
||||
("preferences.language", "Language"),
|
||||
("preferences.language.placeholder", "Select a language…"),
|
||||
("preferences.hint", "The renderer backend applies to the render worker at the next launch; the language switches immediately."),
|
||||
("preferences.theme", "Theme"),
|
||||
("preferences.theme.dark", "Olive Dark"),
|
||||
("preferences.theme.light", "Olive Light"),
|
||||
("preferences.cache.dir", "Disk cache directory"),
|
||||
("preferences.cache.browse", "Browse…"),
|
||||
("preferences.proxy.enable", "Use proxy media"),
|
||||
("preferences.proxy.resolution", "Proxy resolution"),
|
||||
("preferences.proxy.full", "Full resolution"),
|
||||
("preferences.snapshot.interval", "Auto-save (snapshot) interval, seconds"),
|
||||
("preferences.transition.default", "Default transition length, seconds"),
|
||||
("preferences.audio.output", "Audio output device"),
|
||||
("preferences.audio.output.placeholder", "Select an output device…"),
|
||||
("preferences.audio.input", "Audio input device"),
|
||||
("preferences.audio.input.placeholder", "Select an input device…"),
|
||||
("preferences.audio.default", "System Default"),
|
||||
("preferences.hint", "The renderer backend applies to the render worker at the next launch; every other setting takes effect immediately and is saved on exit."),
|
||||
("export.title", "Export Sequence"),
|
||||
("export.format", "Format"),
|
||||
("export.format.placeholder", "Select a format…"),
|
||||
@@ -372,6 +400,7 @@ const ZH: &[(&str, &str)] = &[
|
||||
("menu.edit.paste", "粘贴"),
|
||||
("menu.edit.delete", "删除"),
|
||||
("menu.edit.ripple_delete", "波纹删除"),
|
||||
("menu.edit.select_all", "全选"),
|
||||
// --- View ---
|
||||
("menu.view.theme", "主题"),
|
||||
("menu.view.theme.dark", "Olive Dark"),
|
||||
@@ -379,12 +408,18 @@ const ZH: &[(&str, &str)] = &[
|
||||
("menu.view.language", "语言"),
|
||||
("menu.view.language.en", "English"),
|
||||
("menu.view.language.zh", "简体中文"),
|
||||
("menu.view.zoom_in", "放大"),
|
||||
("menu.view.zoom_out", "缩小"),
|
||||
("menu.view.preferences", "偏好设置…"),
|
||||
// --- Playback ---
|
||||
("menu.playback.play_pause", "播放/暂停"),
|
||||
("menu.playback.play", "播放"),
|
||||
("menu.playback.pause", "暂停"),
|
||||
("menu.playback.prev_frame", "上一帧"),
|
||||
("menu.playback.next_frame", "下一帧"),
|
||||
("menu.playback.to_start", "跳到序列起点"),
|
||||
("menu.playback.set_in_point", "设置入点"),
|
||||
("menu.playback.set_out_point", "设置出点"),
|
||||
// --- Sequence ---
|
||||
("menu.sequence.add_video_track", "添加视频轨道"),
|
||||
("menu.sequence.add_audio_track", "添加音频轨道"),
|
||||
@@ -507,13 +542,34 @@ const ZH: &[(&str, &str)] = &[
|
||||
("file.open.title", "打开项目"),
|
||||
("file.import_footage.title", "导入素材"),
|
||||
("preferences.title", "偏好设置"),
|
||||
("preferences.section.general", "常规"),
|
||||
("preferences.section.render", "渲染"),
|
||||
("preferences.section.cache", "缓存"),
|
||||
("preferences.section.proxy", "代理"),
|
||||
("preferences.section.project", "项目"),
|
||||
("preferences.section.audio", "音频"),
|
||||
("preferences.backend", "渲染后端"),
|
||||
("preferences.backend.placeholder", "选择一个后端…"),
|
||||
("preferences.language", "语言"),
|
||||
("preferences.language.placeholder", "选择语言…"),
|
||||
("preferences.theme", "主题"),
|
||||
("preferences.theme.dark", "Olive Dark"),
|
||||
("preferences.theme.light", "Olive Light"),
|
||||
("preferences.cache.dir", "磁盘缓存目录"),
|
||||
("preferences.cache.browse", "浏览…"),
|
||||
("preferences.proxy.enable", "使用代理媒体"),
|
||||
("preferences.proxy.resolution", "代理分辨率"),
|
||||
("preferences.proxy.full", "原始分辨率"),
|
||||
("preferences.snapshot.interval", "自动保存(快照)间隔(秒)"),
|
||||
("preferences.transition.default", "默认过渡时长(秒)"),
|
||||
("preferences.audio.output", "音频输出设备"),
|
||||
("preferences.audio.output.placeholder", "选择输出设备…"),
|
||||
("preferences.audio.input", "音频输入设备"),
|
||||
("preferences.audio.input.placeholder", "选择输入设备…"),
|
||||
("preferences.audio.default", "系统默认"),
|
||||
(
|
||||
"preferences.hint",
|
||||
"渲染后端在下次启动渲染工作进程时生效;语言立即切换。",
|
||||
"渲染后端在下次启动渲染工作进程时生效;其余设置立即生效,并在退出时保存。",
|
||||
),
|
||||
("export.title", "导出序列"),
|
||||
("export.format", "格式"),
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
//! * [`app`] — the window shell: menu bar, dock layout, status bar, modal
|
||||
//! dialogs (file open/save-as, preferences, export), tick loop.
|
||||
//! * [`dialogs`] — the preferences and export dialog content views.
|
||||
//! * [`shortcuts`] — the keyboard shortcut table (keystroke → menu action).
|
||||
//! * [`manager`] — the project manager window (M13 D4): the library browser
|
||||
//! with new / open / rename / duplicate / delete / import / export.
|
||||
//! * [`panels`] — the dockable panels (viewers, timeline, inspector, ...).
|
||||
@@ -56,6 +57,7 @@ pub mod i18n;
|
||||
pub mod manager;
|
||||
pub mod oakui;
|
||||
pub mod panels;
|
||||
pub mod shortcuts;
|
||||
|
||||
/// The application entry point (called from `main.rs`).
|
||||
pub fn run() {
|
||||
|
||||
@@ -290,6 +290,17 @@ unsafe extern "C" {
|
||||
) -> c_int;
|
||||
/// `oakengine_config_set_string` — write a string value.
|
||||
pub fn oakengine_config_set_string(key: *const c_char, value: *const c_char) -> c_int;
|
||||
/// `oakengine_config_get_int` — read an integer value (fallback when the
|
||||
/// key is missing or not convertible).
|
||||
pub fn oakengine_config_get_int(key: *const c_char, default_value: i64) -> i64;
|
||||
/// `oakengine_config_set_int` — write an integer value.
|
||||
pub fn oakengine_config_set_int(key: *const c_char, value: i64) -> c_int;
|
||||
/// `oakengine_config_load` — load the configuration from disk (the app
|
||||
/// calls it once at startup, before reading any preference).
|
||||
pub fn oakengine_config_load() -> c_int;
|
||||
/// `oakengine_config_save` — persist the configuration to disk (the app
|
||||
/// calls it on exit).
|
||||
pub fn oakengine_config_save() -> c_int;
|
||||
|
||||
// -- oakengine::storage (write-through session state) --
|
||||
|
||||
@@ -1011,6 +1022,39 @@ unsafe extern "C" {
|
||||
/// buffered output into `peaks` (up to `capacity` entries); returns
|
||||
/// the channel count (0 = nothing buffered), negative on error.
|
||||
pub fn oakengine_audio_output_levels(peaks: *mut f32, capacity: c_int) -> c_int;
|
||||
/// `oakengine_audio_create_instance` — create the AudioManager singleton
|
||||
/// (no-op when it exists). The app calls it once at startup so playback
|
||||
/// can open an output stream.
|
||||
pub fn oakengine_audio_create_instance() -> c_int;
|
||||
/// `oakengine_audio_get_output_device` — the output device index
|
||||
/// (-1 = none/default).
|
||||
pub fn oakengine_audio_get_output_device() -> i64;
|
||||
/// `oakengine_audio_set_output_device` — set the output device index;
|
||||
/// the stream reopens on the next pushed samples.
|
||||
pub fn oakengine_audio_set_output_device(device: i64) -> c_int;
|
||||
/// `oakengine_audio_get_input_device` — the input device index.
|
||||
pub fn oakengine_audio_get_input_device() -> i64;
|
||||
/// `oakengine_audio_set_input_device` — set the input device index.
|
||||
pub fn oakengine_audio_set_input_device(device: i64) -> c_int;
|
||||
/// `oakengine_audio_output_device_count` — the host's output device
|
||||
/// count (enumeration order == device index).
|
||||
pub fn oakengine_audio_output_device_count() -> c_int;
|
||||
/// `oakengine_audio_output_device_name` — the name of output device
|
||||
/// `index` (buf/size; the length excludes the NUL).
|
||||
pub fn oakengine_audio_output_device_name(
|
||||
index: c_int,
|
||||
buf: *mut c_char,
|
||||
buf_size: c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_audio_input_device_count` — the host's input device count.
|
||||
pub fn oakengine_audio_input_device_count() -> c_int;
|
||||
/// `oakengine_audio_input_device_name` — the name of input device
|
||||
/// `index` (buf/size).
|
||||
pub fn oakengine_audio_input_device_name(
|
||||
index: c_int,
|
||||
buf: *mut c_char,
|
||||
buf_size: c_int,
|
||||
) -> c_int;
|
||||
|
||||
// -- oakengine::render (manager lifecycle) --
|
||||
|
||||
|
||||
@@ -495,6 +495,11 @@ pub struct MockEngine {
|
||||
/// The enabled work area (render/export in/out range) of the demo
|
||||
/// sequence, in sequence frames (M12 P4). `None` = disabled.
|
||||
workarea: Option<(Frame, Frame)>,
|
||||
/// Undo/redo call counts (test observability; the mock keeps no undo
|
||||
/// stack, so the counters are the only way to see the dispatch landed).
|
||||
undo_calls: u64,
|
||||
/// See [`MockEngine::undo_calls`].
|
||||
redo_calls: u64,
|
||||
}
|
||||
|
||||
impl MockEngine {
|
||||
@@ -744,6 +749,8 @@ impl MockEngine {
|
||||
library_exported: Vec::new(),
|
||||
markers: Vec::new(),
|
||||
workarea: None,
|
||||
undo_calls: 0,
|
||||
redo_calls: 0,
|
||||
};
|
||||
// The demo graph is born connected: derive every port's `connected`
|
||||
// flag from the edge list.
|
||||
@@ -1312,11 +1319,13 @@ impl AppEngine for MockEngine {
|
||||
|
||||
fn undo(&mut self, cx: &mut Context<Self>) {
|
||||
println!("[mock engine] undo: no undo stack in mock mode");
|
||||
self.undo_calls += 1;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn redo(&mut self, cx: &mut Context<Self>) {
|
||||
println!("[mock engine] redo: no undo stack in mock mode");
|
||||
self.redo_calls += 1;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -1650,6 +1659,11 @@ impl MockEngine {
|
||||
&self.imported_footage
|
||||
}
|
||||
|
||||
/// The undo/redo call counts (test observability; see the fields).
|
||||
pub fn undo_redo_calls(&self) -> (u64, u64) {
|
||||
(self.undo_calls, self.redo_calls)
|
||||
}
|
||||
|
||||
/// The uuids opened via [`AppEngine::library_open_project`] so far
|
||||
/// (mock state; drives app-level tests of the manager's open flow).
|
||||
pub fn library_opened(&self) -> &[String] {
|
||||
|
||||
+1024
-21
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,198 @@
|
||||
// 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 keyboard shortcut table (M12 P5c): a flat keystroke → menu-action
|
||||
//! map, so a key press and a menu click dispatch the SAME action id through
|
||||
//! `OakApp::on_menu` (the menu bar's `with_shortcut` labels mirror the
|
||||
//! `display` strings here).
|
||||
//!
|
||||
//! The set follows the C++ Olive layout: space toggles playback, J/K/L form
|
||||
//! the shuttle (J steps back — true reverse playback is an engine transport
|
||||
//! gap), I/O set the work-area in/out points at the playhead, S splits, A
|
||||
//! selects all, Delete / Shift-Delete delete (gap / ripple), and the
|
||||
//! platform-modifier file/edit shortcuts use `secondary` (⌘ on macOS, Ctrl
|
||||
//! elsewhere).
|
||||
//!
|
||||
//! The table is plain data (no gpui keymap contexts): `action_for` matches
|
||||
//! a [`gpui::Keystroke`] against it, and the shell's root key listener does
|
||||
//! the dispatch. While a modal dialog is open the shell skips the table
|
||||
//! entirely, so the dialogs' text fields never trigger editing actions.
|
||||
|
||||
use gpui::Keystroke;
|
||||
|
||||
use crate::app::menu_ids;
|
||||
|
||||
/// One shortcut entry: the gpui keystroke pattern (the
|
||||
/// [`Keystroke::parse`] syntax), the menu action it dispatches, and the
|
||||
/// label the menus show.
|
||||
pub struct Shortcut {
|
||||
/// The keystroke pattern, e.g. `"secondary-z"` or `"space"`.
|
||||
pub keystroke: &'static str,
|
||||
/// The dispatched menu action id (`crate::app::menu_ids`).
|
||||
pub action: usize,
|
||||
/// The menu label for the keystroke (display only).
|
||||
pub display: &'static str,
|
||||
}
|
||||
|
||||
/// The shortcut table, in menu order. `secondary` is the platform command
|
||||
/// modifier (⌘ on macOS, Ctrl on Windows/Linux).
|
||||
pub const SHORTCUTS: &[Shortcut] = &[
|
||||
// --- File ---
|
||||
Shortcut { keystroke: "secondary-n", action: menu_ids::NEW_PROJECT, display: "⌘N" },
|
||||
Shortcut { keystroke: "secondary-o", action: menu_ids::OPEN_PROJECT, display: "⌘O" },
|
||||
Shortcut { keystroke: "secondary-s", action: menu_ids::EXPORT_PROJECT, display: "⌘S" },
|
||||
Shortcut { keystroke: "secondary-e", action: menu_ids::EXPORT, display: "⌘E" },
|
||||
Shortcut { keystroke: "secondary-q", action: menu_ids::QUIT, display: "⌘Q" },
|
||||
// --- Edit ---
|
||||
Shortcut { keystroke: "secondary-z", action: menu_ids::UNDO, display: "⌘Z" },
|
||||
Shortcut { keystroke: "secondary-shift-z", action: menu_ids::REDO, display: "⇧⌘Z" },
|
||||
Shortcut { keystroke: "secondary-x", action: menu_ids::CUT, display: "⌘X" },
|
||||
Shortcut { keystroke: "secondary-c", action: menu_ids::COPY, display: "⌘C" },
|
||||
Shortcut { keystroke: "secondary-v", action: menu_ids::PASTE, display: "⌘V" },
|
||||
Shortcut { keystroke: "backspace", action: menu_ids::DELETE, display: "⌫" },
|
||||
Shortcut { keystroke: "delete", action: menu_ids::DELETE, display: "⌫" },
|
||||
Shortcut { keystroke: "shift-backspace", action: menu_ids::RIPPLE_DELETE, display: "⇧⌫" },
|
||||
Shortcut { keystroke: "shift-delete", action: menu_ids::RIPPLE_DELETE, display: "⇧⌫" },
|
||||
Shortcut { keystroke: "a", action: menu_ids::SELECT_ALL, display: "A" },
|
||||
Shortcut { keystroke: "secondary-a", action: menu_ids::SELECT_ALL, display: "⌘A" },
|
||||
// --- View ---
|
||||
// Zoom-in covers both the unshifted "=" key and the shifted "+" (gpui
|
||||
// reports the base key with the shift modifier on most layouts; the
|
||||
// bare "+" catches layouts that report the shifted character).
|
||||
Shortcut { keystroke: "=", action: menu_ids::ZOOM_IN, display: "+" },
|
||||
Shortcut { keystroke: "shift-=", action: menu_ids::ZOOM_IN, display: "+" },
|
||||
Shortcut { keystroke: "+", action: menu_ids::ZOOM_IN, display: "+" },
|
||||
Shortcut { keystroke: "-", action: menu_ids::ZOOM_OUT, display: "-" },
|
||||
Shortcut { keystroke: "secondary-,", action: menu_ids::PREFERENCES, display: "⌘," },
|
||||
// --- Playback ---
|
||||
Shortcut { keystroke: "space", action: menu_ids::PLAY_PAUSE, display: "空格" },
|
||||
Shortcut { keystroke: "left", action: menu_ids::PREV_FRAME, display: "←" },
|
||||
Shortcut { keystroke: "right", action: menu_ids::NEXT_FRAME, display: "→" },
|
||||
Shortcut { keystroke: "home", action: menu_ids::TO_START, display: "Home" },
|
||||
// The J/K/L shuttle: J steps back (true reverse playback is an engine
|
||||
// transport gap), K pauses, L plays.
|
||||
Shortcut { keystroke: "j", action: menu_ids::PREV_FRAME, display: "J" },
|
||||
Shortcut { keystroke: "k", action: menu_ids::PAUSE, display: "K" },
|
||||
Shortcut { keystroke: "l", action: menu_ids::PLAY, display: "L" },
|
||||
// --- Sequence ---
|
||||
Shortcut { keystroke: "s", action: menu_ids::SPLIT_AT_PLAYHEAD, display: "S" },
|
||||
Shortcut { keystroke: "i", action: menu_ids::SET_IN_POINT, display: "I" },
|
||||
Shortcut { keystroke: "o", action: menu_ids::SET_OUT_POINT, display: "O" },
|
||||
Shortcut { keystroke: "m", action: menu_ids::ADD_MARKER, display: "M" },
|
||||
];
|
||||
|
||||
/// The parsed table (lazily built once; every pattern is a compile-time
|
||||
/// constant, so a parse failure is a bug the tests below catch).
|
||||
fn parsed() -> &'static Vec<(Keystroke, usize)> {
|
||||
static TABLE: std::sync::OnceLock<Vec<(Keystroke, usize)>> = std::sync::OnceLock::new();
|
||||
TABLE.get_or_init(|| {
|
||||
SHORTCUTS
|
||||
.iter()
|
||||
.map(|s| {
|
||||
(
|
||||
Keystroke::parse(s.keystroke)
|
||||
.unwrap_or_else(|_| panic!("invalid shortcut keystroke {:?}", s.keystroke)),
|
||||
s.action,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// The menu action bound to `keystroke`, if any. Matching is exact on the
|
||||
/// key and the full modifier set (a shortcut with no modifiers does not
|
||||
/// fire when shift is held, so shifted typing never triggers edits).
|
||||
pub fn action_for(keystroke: &Keystroke) -> Option<usize> {
|
||||
parsed()
|
||||
.iter()
|
||||
.find(|(k, _)| k.key == keystroke.key && k.modifiers == keystroke.modifiers)
|
||||
.map(|(_, action)| *action)
|
||||
}
|
||||
|
||||
/// The display label for an action's first shortcut (the menus' source).
|
||||
pub fn display_for(action: usize) -> Option<&'static str> {
|
||||
SHORTCUTS
|
||||
.iter()
|
||||
.find(|s| s.action == action)
|
||||
.map(|s| s.display)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every keystroke in the table parses (the table is static data, so a
|
||||
/// typo would otherwise only surface as a dead shortcut at runtime).
|
||||
#[test]
|
||||
fn every_shortcut_keystroke_parses() {
|
||||
for shortcut in SHORTCUTS {
|
||||
assert!(
|
||||
Keystroke::parse(shortcut.keystroke).is_ok(),
|
||||
"invalid keystroke {:?}",
|
||||
shortcut.keystroke
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The main key bindings map to the documented actions (space playback,
|
||||
/// J/K/L shuttle, I/O points, S split, A select-all, delete flavors,
|
||||
/// undo/redo, the file shortcuts and the track zoom).
|
||||
#[test]
|
||||
fn main_keys_dispatch_their_actions() {
|
||||
let action = |pattern: &str| action_for(&Keystroke::parse(pattern).unwrap());
|
||||
|
||||
assert_eq!(action("space"), Some(menu_ids::PLAY_PAUSE));
|
||||
assert_eq!(action("j"), Some(menu_ids::PREV_FRAME));
|
||||
assert_eq!(action("k"), Some(menu_ids::PAUSE));
|
||||
assert_eq!(action("l"), Some(menu_ids::PLAY));
|
||||
assert_eq!(action("i"), Some(menu_ids::SET_IN_POINT));
|
||||
assert_eq!(action("o"), Some(menu_ids::SET_OUT_POINT));
|
||||
assert_eq!(action("s"), Some(menu_ids::SPLIT_AT_PLAYHEAD));
|
||||
assert_eq!(action("a"), Some(menu_ids::SELECT_ALL));
|
||||
assert_eq!(action("secondary-a"), Some(menu_ids::SELECT_ALL));
|
||||
assert_eq!(action("backspace"), Some(menu_ids::DELETE));
|
||||
assert_eq!(action("shift-backspace"), Some(menu_ids::RIPPLE_DELETE));
|
||||
assert_eq!(action("secondary-z"), Some(menu_ids::UNDO));
|
||||
assert_eq!(action("secondary-shift-z"), Some(menu_ids::REDO));
|
||||
assert_eq!(action("secondary-n"), Some(menu_ids::NEW_PROJECT));
|
||||
assert_eq!(action("secondary-o"), Some(menu_ids::OPEN_PROJECT));
|
||||
assert_eq!(action("secondary-s"), Some(menu_ids::EXPORT_PROJECT));
|
||||
assert_eq!(action("="), Some(menu_ids::ZOOM_IN));
|
||||
assert_eq!(action("-"), Some(menu_ids::ZOOM_OUT));
|
||||
assert_eq!(action("m"), Some(menu_ids::ADD_MARKER));
|
||||
assert_eq!(action("left"), Some(menu_ids::PREV_FRAME));
|
||||
assert_eq!(action("right"), Some(menu_ids::NEXT_FRAME));
|
||||
assert_eq!(action("home"), Some(menu_ids::TO_START));
|
||||
assert_eq!(action("secondary-,"), Some(menu_ids::PREFERENCES));
|
||||
}
|
||||
|
||||
/// A shortcut without modifiers must not fire while shift is held (so
|
||||
/// shifted keys — e.g. typing capitals — never trigger edits).
|
||||
#[test]
|
||||
fn unmodified_shortcuts_ignore_extra_modifiers() {
|
||||
let shifted = Keystroke::parse("shift-s").unwrap();
|
||||
assert_eq!(action_for(&shifted), None);
|
||||
let cmd = Keystroke::parse("secondary-i").unwrap();
|
||||
assert_eq!(action_for(&cmd), None);
|
||||
}
|
||||
|
||||
/// Unbound keys map to nothing.
|
||||
#[test]
|
||||
fn unbound_keys_map_to_nothing() {
|
||||
assert_eq!(action_for(&Keystroke::parse("f1").unwrap()), None);
|
||||
assert_eq!(action_for(&Keystroke::parse("x").unwrap()), None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user