feat(app): track-header toggles, effect library, panel titles (design parity)

- track headers: name + visible/mute/lock toggles, undoable through
  graphops, honored by the montage (muted tracks are skipped) and by
  the edit guards (locked tracks reject trims/moves/deletes)
- project explorer gains a panel title row; the tree/icons toggle is
  now small icon buttons with tooltips
- new effect library dock panel (every addable effect; double-click
  appends to the selected clip's chain)
- screenshots refreshed (zh/en, window/manager/preferences)
This commit is contained in:
2026-08-17 01:37:31 +08:00
parent c5f1d0d76c
commit 21cc2fba24
15 changed files with 749 additions and 9 deletions
+162
View File
@@ -0,0 +1,162 @@
// 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 effect library panel (效果库): every effect type the engine can add
//! to a clip's chain, as a flat list. Double-clicking an entry appends the
//! effect to the selected clip's effect chain (the undoable
//! [`AppEngine::add_effect`]; the insertion index is clamped to the chain
//! end by the backend).
use gpui::colors::DefaultColors;
use gpui::dock::{DockPanel, PanelEvent};
use gpui::{
div, prelude::*, AnyElement, App, ClickEvent, Context, Entity, EventEmitter, Render,
SharedString, Window,
};
use crate::i18n;
use crate::oakui::AppEngine;
use crate::panels::ids::EFFECT_LIBRARY;
/// The effect library panel.
pub struct EffectLibraryPanel<E: AppEngine> {
engine: Entity<E>,
}
impl<E: AppEngine> EffectLibraryPanel<E> {
/// Builds the panel over `engine`'s addable-effect table.
pub fn new(engine: Entity<E>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
// Re-read the effect table whenever the engine notifies (the table
// itself is static, but the selection hint depends on the target).
cx.observe(&engine, |_this, _engine, cx| cx.notify()).detach();
Self { engine }
}
}
impl<E: AppEngine> Render for EffectLibraryPanel<E> {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let effects = self.engine.read(cx).addable_effects();
let mut list = div()
.id("effect-library-list")
.flex_1()
.min_h_0()
.flex()
.flex_col()
.gap_1()
.p_2()
.overflow_y_scroll();
for (type_id, name) in effects {
let engine = self.engine.clone();
let row_id = type_id.clone();
list = list.child(
div()
.id(SharedString::from(format!("effect-library-{type_id}")))
.debug_selector(move || format!("effect-library-row-{row_id}").into())
.cursor_pointer()
.px_2()
.py_1()
.rounded_sm()
.text_sm()
.text_color(colors.text)
.hover(|style| style.bg(colors.selected))
.child(name)
.on_click(move |event: &ClickEvent, _window, cx| {
// Double-click appends the effect to the selected
// clip's chain; the backend clamps the index to the
// chain end.
if event.click_count() == 2 {
let type_id = type_id.clone();
engine.update(cx, |engine, cx| {
if let Err(err) = engine.add_effect(usize::MAX, &type_id, cx) {
println!("[effect library] add effect failed: {err}");
}
});
}
}),
);
}
div()
.size_full()
.flex()
.flex_col()
.child(list)
.child(
div()
.flex_shrink_0()
.px_2()
.py_1()
.border_t_1()
.border_color(colors.border)
.text_xs()
.text_color(colors.disabled)
.child(i18n::tr("effect_library.hint")),
)
}
}
impl<E: AppEngine> EventEmitter<PanelEvent> for EffectLibraryPanel<E> {}
impl<E: AppEngine> DockPanel for EffectLibraryPanel<E> {
fn panel_id(&self) -> gpui::dock::PanelId {
EFFECT_LIBRARY
}
fn title(&self, _cx: &App) -> SharedString {
i18n::tr("panel.effect_library").into()
}
fn tab_content(&self, _cx: &App) -> AnyElement {
div()
.child(i18n::tr("panel.effect_library"))
.into_any_element()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::oakui::MockEngine;
use gpui::{px, size, TestAppContext, VisualTestContext};
/// The panel renders one row per addable effect of the engine (the
/// mock exposes the real factory's video-effect table).
#[gpui::test]
async fn lists_every_addable_effect(cx: &mut TestAppContext) {
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(400.0), px(600.0)), |window, cx| {
let engine = cx.new(|cx| MockEngine::demo(cx));
EffectLibraryPanel::new(engine, window, cx)
});
cx.run_until_parked();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
let expected = crate::oakui::effectchain::addable_effects();
assert!(!expected.is_empty());
for (type_id, _) in &expected {
// `debug_bounds` takes a &'static selector; the per-row selector is
// dynamic, so the test leaks it (process-lifetime, test-only).
let selector: &'static str =
Box::leak(format!("effect-library-row-{type_id}").into_boxed_str());
assert!(
cx.debug_bounds(selector).is_some(),
"effect row {type_id} rendered"
);
}
}
}