oakui/component gains the two app-facing components: text_input: the app's text field — the gpui_elements editing engine (IME composition, caret, selection, undo) wrapped with the app theme's colors (text via a text_color refinement on the wrapping div so the engine's run layout picks it up; selection/caret/placeholder/IME-marked directly) — and install_text_input_bindings(), which binds the Backspace/Delete/arrow/Home/End/select-all editing keys into the app keymap scoped to the EditableText context. The app never installed them before, so every field accepted IME text but ignored its editing keys; the bindings are now wired once at OakApp::new. All seven call sites (ofx_params x2, effect_library, manager, dialogs x3) use the component instead of gpui_elements directly. menu: all app menu code consolidates here — the model types are re-exported, the shared context-menu plumbing (ContextMenuHandle, ContextMenuTriggered) and the shared segments (edit/clip-edit/in-out/ color-label/new, the viewer context menu, the dynamic language menu) move in from src/menus, which is deleted; app.rs and every panel import from the component. Inline fully-qualified crate paths in non-use positions are replaced with use imports.
273 lines
8.6 KiB
Rust
273 lines
8.6 KiB
Rust
// 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, MouseButton,
|
|
Render, SharedString, Window,
|
|
};
|
|
use gpui_elements::editable_text::{EditableTextState, StringStorage, TextChanged};
|
|
use crate::oakui::component::text_input;
|
|
|
|
use crate::i18n;
|
|
use crate::oakui::AppEngine;
|
|
use crate::panels::commands::PanelCommandHandler;
|
|
use crate::panels::ids::EFFECT_LIBRARY;
|
|
|
|
/// The effect library panel.
|
|
pub struct EffectLibraryPanel<E: AppEngine> {
|
|
engine: Entity<E>,
|
|
/// The search box state: live-filters the list by name / type id
|
|
/// (case-insensitive substring).
|
|
search: Entity<EditableTextState>,
|
|
}
|
|
|
|
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();
|
|
let search = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
|
|
cx.subscribe(&search, |_this, _state, _event: &TextChanged, cx| {
|
|
cx.notify();
|
|
})
|
|
.detach();
|
|
Self { engine, search }
|
|
}
|
|
}
|
|
|
|
/// The effect library implements no focused-panel commands: everything
|
|
/// falls through to the shell's global handler.
|
|
impl<E: AppEngine> PanelCommandHandler for EffectLibraryPanel<E> {}
|
|
|
|
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 query = self.search.read(cx).as_str().trim().to_lowercase();
|
|
|
|
let mut list = div()
|
|
.id("effect-library-list")
|
|
.flex_1()
|
|
.min_h_0()
|
|
.flex()
|
|
.flex_col()
|
|
.gap_1()
|
|
.p_2()
|
|
.overflow_y_scroll();
|
|
|
|
// Built-in effects render flat under a Built-in header; OpenFX
|
|
// plugin entries are grouped under their sub-category header
|
|
// (Filter / Generator / Transition / General — the C++
|
|
// `factorymenu` OpenFX branch). The engine table arrives sorted
|
|
// (built-ins first, then groups and names alphabetically); the
|
|
// search box live-filters by name / type id.
|
|
let mut last_group: Option<Option<String>> = None;
|
|
for entry in &effects {
|
|
if !query.is_empty()
|
|
&& !entry.name.to_lowercase().contains(&query)
|
|
&& !entry.type_id.to_lowercase().contains(&query)
|
|
{
|
|
continue;
|
|
}
|
|
let group_key = entry.group.clone();
|
|
if last_group.as_ref() != Some(&group_key) {
|
|
last_group = Some(group_key);
|
|
let label = match &entry.group {
|
|
Some(group) => group.clone(),
|
|
None => i18n::tr("effect_library.group.builtin").to_string(),
|
|
};
|
|
list = list.child(group_header(&colors, &label));
|
|
}
|
|
let engine = self.engine.clone();
|
|
let row_id = entry.type_id.clone();
|
|
let name = entry.name.clone();
|
|
let type_id = entry.type_id.clone();
|
|
let drag_payload = gpui::effect_stack::LibraryEffectDrag {
|
|
type_id: SharedString::from(entry.type_id.clone()),
|
|
name: SharedString::from(entry.name.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)
|
|
// Drag the effect onto the inspector's effect stack (or
|
|
// the node editor) to add it there; double-click adds it
|
|
// to the selected clip's chain end.
|
|
.on_drag(drag_payload, |payload, _origin, _window, cx| {
|
|
cx.new(|_cx| EffectDragGhost {
|
|
name: payload.name.clone(),
|
|
})
|
|
})
|
|
.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()
|
|
// Any click inside the panel makes it the focused panel (the
|
|
// dock re-emits this as `DockEvent::PanelFocused`, which the
|
|
// shell uses to route focused-panel commands).
|
|
.on_mouse_down(MouseButton::Left, {
|
|
cx.listener(|_this, _event: &gpui::MouseDownEvent, _window, cx| {
|
|
cx.emit(PanelEvent::Focused);
|
|
})
|
|
})
|
|
.child(
|
|
div()
|
|
.flex_shrink_0()
|
|
.p_2()
|
|
.border_b_1()
|
|
.border_color(colors.border)
|
|
.child(
|
|
text_input("effect-library-search", cx)
|
|
.state(self.search.downgrade())
|
|
.accepts_input(true),
|
|
),
|
|
)
|
|
.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")),
|
|
)
|
|
}
|
|
}
|
|
|
|
/// The sub-category header row of the OpenFX group (a muted, all-caps
|
|
/// line above the plugin entries).
|
|
fn group_header(colors: &gpui::colors::Colors, group: &str) -> impl IntoElement {
|
|
div()
|
|
.id(SharedString::from(format!("effect-library-group-{group}")))
|
|
.pt_2()
|
|
.pb_1()
|
|
.px_2()
|
|
.text_xs()
|
|
.font_weight(gpui::FontWeight(600.0))
|
|
.text_color(colors.disabled)
|
|
.child(group.to_string())
|
|
}
|
|
|
|
/// The drag ghost shown under the pointer while an effect is dragged out
|
|
/// of the library (a small floating label with the effect name).
|
|
struct EffectDragGhost {
|
|
name: SharedString,
|
|
}
|
|
|
|
impl Render for EffectDragGhost {
|
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
let colors = cx.default_colors().clone();
|
|
div()
|
|
.px_2()
|
|
.py_1()
|
|
.rounded_sm()
|
|
.border_1()
|
|
.border_color(colors.border)
|
|
.bg(colors.container)
|
|
.text_sm()
|
|
.text_color(colors.text)
|
|
.child(self.name.clone())
|
|
}
|
|
}
|
|
|
|
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 entry in &expected {
|
|
let type_id = &entry.type_id;
|
|
// `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"
|
|
);
|
|
}
|
|
}
|
|
}
|