// 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 . //! 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 { engine: Entity, /// The search box state: live-filters the list by name / type id /// (case-insensitive substring). search: Entity, } impl EffectLibraryPanel { /// Builds the panel over `engine`'s addable-effect table. pub fn new(engine: Entity, _window: &mut Window, cx: &mut Context) -> 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 PanelCommandHandler for EffectLibraryPanel {} impl Render for EffectLibraryPanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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> = 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) -> 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 EventEmitter for EffectLibraryPanel {} impl DockPanel for EffectLibraryPanel { 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" ); } } }