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:
2026-08-16 18:05:03 +08:00
parent 466cdcb7d7
commit e346ea5338
17 changed files with 2237 additions and 72 deletions
+7 -1
View File
@@ -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
+47 -1
View File
@@ -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 {
+10
View File
@@ -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

+8 -7
View File
@@ -25,9 +25,10 @@
- **扁平化现代 UI**,图标用 C++ 版素材(assets/icons/{dark,light}
16px 网格),双主题,中英双语(src/i18n.rs + gpui i18n 钩子)。
- **平台**macOSApple Silicon)优先;Linux 链接已通(build.rs
rpath+--export-dynamic);Windows 卡在 DLL 不允许未定义符号
oakcore_* 宿主导入),需 stub import lib 或 delay-load(见
§5 风险表)。
rpath+--export-dynamic);Windows DLL 未定义符号问题已解决
M12 P5oakcore_audioparams_* 收编进 dylibDLL 无未定义符号,
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 P5oakcore_audioparams_* 收编进 dylib);剩余为 app 侧 win32gpui | 根 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_* 已收编进 dylibM12 P5DLL 未定义符号
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 libCI 先保 Linux/macOS |
| Windows DLL 未定义符号oakcore_* 宿主导入) | Windows 无法出包 | 已解决(M12 P5):oakcore_audioparams_* 收编进 dylibDLL 无未定义符号CI 先保 Linux/macOS |
| gpui fork 与上游分叉扩大 | 维护成本 | 只在 gpui_widgets 层扩展;上游同步按季度评审 |
| 音频回调实时性(PortAudio 回调里禁锁/分配) | 爆音 | 回调只读写无锁环形缓冲;oakaudio PreviewAudioDevice 已是此形态 |
| OFX 插件 GUI(方案 2)交互延迟 | 插件调参手感 | P5 后单独立项评审(M11 第 4 期) |
+26
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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", "格式"),
+2
View File
@@ -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() {
+44
View File
@@ -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) --
+14
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+198
View File
@@ -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);
}
}