feat(app): OpenFX UI wiring - effect library, inspector params, startup glue

- src/oakui/ofx.rs: startup sequence (host scan, register_plugin_nodes,
  progress reporter factory -> app progress dialog channel, active
  viewer time provider, project extent sync); all failures degrade to
  logs. oak-worker runtime also scans and registers plugins.
- Effect library groups OpenFX entries by sub-category (Filter/
  Generator/Transition/General); effect insertion goes through
  Factory::create_any so dynamic plugin nodes resolve.
- Inspector renders OFX parameters from node inputs (sliders, combo
  boxes from repeated combo_option/combo_value properties, vec/color
  spinboxes, text with explicit commit, push buttons), edits are
  undoable; persistent plugin messages surface as a card badge.
- oakplugin: push_button_clicked and per-instance persistent message
  counting (thin public layers).
This commit is contained in:
2026-08-18 22:16:08 +08:00
parent d61acb9e0a
commit ec7b7e6d13
17 changed files with 1733 additions and 55 deletions
+90 -1
View File
@@ -35,7 +35,8 @@
//! ```
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use gpui::dock::{
@@ -108,6 +109,9 @@ mod modal_ids {
pub const MANAGER_RENAME: usize = 7;
pub const MANAGER_DELETE: usize = 8;
pub const PROXY: usize = 9;
/// The OFX plugin progress dialog (driven by the plugin-progress
/// channel in the tick loop).
pub const PLUGIN_PROGRESS: usize = 10;
}
/// What a picked platform-dialog path should do.
@@ -295,6 +299,15 @@ pub struct OakApp<E: AppEngine> {
shell_focus: gpui::FocusHandle,
/// The running export session, if any.
export: Option<ExportRun>,
/// Whether the progress modal currently on screen is the OFX plugin
/// progress dialog (as opposed to the export progress). Guards
/// [`poll_plugin_progress`] from hijacking the export's bar.
plugin_progress_open: bool,
/// The receiving half of the OFX plugin-progress channel (the sending
/// half is registered into the oakplugin progress suite by
/// [`crate::oakui::ofx::set_progress_tx`]). Drained in the tick loop to
/// drive the progress dialog.
plugin_progress_rx: Mutex<mpsc::Receiver<crate::oakui::ofx::PluginProgressEvent>>,
/// The library row pending an export save dialog (manager 导出).
pending_export: Option<String>,
/// The panel that most recently took focus (the target of
@@ -606,6 +619,12 @@ impl<E: AppEngine> OakApp<E> {
// The shell starts focused so the action dispatch layer works before
// any panel grabs focus.
window.focus(&shell_focus, cx);
// OFX plugin-progress channel: the oakplugin progress suite pushes
// (label, message, fraction) events here; the tick loop drives the
// progress dialog.
let (plugin_progress_tx, plugin_progress_rx) =
mpsc::channel::<crate::oakui::ofx::PluginProgressEvent>();
crate::oakui::ofx::set_progress_tx(plugin_progress_tx);
let shell = Self {
engine,
program_clock,
@@ -619,6 +638,8 @@ impl<E: AppEngine> OakApp<E> {
shell_focus,
export: None,
pending_export: None,
plugin_progress_open: false,
plugin_progress_rx: Mutex::new(plugin_progress_rx),
focused_panel: None,
panels,
active_tool: Tool::Pointer,
@@ -655,9 +676,58 @@ impl<E: AppEngine> OakApp<E> {
.update(cx, |timeline, _| timeline.state.work_area = work_area.map(|(s, e)| FrameRange::new(s, e)));
self.meter.update(cx, |meter, cx| meter.update(cx));
self.poll_export(cx);
self.poll_plugin_progress(cx);
cx.notify();
}
/// Drains the OFX plugin-progress channel: the first event of a render
/// opens the progress dialog, subsequent events update the bar, and the
/// dialog closes when the fraction reaches 1.0 (the plugin's
/// progressEnd is not surfaced by the reporter, so completion is
/// inferred from the fraction).
fn poll_plugin_progress(&mut self, cx: &mut Context<Self>) {
let mut events = Vec::new();
while let Ok(event) = self.plugin_progress_rx.lock().unwrap().try_recv() {
events.push(event);
}
if events.is_empty() {
return;
}
let last = events.last().cloned().unwrap_or_default();
// Ensure the plugin progress dialog is on screen. If another modal
// is already up (e.g. export progress), leave it alone.
if !self.plugin_progress_open && matches!(self.modal, ModalState::None) {
let title = crate::i18n::tr("ofx.progress.title");
let label = last.label.clone();
let message = last.message.clone();
self.spawn_modal(cx, move |window, app| {
let (modal, content) = progress_dialog(
modal_ids::PLUGIN_PROGRESS,
title,
format!("{label}\n{message}"),
window,
app,
);
ModalState::Progress { modal, content }
});
self.plugin_progress_open = true;
}
if self.plugin_progress_open {
if let ModalState::Progress { content, .. } = &self.modal {
let fraction = last.fraction as f32;
content.update(cx, |content, cx| content.set_progress(fraction, cx));
}
// The reporter answers false after the user cancels, and the
// fraction reaches 1.0 when the render completes; either way the
// dialog is dismissed.
if last.fraction >= 1.0 {
self.modal = ModalState::None;
self.plugin_progress_open = false;
cx.notify();
}
}
}
/// Routes a menu click: resolves the item id to its registry action and
/// dispatches it through the same path the keyboard shortcuts use, so a
/// menu click and a key press can never diverge.
@@ -1855,6 +1925,16 @@ impl<E: AppEngine> OakApp<E> {
self.cancel_export(cx);
}
}
modal_ids::PLUGIN_PROGRESS => {
if *button == 1 {
// Cancel the plugin render; the reporter then answers
// false and the render aborts at the next progress
// update (or the dialog closes on the 1.0 fraction).
crate::oakui::ofx::cancel_plugin_render();
self.plugin_progress_open = false;
self.close_modal(cx);
}
}
modal_ids::PREFERENCES => {
self.commit_preferences(cx);
self.close_modal(cx);
@@ -2273,6 +2353,15 @@ fn run_with<E: AppEngine>(args: AppArgs) {
// 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();
// Stage 6b: wire the optional OFX plugin host — scan the standard
// plugin paths, register every discovered plugin into the node
// factory (effect library / add-effect menu), install the render
// executor, and register the progress / viewer-time bridges. All
// failures degrade to logs; plugins are optional.
let plugin_count = crate::oakui::ofx::init();
if plugin_count > 0 {
println!("[ofx] registered {plugin_count} OFX plugin node type(s)");
}
cx.init_colors();
let bounds = Bounds::centered(None, size(px(1600.0), px(900.0)), cx);
let initial = initial.clone();
+6
View File
@@ -418,6 +418,9 @@ const EN: &[(&str, &str)] = &[
("effect_stack.add", "+ Add Effect"),
// --- inspector ---
("inspector.params", "Parameters (placeholder)"),
("inspector.badge.openfx", "OpenFX"),
// --- OpenFX progress ---
("ofx.progress.title", "OpenFX Plugin Progress"),
// --- dialogs ---
("dialog.cancel", "Cancel"),
("dialog.close", "Close"),
@@ -852,6 +855,9 @@ const ZH: &[(&str, &str)] = &[
("effect_stack.add", "+ 添加效果"),
// --- inspector ---
("inspector.params", "参数(占位)"),
("inspector.badge.openfx", "OpenFX"),
// --- OpenFX progress ---
("ofx.progress.title", "OpenFX 插件进度"),
// --- dialogs ---
("dialog.cancel", "取消"),
("dialog.close", "关闭"),
+319 -13
View File
@@ -93,30 +93,162 @@ pub fn is_enabled(g: &Graph, node: NodeId) -> bool {
.unwrap_or(false)
}
/// The effect types the user can add to a clip's chain, as
/// (type id, display name) pairs — the factory entries flagged
/// `video_effect` and not hidden from the create menu.
pub fn addable_effects() -> Vec<(String, String)> {
/// The effect types the user can add to a clip's chain — the built-in
/// factory entries flagged `video_effect` and not hidden from the create
/// menu (no group), plus every runtime-registered OpenFX plugin entry
/// (grouped by its sub-category: Filter / Generator / Transition /
/// General — the C++ `factorymenu.cpp` OpenFX branch).
pub fn addable_effects() -> Vec<super::engine::EffectEntry> {
use super::engine::EffectEntry;
use oaknode::node::flags as node_flags;
let mut out = Vec::new();
for meta in oaknode::factory::Factory::global().entries() {
// A scratch instance per entry just to read its flags (the factory
// metadata carries no flag copy).
let (core, _behavior) = (meta.create)();
let flags = core.flags;
if flags & oaknode::node::flags::VIDEO_EFFECT != 0
&& flags & oaknode::node::flags::DONT_SHOW_IN_CREATE_MENU == 0
if flags & node_flags::VIDEO_EFFECT != 0
&& flags & node_flags::DONT_SHOW_IN_CREATE_MENU == 0
{
let name = if meta.name.is_empty() {
meta.type_id.to_string()
} else {
meta.name.to_string()
};
out.push((meta.type_id.to_string(), name));
out.push(EffectEntry {
type_id: meta.type_id.to_string(),
name,
group: None,
});
}
}
for meta in oaknode::factory::Factory::global().dynamic_entries() {
// OpenFX plugin entries: grouped by sub-category. The factory
// metadata carries no flags; plugin nodes are always video effects.
let name = if meta.name.is_empty() {
meta.type_id.clone()
} else {
meta.name
};
out.push(EffectEntry {
type_id: meta.type_id,
name,
group: Some(meta.sub_category),
});
}
out
}
// ---------------------------------------------------------------------------
// OFX parameter data model (stage 6b)
// ---------------------------------------------------------------------------
/// The OFX plugin instance handle of a plugin node (the oakplugin registry
/// key), or `None` for built-in nodes. Used by the inspector to read the
/// persistent-message badge and to trigger push buttons.
pub fn plugin_instance_handle(g: &Graph, node: NodeId) -> Option<u64> {
let behavior = g.get(node)?.behavior.as_any()?;
let plugin = behavior.downcast_ref::<oaknode::nodes::plugin::PluginNode>()?;
let handle = plugin.instance_handle();
(!handle.is_null()).then_some(handle.0)
}
/// The parameter controls of `effect` for the inspector, or `None` when
/// the effect exposes no parameter UI (not a plugin node, or no editable
/// parameters).
pub fn effect_params(
g: &Graph,
node: NodeId,
) -> Option<Vec<super::engine::EffectParam>> {
use oaknode::input::flags as input_flags;
use oaknode::nodes::plugin::PluginNode;
use oaknode::value::ValueType;
let entry = g.get(node)?;
// Only OFX plugin nodes expose the parameter UI (built-in effects keep
// the inspector's placeholder).
let behavior = entry.behavior.as_any()?;
if behavior.downcast_ref::<PluginNode>().is_none() {
return None;
}
let mut out = Vec::new();
for input in &entry.core.inputs {
// Clip/texture inputs are graph connections, not params; hidden
// (secret) inputs never render.
if input.value_type == ValueType::Texture {
continue;
}
if input.flags & input_flags::HIDDEN != 0 {
continue;
}
// The standard enabled input is structural, not a parameter.
if input.id == oaknode::node::ENABLED_INPUT {
continue;
}
out.push(super::engine::EffectParam {
input_id: input.id.clone(),
display_name: input.display_name.clone(),
value_type: input.value_type,
value: entry.core.standard_value(&input.id, -1),
flags: input.flags,
properties: input.properties.clone(),
});
}
Some(out)
}
/// The combo option labels of a parameter, collected from the repeated
/// `("combo_option", Text)` property keys (the OFX translation pass
/// carries the choice options that way; `str_combo` values ride the
/// `("combo_value", Text)` keys).
pub fn combo_options(p: &super::engine::EffectParam) -> Vec<String> {
p.properties
.iter()
.filter(|(k, _)| k == "combo_option")
.filter_map(|(_, v)| match v {
oaknode::value::NodeValue::Text(s) => Some(s.clone()),
_ => None,
})
.collect()
}
/// The string-combo values of a parameter (`("combo_value", Text)` keys).
pub fn combo_values(p: &super::engine::EffectParam) -> Vec<String> {
p.properties
.iter()
.filter(|(k, _)| k == "combo_value")
.filter_map(|(_, v)| match v {
oaknode::value::NodeValue::Text(s) => Some(s.clone()),
_ => None,
})
.collect()
}
/// The `ui_group` / `ui_page` property of a parameter, if any (the OFX
/// group/page headers; the inspector renders them as section titles).
pub fn ui_section_of(p: &super::engine::EffectParam) -> Option<(String, String)> {
let group = p
.properties
.iter()
.find(|(k, _)| k == "ui_group")
.and_then(|(_, v)| match v {
oaknode::value::NodeValue::Text(s) => Some(s.clone()),
_ => None,
});
let page = p
.properties
.iter()
.find(|(k, _)| k == "ui_page")
.and_then(|(_, v)| match v {
oaknode::value::NodeValue::Text(s) => Some(s.clone()),
_ => None,
});
match (group, page) {
(None, None) => None,
(group, page) => Some((group.unwrap_or_default(), page.unwrap_or_default())),
}
}
// ---------------------------------------------------------------------------
// Command pieces
// ---------------------------------------------------------------------------
@@ -218,6 +350,55 @@ pub fn set_enabled(p: &ProjectRef, effect: NodeId, enabled: bool) -> Result<(),
)
}
/// Undoable set of an effect parameter (a node input's standard value) —
/// "Set Parameter". `input_id` must exist on the node; the value is
/// written as the standard value of element -1 (non-keyframed). Used by
/// the inspector's OFX parameter controls.
pub fn set_input_value(
p: &ProjectRef,
effect: NodeId,
input_id: &str,
value: NodeValue,
) -> Result<(), String> {
let old = {
let g = lock(p);
let entry = g
.graph
.get(effect)
.ok_or_else(|| "set parameter: node not found".to_string())?;
if entry.core.get_input(input_id).is_none() {
return Err(format!("set parameter: unknown input \"{input_id}\""));
}
entry.core.standard_value(input_id, -1)
};
// NodeValue is not Copy: each closure owns its own clone (the closures
// are FnMut and may run more than once across undo/redo cycles).
let (p1, p2) = (p.clone(), p.clone());
let (input_id, value) = (input_id.to_string(), value);
let (redo_input, undo_input) = (input_id.clone(), input_id);
let redo_value = value.clone();
let undo_old = old.clone();
push(
UndoCommand::from_closures(
move || {
let mut g = lock(&p1);
if let Some(e) = g.graph.get_mut(effect) {
e.core
.set_standard_value(&redo_input, -1, redo_value.clone());
}
},
move || {
let mut g = lock(&p2);
if let Some(e) = g.graph.get_mut(effect) {
e.core
.set_standard_value(&undo_input, -1, undo_old.clone());
}
},
),
"Set Parameter",
)
}
/// The neighbors of chain position `pos` in `chain` (length `len`):
/// `(upstream, downstream)` — the chain source is `None`, the host closes
/// the chain.
@@ -250,10 +431,9 @@ pub fn insert(p: &ProjectRef, host: NodeId, index: usize, type_id: &str) -> Resu
if effect_input_of(&g.graph, host).is_none() {
return Err("node cannot host effects (no effect input)".to_string());
}
let Some(meta) = oaknode::factory::Factory::global().find(type_id) else {
let Some((core, behavior)) = oaknode::factory::Factory::global().create_any(type_id) else {
return Err(format!("unknown node type id \"{type_id}\""));
};
let (core, behavior) = (meta.create)();
if core.effect_input.is_empty() {
return Err("node type has no effect input; cannot be chained".to_string());
}
@@ -482,7 +662,7 @@ mod tests {
.into_iter()
.next()
.expect("the factory registers at least one video effect")
.0
.type_id
}
#[test]
@@ -564,9 +744,135 @@ mod tests {
fn addable_effects_are_video_effects() {
let entries = addable_effects();
assert!(!entries.is_empty());
for (type_id, name) in &entries {
assert!(!type_id.is_empty());
assert!(!name.is_empty());
for entry in &entries {
assert!(!entry.type_id.is_empty());
assert!(!entry.name.is_empty());
}
}
/// The effect-library grouping: every addable effect is either an
/// ungrouped built-in or an OpenFX entry with one of the four
/// sub-categories.
#[test]
fn addable_effects_group_openfx_by_sub_category() {
for entry in addable_effects() {
if let Some(group) = entry.group {
assert!(
["Filter", "Generator", "Transition", "General"].contains(&group.as_str()),
"unexpected OpenFX sub-category {group:?}"
);
}
}
}
/// `set_input_value` writes the standard value undoably and rejects
/// unknown input ids.
#[test]
fn set_input_value_undoes() {
let _g = stack_lock();
oakundo::global::clear().unwrap();
let (project, host) = project_with_clip();
// Choose a built-in effect whose scratch core exposes a float input
// (the value write/undo round-trip needs one).
let ty = addable_effects()
.into_iter()
.find(|entry| {
let (core, _behavior) = oaknode::factory::Factory::global()
.create_any(&entry.type_id)
.expect("the entry resolves");
core.inputs.iter().any(|i| {
i.value_type == oaknode::value::ValueType::Float
&& i.flags & oaknode::input::flags::HIDDEN == 0
})
})
.expect("at least one built-in effect exposes a float input")
.type_id;
let eff = insert(&project, host, 0, &ty).unwrap();
// Unknown input: rejected without touching the graph.
assert!(set_input_value(&project, eff, "nope", NodeValue::Float(1.0)).is_err());
// Pick an editable (non-texture, non-hidden) float input of the
// inserted effect to exercise the write/undo round-trip.
let input_id = {
let g = lock(&project);
g.graph
.get(eff)
.and_then(|e| {
e.core.inputs.iter().find(|i| {
i.value_type == oaknode::value::ValueType::Float
&& i.flags & oaknode::input::flags::HIDDEN == 0
})
})
.map(|i| i.id.clone())
.expect("the effect exposes a float input")
};
let before = lock(&project)
.graph
.get(eff)
.unwrap()
.core
.standard_value(&input_id, -1);
set_input_value(&project, eff, &input_id, NodeValue::Float(42.0)).unwrap();
assert_eq!(
lock(&project).graph.get(eff).unwrap().core.standard_value(&input_id, -1),
NodeValue::Float(42.0)
);
oakundo::global::undo().unwrap();
assert_eq!(
lock(&project).graph.get(eff).unwrap().core.standard_value(&input_id, -1),
before
);
oakundo::global::clear().unwrap();
}
/// The combo-option collector reads the repeated `("combo_option", _)`
/// property keys (and the string-combo values from `("combo_value", _)`).
#[test]
fn combo_option_collectors_read_repeated_properties() {
use crate::oakui::engine::EffectParam;
use oaknode::value::{NodeValue, ValueType};
let param = EffectParam {
input_id: "mode".into(),
display_name: "Mode".into(),
value_type: ValueType::Combo,
value: NodeValue::Combo(0),
flags: 0,
properties: vec![
("combo_option".into(), NodeValue::Text("Fast".into())),
("combo_option".into(), NodeValue::Text("High".into())),
("combo_value".into(), NodeValue::Text("fast".into())),
("combo_value".into(), NodeValue::Text("high".into())),
],
};
assert_eq!(combo_options(&param), vec!["Fast", "High"]);
assert_eq!(combo_values(&param), vec!["fast", "high"]);
}
/// ui_group / ui_page surface as a section header (empty halves are
/// fine); params without either have no section.
#[test]
fn ui_section_collects_group_and_page() {
use crate::oakui::engine::EffectParam;
use oaknode::value::{NodeValue, ValueType};
let plain = EffectParam {
input_id: "p".into(),
display_name: "P".into(),
value_type: ValueType::Int,
value: NodeValue::Int(0),
flags: 0,
properties: vec![],
};
assert!(ui_section_of(&plain).is_none());
let grouped = EffectParam {
properties: vec![
("ui_group".into(), NodeValue::Text("Basic".into())),
("ui_page".into(), NodeValue::Text("Main".into())),
],
..plain
};
assert_eq!(ui_section_of(&grouped), Some(("Basic".into(), "Main".into())));
}
}
+74 -6
View File
@@ -35,7 +35,7 @@
use std::path::PathBuf;
use std::sync::Arc;
use gpui::effect_stack::{EffectStackDataSource, EffectStackEvent};
use gpui::effect_stack::{EffectId, EffectStackDataSource, EffectStackEvent};
use gpui::node_graph::{NodeGraphDataSource, NodeGraphEvent};
use gpui::timeline::{
ClipId, Frame, FrameRate, TimelineDataSource, TimelineEvent, TrackData, TrackKind,
@@ -154,6 +154,41 @@ pub struct NodeLibraryEntry {
pub category_key: &'static str,
}
/// One addable effect entry (the effect library list and the inspector's
/// add-effect menu).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EffectEntry {
/// The factory type id handed to [`AppEngine::add_effect`].
pub type_id: String,
/// The effect's display name.
pub name: String,
/// The effect-library group: `Some(sub_category)` for OpenFX plugin
/// entries (Filter / Generator / Transition / General — the C++
/// `factorymenu` OpenFX branch), `None` for built-in effects (rendered
/// without a group header).
pub group: Option<String>,
}
/// A snapshot of one effect parameter (a node input) for the inspector's
/// parameter view. For OFX plugin nodes each entry maps 1:1 to an OFX
/// parameter (input id = param name, display name = param label).
#[derive(Debug, Clone)]
pub struct EffectParam {
/// The input id (the OFX param name for plugin nodes).
pub input_id: String,
/// The display name (the OFX param label).
pub display_name: String,
/// The value type.
pub value_type: oaknode::value::ValueType,
/// The current value.
pub value: oaknode::value::NodeValue,
/// The input flag bits (`oaknode::input::flags::*`).
pub flags: u32,
/// The input properties (`combo_option` / `combo_value` / `ui_group` /
/// `ui_page` / `min` / `max` / ...).
pub properties: Vec<(String, oaknode::value::NodeValue)>,
}
/// The i18n key of a node category submenu, or `None` for categories that
/// never appear in the node editor's Add menu (timeline-structural nodes).
pub fn node_category_key(category: oaknode::node::Category) -> Option<&'static str> {
@@ -292,11 +327,12 @@ pub trait AppEngine:
/// selection-driven stack keep their existing behavior).
fn set_selected_clips(&mut self, _clips: Vec<ClipId>, _cx: &mut Context<Self>) {}
/// The effect types the user can add to the selected clip's chain, as
/// (type id, display name) pairs — the facade factory entries flagged
/// `video_effect` and not hidden from the create menu. The inspector
/// panel lists them in its "add effect" menu. Default: empty.
fn addable_effects(&self) -> Vec<(String, String)> {
/// The effect types the user can add to the selected clip's chain — the
/// factory entries flagged `video_effect` and not hidden from the create
/// menu, plus every runtime-registered OpenFX plugin entry (grouped by
/// its sub-category). The inspector panel lists them in its "add
/// effect" menu. Default: empty.
fn addable_effects(&self) -> Vec<EffectEntry> {
Vec::new()
}
@@ -315,6 +351,38 @@ pub trait AppEngine:
Err("add effect not supported".into())
}
/// The parameter controls of `effect` for the inspector, or `None`
/// when the effect exposes no parameter UI (not a plugin node, or no
/// editable parameters). Default: `None` (the inspector renders its
/// placeholder).
fn effect_params(&self, _effect: EffectId) -> Option<Vec<EffectParam>> {
None
}
/// Sets an effect parameter (a node input) undoably. Returns a
/// user-facing error on failure. Default: unsupported.
fn set_effect_param(
&mut self,
_effect: EffectId,
_input_id: &str,
_value: oaknode::value::NodeValue,
_cx: &mut Context<Self>,
) -> Result<(), String> {
Err("effect params not supported".into())
}
/// Triggers a push-button parameter of `effect` (the OFX push-button
/// press). Returns a user-facing error on failure. Default:
/// unsupported.
fn effect_push_button(
&mut self,
_effect: EffectId,
_input_id: &str,
_cx: &mut Context<Self>,
) -> Result<(), String> {
Err("effect push button not supported".into())
}
/// Applies a node-editor edit request to the engine's model.
fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context<Self>);
+8 -5
View File
@@ -1273,9 +1273,10 @@ impl AppEngine for MockEngine {
self.apply_effect_event(event, cx);
}
fn addable_effects(&self) -> Vec<(String, String)> {
// The demo list is the real factory's video-effect table, so the
// effect library shows the same entries the real engine would.
fn addable_effects(&self) -> Vec<crate::oakui::engine::EffectEntry> {
// The demo list is the real factory's effect table (built-ins plus
// any registered OpenFX plugins), so the effect library shows the
// same entries the real engine would.
crate::oakui::effectchain::addable_effects()
}
@@ -1287,7 +1288,8 @@ impl AppEngine for MockEngine {
) -> Result<(), String> {
let Some((_, name)) = crate::oakui::effectchain::addable_effects()
.into_iter()
.find(|(id, _)| id == type_id)
.find(|entry| entry.type_id == type_id)
.map(|entry| (entry.type_id, entry.name))
else {
return Err(format!("unknown effect \"{type_id}\""));
};
@@ -2721,7 +2723,8 @@ mod tests {
let engine = demo_engine(app);
let effects = engine.read(app).addable_effects();
assert!(!effects.is_empty(), "the demo list is the factory table");
let (type_id, name) = effects[0].clone();
let first = effects[0].clone();
let (type_id, name) = (first.type_id, first.name);
let before = engine.read(app).effects().len();
engine.update(app, |engine, cx| {
engine
+4 -3
View File
@@ -48,6 +48,7 @@ pub mod icons;
pub mod mock;
pub mod multicam;
pub mod nodegraph;
pub mod ofx;
pub mod projectbrowser;
pub mod real;
pub mod renderops;
@@ -58,9 +59,9 @@ pub mod waveform;
pub mod waveformsync;
pub use engine::{
AppEngine, EngineClock, EngineGateway, ExportEvent, ExportSession, HistoryEntry,
LibraryProject, Monitor, MulticamState, NodeLibraryEntry, Project, ScopeData, Sequence,
VideoFormat,
AppEngine, EffectEntry, EffectParam, EngineClock, EngineGateway, ExportEvent, ExportSession,
HistoryEntry, LibraryProject, Monitor, MulticamState, NodeLibraryEntry, Project, ScopeData,
Sequence, VideoFormat,
};
pub use mock::{MockClock, MockEngine};
pub use real::{RealClock, RealEngine};
+248
View File
@@ -0,0 +1,248 @@
// 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/>.
//! OFX plugin startup wiring (stage 6b).
//!
//! The app is the only place that holds both the oakplugin host and the UI
//! services the OFX suites consult at runtime, so the wiring lives here:
//!
//! - [`init`] scans the standard plugin paths ([`oakplugin::host::Host`]
//! default path set, `host.rs:440-449`), registers every discovered
//! plugin into the node factory (the effect library and the add-effect
//! menu consume those entries), installs the render executor and the
//! plugin-node duplicator (both idempotent), and registers the
//! progress-reporter factory plus the active-viewer provider.
//! - [`update_project_extent`] / [`update_viewer_time`] keep the
//! oakplugin side's fallback project size and timeline time in sync with
//! the current sequence (the engine calls them on open / seek / tick).
//! - [`set_progress_tx`] wires a progress-event channel the app drains in
//! its tick loop to drive the progress dialog.
//!
//! Every failure degrades to a log: plugin support is an optional
//! capability, never a startup dependency.
//!
//! ## Rendering topology and progress
//!
//! Preview/export rendering runs through the process-isolated oak-worker
//! pool (M15 S2), so plugin rendering happens in the worker process where
//! this main-process reporter factory is not in effect. The wiring still
//! serves the in-process render paths (e.g. the test-only inline backend)
//! and future work; worker-side progress forwarding over IPC is a TODO.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use oakplugin::progress::{ReporterFactory, UiProgressReporter};
use oakplugin::suites::timeline::{ActiveViewerProvider, ViewerTimeInfo};
/// One progress event a plugin reporter pushed to the app channel (drained
/// by the app tick, which drives a progress dialog).
#[derive(Debug, Clone, Default)]
pub struct PluginProgressEvent {
/// The label the plugin passed to progressStart.
pub label: String,
/// The message the plugin passed to progressStart.
pub message: String,
/// Progress fraction in 0.0..=1.0.
pub fraction: f64,
}
/// The app's progress-event channel (registered by [`set_progress_tx`]).
static PROGRESS_TX: OnceLock<Mutex<Option<std::sync::mpsc::Sender<PluginProgressEvent>>>> =
OnceLock::new();
/// The sticky cancel flag read by every live reporter (`update` returns
/// false once set; the progress dialog's cancel button sets it).
static CANCEL: AtomicBool = AtomicBool::new(false);
/// The last active-viewer time snapshot (the timeline-suite provider reads
/// it; the engine refreshes it on seek / tick).
static VIEWER_TIME: OnceLock<Mutex<ViewerTimeInfo>> = OnceLock::new();
/// The last known project extent (normalised-coordinate default conversion;
/// the engine refreshes it whenever the sequence changes).
static PROJECT_EXTENT: OnceLock<Mutex<(f64, f64)>> = OnceLock::new();
fn viewer_slot() -> &'static Mutex<ViewerTimeInfo> {
VIEWER_TIME.get_or_init(|| {
Mutex::new(ViewerTimeInfo {
time: 0.0,
range_min: 0.0,
range_max: 0.0,
})
})
}
fn extent_slot() -> &'static Mutex<(f64, f64)> {
PROJECT_EXTENT.get_or_init(|| Mutex::new((1920.0, 1080.0)))
}
// ---------------------------------------------------------------------------
// App-driven state sync
// ---------------------------------------------------------------------------
/// Wires the app's progress-event channel into the OFX progress suite. The
/// app keeps the receiving half and drains it in its tick loop.
pub fn set_progress_tx(tx: std::sync::mpsc::Sender<PluginProgressEvent>) {
*PROGRESS_TX
.get_or_init(|| Mutex::new(None))
.lock()
.unwrap_or_else(|e| e.into_inner()) = Some(tx);
}
/// Clone of the registered sender, or `None` before
/// [`set_progress_tx`] (a reporter then silently continues).
fn progress_tx() -> Option<std::sync::mpsc::Sender<PluginProgressEvent>> {
PROGRESS_TX
.get_or_init(|| Mutex::new(None))
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
/// Updates the active-viewer time snapshot the timeline suite falls back
/// to when no render context is live (engine seek / tick path).
pub fn update_viewer_time(time: f64, range_min: f64, range_max: f64) {
*viewer_slot().lock().unwrap_or_else(|e| e.into_inner()) = ViewerTimeInfo {
time,
range_min,
range_max,
};
}
/// Updates the project extent (width/height) the OFX normalised-coordinate
/// default conversion uses, and pushes it into oakplugin.
pub fn update_project_extent(width: f64, height: f64) {
let (w, h) = (width.max(1.0), height.max(1.0));
*extent_slot().lock().unwrap_or_else(|e| e.into_inner()) = (w, h);
oakplugin::node_factory::set_project_extent(w, h);
}
/// Requests cancellation of the running plugin render (the progress
/// dialog's Cancel button). The next progressStart resets the flag.
pub fn cancel_plugin_render() {
CANCEL.store(true, Ordering::Relaxed);
}
// ---------------------------------------------------------------------------
// Reporters / providers
// ---------------------------------------------------------------------------
/// A reporter that forwards (label, message, fraction) to the app channel
/// and honours the global cancel flag.
struct ChannelProgressReporter {
tx: Option<std::sync::mpsc::Sender<PluginProgressEvent>>,
label: String,
message: String,
}
impl UiProgressReporter for ChannelProgressReporter {
fn update(&mut self, progress: f64) -> bool {
if let Some(tx) = &self.tx {
let _ = tx.send(PluginProgressEvent {
label: self.label.clone(),
message: self.message.clone(),
fraction: progress,
});
}
!CANCEL.load(Ordering::Relaxed)
}
}
fn reporter_factory() -> ReporterFactory {
Arc::new(|label, message| {
// A fresh render begins: reset the sticky cancel flag.
CANCEL.store(false, Ordering::Relaxed);
Box::new(ChannelProgressReporter {
tx: progress_tx(),
label: label.to_string(),
message: message.to_string(),
})
})
}
fn viewer_provider() -> ActiveViewerProvider {
Arc::new(|| {
let info = *viewer_slot().lock().unwrap_or_else(|e| e.into_inner());
Some(info)
})
}
// ---------------------------------------------------------------------------
// Startup
// ---------------------------------------------------------------------------
/// Idempotent OFX startup wiring. Scans the standard plugin directories,
/// registers every discovered plugin into the node factory, installs the
/// render executor / duplicator, and registers the progress factory and
/// the active-viewer provider. Returns the number of plugin node types
/// registered (0 when no plugins were discovered or the scan failed).
pub fn init() -> usize {
// 1. Scan the standard OFX plugin directories (host.rs:440-449 default
// path set: ~/.OFX/Plugins, ~/.local/share/OFX/Plugins, ...
// plus the OLIVE_OFX_PLUGIN_PATH / OLIVE_PLUGIN_PATH /
// OFX_PLUGIN_PATH environment variables). A scan failure only
// logs — plugins are optional.
if let Err(e) = oakplugin::host::Host::global().cache.scan() {
eprintln!("[ofx] plugin scan failed: {e}");
}
// 2. Register discovered plugins into the node factory (idempotent;
// also installs the render executor and the plugin-node duplicator).
let registered = oakplugin::node_factory::register_plugin_nodes();
// 3. Progress reporter factory -> the app progress channel.
oakplugin::progress::set_reporter_factory(Some(reporter_factory()));
// 4. Active-viewer time provider (timeline suite fallback).
oakplugin::suites::timeline::set_active_viewer_provider(Some(viewer_provider()));
// 5. Project extent (the engine refreshes it whenever the sequence
// changes; keep the oakplugin side in sync with the default).
let (w, h) = *extent_slot().lock().unwrap_or_else(|e| e.into_inner());
oakplugin::node_factory::set_project_extent(w, h);
registered.len()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn viewer_time_roundtrip() {
update_viewer_time(42.5, 10.0, 200.0);
let provider = viewer_provider();
let info = provider().expect("provider always reports a snapshot");
assert_eq!(info.time, 42.5);
assert_eq!((info.range_min, info.range_max), (10.0, 200.0));
}
#[test]
fn cancel_is_sticky_until_a_new_reporter() {
CANCEL.store(false, Ordering::Relaxed);
let factory = reporter_factory();
let mut a = factory("a", "b");
assert!(a.update(0.1));
cancel_plugin_render();
assert!(!a.update(0.5), "a cancelled render reports no");
// A fresh progressStart resets the flag.
let mut b = factory("a", "b");
assert!(b.update(0.1));
}
#[test]
fn project_extent_is_forwarded() {
update_project_extent(1280.0, 720.0);
let slot = extent_slot();
assert_eq!(*slot.lock().unwrap(), (1280.0, 720.0));
}
}
+102 -3
View File
@@ -668,6 +668,11 @@ struct RealEffect {
enabled: bool,
/// The app-owned expansion state (not undoable).
expanded: bool,
/// Optional secondary line (the "OpenFX" tag for plugin nodes).
subtitle: Option<SharedString>,
/// The persistent-message badge count (plugin nodes only; `None`
/// otherwise).
badge: Option<usize>,
}
impl EffectData for RealEffect {
@@ -683,6 +688,10 @@ impl EffectData for RealEffect {
self.title.clone()
}
fn subtitle(&self) -> Option<SharedString> {
self.subtitle.clone()
}
fn is_enabled(&self) -> bool {
self.enabled
}
@@ -690,6 +699,10 @@ impl EffectData for RealEffect {
fn is_expanded(&self) -> bool {
self.expanded
}
fn badge_count(&self) -> Option<usize> {
self.badge
}
}
/// A track on the real timeline (snapshot handed to the timeline widget).
@@ -2334,6 +2347,23 @@ impl RealEngine {
},
length,
});
// Stage 6b: keep the OFX normalised-coordinate default conversion in
// sync with the current sequence's extent.
crate::oakui::ofx::update_project_extent(width as f64, height as f64);
}
/// Stage 6b: refreshes the OFX timeline-suite fallback time snapshot
/// from the program monitor's playhead (seconds) and the sequence
/// length (the range bounds). Called on seek and on every tick.
fn update_ofx_viewer_time(&self, cx: &App) {
let fps = self.frame_rate().as_f64();
if fps <= 0.0 {
return;
}
let frame = self.clock_frame(Monitor::Program, cx).0;
let time = frame as f64 / fps;
let length = self.sequence_length().0 as f64 / fps;
crate::oakui::ofx::update_viewer_time(time, 0.0, length);
}
/// Rebuilds the timeline snapshot from the graph.
@@ -2528,16 +2558,32 @@ impl RealEngine {
for node in super::effectchain::chain(&guard.graph, host) {
let identity = node.identity();
let type_id = graphops::node_type_id(&guard.graph, node);
// `name_of` covers both static (built-in) and dynamic (OpenFX
// plugin) factory entries.
let title = oaknode::factory::Factory::global()
.find(&type_id)
.map(|m| m.name.to_string())
.name_of(&type_id)
.filter(|n| !n.is_empty())
.unwrap_or(type_id);
let plugin_handle = super::effectchain::plugin_instance_handle(&guard.graph, node);
// The OpenFX plugin badge: the persistent-message count (the
// simplified 徽标/计数 of stage 6b). Built-in effects show none.
let badge = plugin_handle.and_then(|handle| {
let count =
oakplugin::suites::message::persistent_message_count(handle as usize);
(count > 0).then_some(count)
});
let subtitle = plugin_handle.map(|_| {
// A muted secondary line identifying the plugin effect as an
// OpenFX entry.
crate::i18n::tr("inspector.badge.openfx").to_string()
});
out.push(Arc::new(RealEffect {
id: EffectId(identity),
title: title.into(),
subtitle: subtitle.map(Into::into),
enabled: super::effectchain::is_enabled(&guard.graph, node),
expanded: self.expanded_effects.contains(&identity),
badge,
}) as Arc<dyn EffectData>);
}
out
@@ -2600,6 +2646,7 @@ impl EngineGateway for RealEngine {
cx.notify();
});
self.mirror_program_playhead(cx);
self.update_ofx_viewer_time(cx);
cx.notify();
}
@@ -2660,6 +2707,7 @@ impl EngineGateway for RealEngine {
});
}
self.mirror_program_playhead(cx);
self.update_ofx_viewer_time(cx);
self.meter_phase = self.meter_phase.wrapping_add(1);
// M15 S2: pump the process dispatcher — ticket completions (the
// pre-render window, full-res fills, synchronous renders) are
@@ -2955,7 +3003,7 @@ impl AppEngine for RealEngine {
cx.notify();
}
fn addable_effects(&self) -> Vec<(String, String)> {
fn addable_effects(&self) -> Vec<crate::oakui::engine::EffectEntry> {
super::effectchain::addable_effects()
}
@@ -2976,6 +3024,57 @@ impl AppEngine for RealEngine {
result
}
fn effect_params(&self, effect: EffectId) -> Option<Vec<crate::oakui::engine::EffectParam>> {
let project = self.project_ref()?;
let node = graphops::id_of(effect.0)?;
let guard = graphops::lock(project);
super::effectchain::effect_params(&guard.graph, node)
}
fn set_effect_param(
&mut self,
effect: EffectId,
input_id: &str,
value: oaknode::value::NodeValue,
cx: &mut Context<Self>,
) -> Result<(), String> {
let Some(project) = self.project.clone() else {
return Err("no project open".into());
};
let Some(node) = graphops::id_of(effect.0) else {
return Err("effect node not found".into());
};
let result = super::effectchain::set_input_value(&project, node, input_id, value);
self.apply_edit(result.clone(), "set parameter", cx);
result
}
fn effect_push_button(
&mut self,
effect: EffectId,
input_id: &str,
cx: &mut Context<Self>,
) -> Result<(), String> {
let Some(project) = self.project_ref() else {
return Err("no project open".into());
};
let Some(node) = graphops::id_of(effect.0) else {
return Err("effect node not found".into());
};
let guard = graphops::lock(project);
let Some(instance) = super::effectchain::plugin_instance_handle(&guard.graph, node) else {
return Err("not a plugin effect".into());
};
drop(guard);
if !oakplugin::node_factory::push_button_clicked(instance, input_id) {
return Err(format!("push button \"{input_id}\" not found"));
}
// A button press can change the plugin's other parameters; refresh
// the snapshots and repaint.
cx.notify();
Ok(())
}
fn apply_effect_event(&mut self, event: &EffectStackEvent, cx: &mut Context<Self>) {
match event {
EffectStackEvent::EnableToggled { effect, enabled } => {
+36 -3
View File
@@ -65,9 +65,27 @@ impl<E: AppEngine> Render for EffectLibraryPanel<E> {
.gap_1()
.p_2()
.overflow_y_scroll();
for (type_id, name) in effects {
// Built-in effects render flat; OpenFX plugin entries are grouped
// under their sub-category header (Filter / Generator / Transition /
// General — the C++ `factorymenu` OpenFX branch).
let mut last_group: Option<String> = None;
for entry in &effects {
match &entry.group {
Some(group) => {
if last_group.as_deref() != Some(group.as_str()) {
last_group = Some(group.clone());
list = list.child(group_header(&colors, group));
}
}
None => {
last_group = None;
}
}
let engine = self.engine.clone();
let row_id = type_id.clone();
let row_id = entry.type_id.clone();
let name = entry.name.clone();
let type_id = entry.type_id.clone();
list = list.child(
div()
.id(SharedString::from(format!("effect-library-{type_id}")))
@@ -123,6 +141,20 @@ impl<E: AppEngine> Render for EffectLibraryPanel<E> {
}
}
/// 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())
}
impl<E: AppEngine> EventEmitter<PanelEvent> for EffectLibraryPanel<E> {}
impl<E: AppEngine> DockPanel for EffectLibraryPanel<E> {
@@ -161,7 +193,8 @@ mod tests {
let expected = crate::oakui::effectchain::addable_effects();
assert!(!expected.is_empty());
for (type_id, _) in &expected {
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 =
+14 -21
View File
@@ -32,6 +32,7 @@ use crate::menus::context::{ContextMenuHandle, ContextMenuTriggered};
use crate::oakui::AppEngine;
use crate::panels::commands::PanelCommandHandler;
use crate::panels::ids::INSPECTOR;
use crate::panels::ofx_params::OfxParamsView;
/// The inspector / effect stack panel.
pub struct InspectorPanel<E: AppEngine> {
@@ -53,8 +54,16 @@ impl<E: AppEngine> InspectorPanel<E> {
/// Builds the stack over `engine`'s effect model.
pub fn new(engine: Entity<E>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let stack = cx.new(|cx| {
EffectStackView::new(engine.clone(), cx)
.params_renderer(|_effect, _window, cx| cx.new(|_cx| ParamPlaceholder).into())
let engine_for_params = engine.clone();
EffectStackView::new(engine.clone(), cx).params_renderer(
move |effect, window, cx| {
// The OFX parameter view: auto-generated controls for the
// effect's inputs (empty state for effects without a
// parameter UI).
cx.new(|cx| OfxParamsView::new(*effect, engine_for_params.clone(), window, cx))
.into()
},
)
});
// The "edits are requests" loop: forward each request to the engine,
// which applies it to its model and notifies. `AddRequested` carries
@@ -133,10 +142,10 @@ impl<E: AppEngine> InspectorPanel<E> {
.flex_col()
.gap_1();
for (type_id, name) in &effects {
for entry in &effects {
let engine = self.engine.clone();
let type_id = type_id.clone();
let name = name.clone();
let type_id = entry.type_id.clone();
let name = entry.name.clone();
let index = index;
menu = menu.child(
div()
@@ -264,22 +273,6 @@ impl<E: AppEngine> DockPanel for InspectorPanel<E> {
}
}
/// Placeholder parameter view rendered inside expanded effect cards.
/// A real app builds the effect's controls here and calls
/// [`EffectStackView::notify_parameter_changed`] after edits.
struct ParamPlaceholder;
impl Render for ParamPlaceholder {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
div()
.px_3()
.py_2()
.text_color(colors.disabled)
.child(crate::i18n::tr("inspector.params"))
}
}
// ---------------------------------------------------------------------------
// Context menu — an effect card's right-click menu: enable/disable and
// remove drive the same `EffectStackEvent`s the card widgets emit; rename
+1
View File
@@ -29,6 +29,7 @@ pub mod history;
pub mod inspector;
pub mod multicam;
pub mod node_editor;
pub mod ofx_params;
pub mod program_viewer;
pub mod project_explorer;
pub mod source_viewer;
+644
View File
@@ -0,0 +1,644 @@
// 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 inspector's OFX parameter view (stage 6b): auto-generated controls
//! for the effect node's inputs.
//!
//! The [`EffectStackView`](gpui::effect_stack::EffectStackView) invokes a
//! params renderer inside each expanded effect card. For OFX plugin nodes
//! this view reads the effect's parameter snapshot from the engine
//! ([`AppEngine::effect_params`]) and renders one control per input:
//!
//! - int / float → [`Slider`] (double-click for direct entry)
//! - boolean → [`CheckBox`]
//! - combo → [`ComboBox`] fed from the repeated `("combo_option", _)`
//! properties; string-combo values come from `("combo_value", _)`
//! - text → [`EditableTextState`]
//! - vec2 / vec3 / color → one [`SpinBox`] per component
//! - push button → a clickable button (`AppEngine::effect_push_button`)
//!
//! Secret (HIDDEN) inputs never reach the snapshot, so they render
//! nothing; `ui_group` / `ui_page` become section titles. Every edit is
//! routed through [`AppEngine::set_effect_param`] (undoable).
//!
//! The control set is rebuilt when the card re-renders (the params view is
//! created fresh per expanded-card render), so it carries no state of its
//! own; values are re-synced from the engine each frame.
use gpui::effect_stack::EffectId;
use gpui::colors::DefaultColors;
use gpui::{
div, prelude::*, px, ClickEvent, 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::{Slider, SliderModel};
use gpui_widgets::spinbox::{SpinBox, SpinBoxEvent};
use gpui_widgets::value::{SliderValue, ValueKind};
use oaknode::value::{NodeValue, ValueType};
use crate::oakui::{AppEngine, EffectParam};
/// One editable parameter row of the view.
struct ParamControl {
/// The input id (the OFX param name).
input_id: String,
/// The display name (the OFX param label).
display_name: String,
/// The OFX ui_group / ui_page section header, if any.
section: Option<(String, String)>,
/// The control(s) for this parameter.
kind: ControlKind,
}
/// The concrete control(s) for one parameter.
enum ControlKind {
/// A slider (int / float).
Slider(Entity<Slider>),
/// A checkbox (boolean).
CheckBox(Entity<CheckBox>),
/// A combo box (combo / string combo).
Combo(Entity<ComboBox>),
/// One spinbox per component (vec2 / vec3 / color); the usize is the
/// component index within the value.
Spin(Vec<(Entity<SpinBox>, usize)>),
/// A text field (string).
Text(Entity<EditableTextState>),
/// A push button (rendered inline, no entity).
PushButton,
/// A read-only value line (no editable control; e.g. custom/binary).
ReadOnly(SharedString),
}
/// The inspector's parameter view for one expanded effect card.
pub struct OfxParamsView<E: AppEngine> {
engine: Entity<E>,
effect: EffectId,
controls: Vec<ParamControl>,
}
impl<E: AppEngine> OfxParamsView<E> {
/// Builds the control set from the engine's current parameter snapshot.
pub fn new(
effect: EffectId,
engine: Entity<E>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let params = engine.read(cx).effect_params(effect).unwrap_or_default();
let mut control_id = 0usize;
let controls = params
.iter()
.map(|param| build_control(param, &mut control_id, window, cx))
.collect();
let this = Self {
engine,
effect,
controls,
};
wire_controls(&this, cx);
this
}
/// Applies the engine's current values to every control (called each
/// render so external edits / undo / redo land on the controls).
fn sync_values(&mut self, cx: &mut Context<Self>) {
let params = self.engine.read(cx).effect_params(self.effect).unwrap_or_default();
for control in &self.controls {
let Some(param) = params.iter().find(|p| p.input_id == control.input_id) else {
continue;
};
match &control.kind {
ControlKind::Slider(slider) => {
let sv = slider_value(param);
let slider = slider.clone();
slider.update(cx, |slider, _| slider.set_value(sv));
}
ControlKind::CheckBox(check) => {
let state = match param.value {
NodeValue::Boolean(true) => CheckState::Checked,
_ => CheckState::Unchecked,
};
let check = check.clone();
check.update(cx, |check, cx| check.set_state(state, cx));
}
ControlKind::Combo(combo) => {
let index = combo_index_for(param);
let combo = combo.clone();
combo.update(cx, |combo, cx| {
combo.set_selected(Some(index), cx);
});
}
ControlKind::Spin(spins) => {
let components = value_components(&param.value);
for (spin, channel) in spins {
if let Some(value) = components.get(*channel) {
let spin = spin.clone();
let sv = SliderValue::Float(*value);
spin.update(cx, |spin, cx| spin.set_value(sv, cx));
}
}
}
ControlKind::Text(editor) => {
let text = match &param.value {
NodeValue::Text(s) => s.clone(),
NodeValue::StrCombo(s) => s.clone(),
_ => continue,
};
let editor = editor.clone();
editor.update(cx, |editor, cx| {
if editor.as_str() != text {
editor.emplace(&text, cx);
}
});
}
ControlKind::PushButton | ControlKind::ReadOnly(_) => {}
}
}
}
}
/// The SliderValue for a param's current value (int → Integer, float →
/// Float).
fn slider_value(param: &EffectParam) -> SliderValue {
match param.value {
NodeValue::Int(v) => SliderValue::Integer(v),
NodeValue::Float(v) => SliderValue::Float(v),
_ => SliderValue::Float(param.value.to_double()),
}
}
/// The selected option index of a combo/string-combo parameter. Integer
/// combos carry the index directly; string combos are matched against the
/// `("combo_value", _)` (or `("combo_option", _)`) list by value.
fn combo_index_for(param: &EffectParam) -> usize {
if param.value_type == ValueType::StrCombo {
let values = crate::oakui::effectchain::combo_values(param);
let haystack = if values.is_empty() {
crate::oakui::effectchain::combo_options(param)
} else {
values
};
match &param.value {
NodeValue::StrCombo(s) | NodeValue::Text(s) => {
haystack.iter().position(|v| v == s).unwrap_or(0)
}
_ => 0,
}
} else {
param.value.to_double().max(0.0) as usize
}
}
/// The component list of a vec/color value (empty for other types).
fn value_components(value: &NodeValue) -> Vec<f64> {
match value {
NodeValue::Vec2(v) => vec![v[0], v[1]],
NodeValue::Vec3(v) => vec![v[0], v[1], v[2]],
NodeValue::Vec4(v) => vec![v[0], v[1], v[2], v[3]],
NodeValue::Color(v) => vec![v[0], v[1], v[2], v[3]],
_ => Vec::new(),
}
}
/// The default numeric range when the parameter carries no min/max
/// properties (the OFX translation only attaches min/max to colour
/// inputs). Wide ranges keep every value reachable; the slider's
/// double-click entry allows exact typing.
fn default_range(value_type: ValueType) -> (f64, f64) {
match value_type {
ValueType::Int => (-100000.0, 100000.0),
_ => (-10000.0, 10000.0),
}
}
/// The min/max from the parameter's `("min", Float)` / `("max", Float)`
/// properties, falling back to [`default_range`].
fn numeric_range(param: &EffectParam) -> (f64, f64) {
let prop = |key: &str| {
param
.properties
.iter()
.find(|(k, _)| k == key)
.and_then(|(_, v)| match v {
NodeValue::Float(f) => Some(*f),
NodeValue::Int(i) => Some(*i as f64),
_ => None,
})
};
let (dmin, dmax) = default_range(param.value_type);
(
prop("min").unwrap_or(dmin),
prop("max").unwrap_or(dmax),
)
}
/// Builds one [`ParamControl`] for `param`, creating the control entities
/// (each consuming one control id from `next_id`).
fn build_control<E: AppEngine>(
param: &EffectParam,
next_id: &mut usize,
window: &mut Window,
cx: &mut Context<OfxParamsView<E>>,
) -> ParamControl {
let kind = match param.value_type {
ValueType::Int | ValueType::Float => {
let (min, max) = numeric_range(param);
let kind = if param.value_type == ValueType::Int {
ValueKind::Integer
} else {
ValueKind::Float
};
let step = if param.value_type == ValueType::Int {
1.0
} else {
((max - min) / 200.0).max(0.001)
};
let default_raw = param.value.to_double().clamp(min, max);
let model = SliderModel::new(kind, min, max, step, default_raw);
let slider = cx.new(|cx| Slider::new(*next_id, model, window, cx));
*next_id += 1;
ControlKind::Slider(slider)
}
ValueType::Boolean => {
let state = match param.value {
NodeValue::Boolean(true) => CheckState::Checked,
_ => CheckState::Unchecked,
};
let check = cx.new(|cx| CheckBox::new(*next_id, state, window, cx));
*next_id += 1;
ControlKind::CheckBox(check)
}
ValueType::Combo | ValueType::StrCombo => {
let options: Vec<String> = if param.value_type == ValueType::StrCombo {
let values = crate::oakui::effectchain::combo_values(param);
if values.is_empty() {
crate::oakui::effectchain::combo_options(param)
} else {
values
}
} else {
crate::oakui::effectchain::combo_options(param)
};
if options.is_empty() {
// No option list: show the raw value read-only.
let text = if param.value_type == ValueType::Combo {
format!("{}", param.value.to_double() as i64)
} else {
match &param.value {
NodeValue::StrCombo(s) | NodeValue::Text(s) => s.clone(),
_ => String::new(),
}
};
ControlKind::ReadOnly(text.into())
} else {
let options = options
.iter()
.enumerate()
.map(|(i, label)| ComboBoxOption::new(i, label.clone()))
.collect();
let combo = cx.new(|cx| ComboBox::new(*next_id, options, window, cx));
let index = combo_index_for(param);
combo.update(cx, |combo, cx| combo.set_selected(Some(index), cx));
*next_id += 1;
ControlKind::Combo(combo)
}
}
ValueType::Text => {
let text = match &param.value {
NodeValue::Text(s) => s.clone(),
_ => String::new(),
};
let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
editor.update(cx, |editor, cx| editor.emplace(&text, cx));
*next_id += 1;
ControlKind::Text(editor)
}
ValueType::Vec2 | ValueType::Vec3 | ValueType::Color => {
let components = value_components(&param.value);
let count = if param.value_type == ValueType::Vec2 {
2
} else if param.value_type == ValueType::Vec3 {
3
} else {
4
};
let (min, max) = if param.value_type == ValueType::Color {
// Colour channels live in 0..1 (or the attached min/max).
(0.0f64, 1.0f64)
} else {
default_range(param.value_type)
};
let mut spins = Vec::new();
for channel in 0..count {
let value = components.get(channel).copied().unwrap_or(0.0).clamp(min, max);
let model = SliderModel::new(ValueKind::Float, min, max, 0.001, value);
let spin = cx.new(|cx| SpinBox::new(*next_id, model, window, cx));
*next_id += 1;
spins.push((spin, channel));
}
ControlKind::Spin(spins)
}
ValueType::PushButton => ControlKind::PushButton,
// Custom / binary and anything without an editable control: a
// read-only line (or nothing).
_ => ControlKind::ReadOnly(SharedString::new("")),
};
ParamControl {
input_id: param.input_id.clone(),
display_name: param.display_name.clone(),
section: crate::oakui::effectchain::ui_section_of(param),
kind,
}
}
/// Wires every control's events to the engine's `set_effect_param` /
/// `effect_push_button`.
fn wire_controls<E: AppEngine>(view: &OfxParamsView<E>, cx: &mut Context<OfxParamsView<E>>) {
for control in &view.controls {
let input_id = control.input_id.clone();
let effect = view.effect;
let engine = view.engine.clone();
match &control.kind {
ControlKind::Slider(slider) => {
let slider = slider.clone();
cx.subscribe(&slider, move |_, _, event: &gpui_widgets::slider::SliderEvent, cx| {
if let gpui_widgets::slider::SliderEvent::ValueChanged { value, .. } = event {
let nv = match value {
SliderValue::Integer(v) => NodeValue::Int(*v),
_ => NodeValue::Float(value.to_f64()),
};
engine.update(cx, |engine, cx| {
let _ = engine.set_effect_param(effect, &input_id, nv, cx);
});
}
})
.detach();
}
ControlKind::CheckBox(check) => {
let check = check.clone();
cx.subscribe(&check, move |_, _, event: &CheckBoxEvent, cx| {
let CheckBoxEvent::Toggled { state, .. } = event;
let nv = NodeValue::Boolean(*state == CheckState::Checked);
engine.update(cx, |engine, cx| {
let _ = engine.set_effect_param(effect, &input_id, nv, cx);
});
})
.detach();
}
ControlKind::Combo(combo) => {
let combo = combo.clone();
cx.subscribe(&combo, move |_, _, event: &ComboBoxEvent, cx| {
if let ComboBoxEvent::Selected { value, .. } = event {
// Integer combos carry the index; string combos map
// the picked option back to its string value.
let param = engine.update(cx, |engine, cx| {
engine
.effect_params(effect)
.unwrap_or_default()
.into_iter()
.find(|p| p.input_id == input_id)
});
let nv = match &param {
Some(p) if p.value_type == ValueType::StrCombo => {
let values = crate::oakui::effectchain::combo_values(p);
let haystack = if values.is_empty() {
crate::oakui::effectchain::combo_options(p)
} else {
values
};
NodeValue::StrCombo(
haystack.get(*value).cloned().unwrap_or_default(),
)
}
_ => NodeValue::Combo(*value as i64),
};
engine.update(cx, |engine, cx| {
let _ = engine.set_effect_param(effect, &input_id, nv, cx);
});
}
})
.detach();
}
ControlKind::Spin(spins) => {
let spins = spins.clone();
for (spin, channel) in spins {
let spin = spin.clone();
let channel = channel;
// Clone per iteration: each spinbox's closure owns its
// own engine / input id.
let engine = engine.clone();
let input_id = input_id.clone();
cx.subscribe(&spin, move |_, _, event: &SpinBoxEvent, cx| {
if let SpinBoxEvent::ValueChanged { value, .. } = event {
// Re-read the current value, patch the changed
// component, and write the whole value back.
let patched = engine.update(cx, |engine, cx| {
let params = engine.effect_params(effect).unwrap_or_default();
let current = params
.iter()
.find(|p| p.input_id == input_id)
.map(|p| p.value.clone())
.unwrap_or(NodeValue::None);
let patched = patch_component(&current, channel, value.to_f64());
engine.set_effect_param(effect, &input_id, patched, cx).is_ok()
});
let _ = patched;
}
})
.detach();
}
}
ControlKind::Text(_editor) => {
// The text field commits explicitly (the commit button in the
// row). No event subscription here: the params view is rebuilt
// on every card render, so committing on TextChanged would
// re-enter the engine update on the same frame the value is
// re-synced (an endless re-render loop).
}
ControlKind::PushButton | ControlKind::ReadOnly(_) => {}
}
}
}
/// Returns `value` with the component at `channel` replaced by `component`.
fn patch_component(value: &NodeValue, channel: usize, component: f64) -> NodeValue {
let mut out = value.clone();
match &mut out {
NodeValue::Vec2(v) if channel < 2 => v[channel] = component,
NodeValue::Vec3(v) if channel < 3 => v[channel] = component,
NodeValue::Vec4(v) if channel < 4 => v[channel] = component,
NodeValue::Color(v) if channel < 4 => v[channel] = component,
_ => {}
}
out
}
impl<E: AppEngine> Render for OfxParamsView<E> {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
self.sync_values(cx);
let mut body = div().flex().flex_col().gap_1().p_2();
let mut last_section: Option<(String, String)> = None;
for control in &self.controls {
if control.section != last_section {
last_section = control.section.clone();
if let Some((group, page)) = &last_section {
let title = if page.is_empty() {
group.clone()
} else if group.is_empty() {
page.clone()
} else {
format!("{group} · {page}")
};
body = body.child(
div()
.py_1()
.text_xs()
.font_weight(gpui::FontWeight(600.0))
.text_color(colors.selected)
.child(title),
);
}
}
// A push button renders full-width with its own label (the OFX
// param label IS the button text); every other control gets the
// label column + control layout.
let row_element: gpui::AnyElement = if matches!(control.kind, ControlKind::PushButton) {
let engine = self.engine.clone();
let effect = self.effect;
let input_id = control.input_id.clone();
let button_label = control.display_name.clone();
div()
.id(SharedString::from(format!("ofx-push-{}", control.input_id)))
.flex_1()
.cursor_pointer()
.rounded_sm()
.border_1()
.border_color(colors.border)
.bg(colors.selected)
.text_sm()
.text_color(colors.text)
.text_center()
.py_1()
.child(button_label)
.on_click(move |_event: &ClickEvent, _window, cx| {
engine.update(cx, |engine, cx| {
let _ = engine.effect_push_button(effect, &input_id, cx);
});
})
.into_any_element()
} else {
let label = div()
.flex_shrink_0()
.w(px(110.0))
.text_sm()
.text_color(colors.text)
.child(control.display_name.clone());
let widget = match &control.kind {
ControlKind::Slider(slider) => div().flex_1().child(slider.clone()).into_any_element(),
ControlKind::CheckBox(check) => div().flex_1().child(check.clone()).into_any_element(),
ControlKind::Combo(combo) => div().flex_1().child(combo.clone()).into_any_element(),
ControlKind::Spin(spins) => {
let mut row = div().flex_1().flex().gap_1();
for (spin, _) in spins {
row = row.child(div().flex_1().child(spin.clone()));
}
row.into_any_element()
}
ControlKind::Text(editor) => {
let weak = editor.downgrade();
let engine = self.engine.clone();
let effect = self.effect;
let input_id = control.input_id.clone();
let editor_commit = editor.clone();
div()
.flex_1()
.flex()
.gap_1()
.child(
div()
.flex_1()
.rounded_md()
.border_1()
.border_color(colors.border)
.bg(colors.background)
.px_2()
.py_1()
.child(text_input(format!("ofx-param-{}", control.input_id)).state(weak).accepts_input(true)),
)
.child(
// Explicit commit: reads the field and pushes the
// string to the engine (avoids the re-render loop of
// committing on every keystroke).
div()
.id(SharedString::from(format!("ofx-commit-{}", control.input_id)))
.cursor_pointer()
.rounded_sm()
.border_1()
.border_color(colors.border)
.bg(colors.selected)
.text_sm()
.text_color(colors.text)
.px_2()
.py_1()
.child("")
.on_click(move |_event: &ClickEvent, _window, cx| {
let text = editor_commit.read(cx).as_str().to_string();
engine.update(cx, |engine, cx| {
let _ = engine
.set_effect_param(effect, &input_id, NodeValue::Text(text), cx);
});
}),
)
.into_any_element()
}
ControlKind::ReadOnly(text) => div()
.flex_1()
.text_sm()
.text_color(colors.disabled)
.child(text.clone())
.into_any_element(),
ControlKind::PushButton => unreachable!("handled above"),
};
div()
.id(SharedString::from(format!("ofx-param-{}", control.input_id)))
.flex()
.items_center()
.gap_2()
.child(label)
.child(widget)
.into_any_element()
};
body = body.child(row_element);
}
if self.controls.is_empty() {
body = body.child(
div()
.text_sm()
.text_color(colors.disabled)
.child(crate::i18n::tr("inspector.params")),
);
}
body
}
}