feat(app): OFX Interact viewer integration - overlay drawing and event forwarding
- Main-process interact instances for the selected OFX effect card (create on selection change, describe, destroy on deselect/close), coexisting with the render-worker plugin instances per the OFX multi-instance model. - Program viewer composites the interact's overlay: draw into a GL FBO via gl_bridge, read back, straight-alpha 'over' composite onto the displayed frame; cached and only re-rendered on frame/time/ viewport/instance change or plugin redraw requests. - Event forwarding: picture-area pointer maps through the contain-fit letterbox inverse to OFX pen coordinates (pen_motion/down/up); Keystroke to OFX key symbols (ASCII, navigation, F1-F35) for key_down/up; a 50ms idle pump; global shortcut consumption keeps precedence. - e2e with the real test plugin: lifecycle marker assertions, pen/key event records, and macOS GL overlay compositing verified (265 tests green incl. gpui_widgets viewer suite).
This commit is contained in:
@@ -62,6 +62,7 @@ path = "src/lib.rs"
|
||||
# Doctests are disabled: the app links the oak* module crates (which carry
|
||||
# media/codec dependencies); the doc examples' assertions are covered by
|
||||
# unit tests instead (see `oakui/timecode`).
|
||||
doctest = false
|
||||
|
||||
[[bin]]
|
||||
name = "oak-editor"
|
||||
|
||||
@@ -37,4 +37,45 @@ fn main() {
|
||||
// at the real system library.
|
||||
println!("cargo:rustc-link-arg=-Wl,-rpath,/usr/lib");
|
||||
}
|
||||
// --- OFX interact end-to-end test plugin ---------------------------------
|
||||
// The app-side interact tests (src/oakui/ofx.rs) drive the *real*
|
||||
// minimal test plugin (crates/oakplugin/cbits/oak_test_plugin.c) through
|
||||
// the app's interact wiring. oakplugin compiles the same C file into its
|
||||
// own OUT_DIR, but build-script env vars do not cross crates, so compile
|
||||
// it here too — the test assembles a plugin bundle from the app's
|
||||
// OUT_DIR.
|
||||
println!("cargo:rerun-if-changed=crates/oakplugin/cbits/oak_test_plugin.c");
|
||||
build_test_plugin(&os);
|
||||
}
|
||||
|
||||
/// Compiles the minimal OFX test plugin as a shared library into
|
||||
/// `$OUT_DIR/oak_test_plugin.{dylib,so}` (same recipe as oakplugin's
|
||||
/// build.rs). Only the interact branch of the plugin is exercised by the
|
||||
/// app-side tests; the GL parts are macOS-gated inside the C source.
|
||||
fn build_test_plugin(os: &str) {
|
||||
use std::process::Command;
|
||||
let out = std::env::var("OUT_DIR").expect("OUT_DIR");
|
||||
let cc = std::env::var("CC").unwrap_or_else(|_| "cc".into());
|
||||
let (link_flag, ext) = if os == "macos" {
|
||||
("-dynamiclib", "dylib")
|
||||
} else {
|
||||
("-shared", "so")
|
||||
};
|
||||
let mut args = vec![
|
||||
"-fPIC".to_string(),
|
||||
"-Icrates/oakplugin/ofx".to_string(),
|
||||
"crates/oakplugin/cbits/oak_test_plugin.c".to_string(),
|
||||
link_flag.to_string(),
|
||||
"-o".to_string(),
|
||||
format!("{out}/oak_test_plugin.{ext}"),
|
||||
];
|
||||
if os == "macos" {
|
||||
args.push("-framework".into());
|
||||
args.push("OpenGL".into());
|
||||
}
|
||||
let status = Command::new(&cc)
|
||||
.args(&args)
|
||||
.status()
|
||||
.expect("compile OFX test plugin failed");
|
||||
assert!(status.success(), "OFX test plugin compile failed");
|
||||
}
|
||||
|
||||
@@ -318,8 +318,10 @@ fn interact_draw_renders_plugin_colours() {
|
||||
);
|
||||
|
||||
// 像素断言:矩形区域 (10..30)² 的 GL 像素 → 翻转后 frame y 33..53。
|
||||
// F32 RGBA 每像素 16 字节(紧凑行)。
|
||||
let stride = (w as usize) * 16;
|
||||
let px = |x: usize, y: usize| -> [f32; 4] {
|
||||
let p = &img.pixels()[y * (w as usize) * 4 + x * 4..y * (w as usize) * 4 + x * 4 + 4];
|
||||
let p = &img.pixels()[y * stride + x * 16..y * stride + x * 16 + 16];
|
||||
[
|
||||
f32::from_ne_bytes(p[0..4].try_into().unwrap()),
|
||||
f32::from_ne_bytes(p[4..8].try_into().unwrap()),
|
||||
|
||||
+10
@@ -423,6 +423,11 @@ const EN: &[(&str, &str)] = &[
|
||||
("inspector.badge.openfx", "OpenFX"),
|
||||
// --- OpenFX progress ---
|
||||
("ofx.progress.title", "OpenFX Plugin Progress"),
|
||||
// --- OpenFX color picker ---
|
||||
("ofx.color.hex", "Hex"),
|
||||
("ofx.color.ok", "OK"),
|
||||
("ofx.color.cancel", "Cancel"),
|
||||
("ofx.color.invalid", "Invalid hex color"),
|
||||
// --- dialogs ---
|
||||
("dialog.cancel", "Cancel"),
|
||||
("dialog.close", "Close"),
|
||||
@@ -893,6 +898,11 @@ const ZH: &[(&str, &str)] = &[
|
||||
("inspector.badge.openfx", "OpenFX"),
|
||||
// --- OpenFX progress ---
|
||||
("ofx.progress.title", "OpenFX 插件进度"),
|
||||
// --- OpenFX 取色器 ---
|
||||
("ofx.color.hex", "十六进制"),
|
||||
("ofx.color.ok", "确定"),
|
||||
("ofx.color.cancel", "取消"),
|
||||
("ofx.color.invalid", "无效的十六进制颜色"),
|
||||
// --- dialogs ---
|
||||
("dialog.cancel", "取消"),
|
||||
("dialog.close", "关闭"),
|
||||
|
||||
@@ -383,6 +383,17 @@ pub trait AppEngine:
|
||||
Err("effect push button not supported".into())
|
||||
}
|
||||
|
||||
/// The plugin instance handle (the oakplugin registry key) of the OFX
|
||||
/// effect the program viewer's interact should target — the inspector's
|
||||
/// current selection, i.e. the first expanded OFX plugin card in the
|
||||
/// selected clip's chain. `None` when there is no candidate (no
|
||||
/// project, no selected clip, no expanded plugin card). The program
|
||||
/// viewer feeds this to `oakui::ofx::sync_active_interact`; the default
|
||||
/// keeps engines without a plugin selection inert.
|
||||
fn ofx_interact_target(&self, _cx: &App) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Applies a node-editor edit request to the engine's model.
|
||||
fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context<Self>);
|
||||
|
||||
|
||||
+771
-4
@@ -39,13 +39,21 @@
|
||||
//! 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.
|
||||
//! serves the in-process render paths (e.g. the test-only inline backend).
|
||||
//! Worker-side progress is forwarded over the control plane: the worker
|
||||
//! installs its own reporter factory whose reporters stream
|
||||
//! `plugin_progress` NDJSON events to the dispatcher ([`init`] registers
|
||||
//! the dispatcher callback, which feeds the same channel as the inline
|
||||
//! reporter); the dialog's Cancel button broadcasts `plugin_cancel` to the
|
||||
//! workers ([`cancel_plugin_render`]).
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use gpui::{Keystroke, Point, RenderImage, Size};
|
||||
use oakplugin::progress::{ReporterFactory, UiProgressReporter};
|
||||
use oakplugin::suites::interact::Interact;
|
||||
use oakplugin::suites::status;
|
||||
use oakplugin::suites::timeline::{ActiveViewerProvider, ViewerTimeInfo};
|
||||
|
||||
/// One progress event a plugin reporter pushed to the app channel (drained
|
||||
@@ -132,9 +140,15 @@ pub fn update_project_extent(width: f64, height: f64) {
|
||||
}
|
||||
|
||||
/// Requests cancellation of the running plugin render (the progress
|
||||
/// dialog's Cancel button). The next progressStart resets the flag.
|
||||
/// dialog's Cancel button). The next progressStart resets the flag. Also
|
||||
/// forwards the cancel to the render workers (their progress reporters
|
||||
/// answer false from the next batch boundary on).
|
||||
pub fn cancel_plugin_render() {
|
||||
CANCEL.store(true, Ordering::Relaxed);
|
||||
// The worker-process plugin renders are cancelled through the control
|
||||
// plane (`plugin_cancel`); the in-flight frame completes (batch
|
||||
// granularity — the worker processes messages between batches).
|
||||
oakrender::procpool::request_plugin_cancel_all();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -206,13 +220,410 @@ pub fn init() -> usize {
|
||||
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
|
||||
// 5. Worker-forwarded plugin progress (the render workers run plugin
|
||||
// renders in their own process and stream `plugin_progress` NDJSON
|
||||
// events over the control plane; the dispatcher hands them to this
|
||||
// callback, which feeds the same channel the inline reporter uses).
|
||||
oakrender::procpool::set_plugin_progress_cb(Some(Arc::new(|label, message, fraction| {
|
||||
if let Some(tx) = progress_tx() {
|
||||
let _ = tx.send(PluginProgressEvent {
|
||||
label,
|
||||
message,
|
||||
fraction,
|
||||
});
|
||||
}
|
||||
})));
|
||||
// 6. 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()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main-process Interact instance management (the program viewer's overlay +
|
||||
// event target)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The viewport parameters of an interact: the plugin's draw/pen coordinate
|
||||
/// space. `width`/`height` are the viewport size in pixels (the displayed
|
||||
/// frame's pixel grid), `pixel_scale` is the canonical→pixel ratio (1.0 at
|
||||
/// 1:1, the offscreen buffer's scale).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct InteractViewport {
|
||||
/// Viewport width in pixels.
|
||||
pub width: f64,
|
||||
/// Viewport height in pixels.
|
||||
pub height: f64,
|
||||
/// Canonical→pixel scale (`(1.0, 1.0)` at 1:1).
|
||||
pub pixel_scale: (f64, f64),
|
||||
}
|
||||
|
||||
impl InteractViewport {
|
||||
/// The 1:1 viewport matching a frame of `width`×`height` pixels.
|
||||
pub fn at_frame_size(width: u32, height: u32) -> Self {
|
||||
Self {
|
||||
width: width as f64,
|
||||
height: height as f64,
|
||||
pixel_scale: (1.0, 1.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The app-side holder of the selected effect's live interact.
|
||||
///
|
||||
/// The interact is created on the *main process* `oakplugin::Instance` of
|
||||
/// the selected plugin effect node (the same registry the inspector's
|
||||
/// push-button path uses), distinct from the worker-process render
|
||||
/// instances — OFX allows a plugin to have several instances, and the
|
||||
/// interact is a UI-event host, not a renderer.
|
||||
struct ActiveInteract {
|
||||
/// The oakplugin instance registry key (the plugin node's
|
||||
/// `plugin_instance_handle`).
|
||||
instance: u64,
|
||||
/// The live interact (created via `Instance::new_interact`).
|
||||
interact: Arc<Interact>,
|
||||
/// The viewport of the last overlay draw (updated on every successful
|
||||
/// composite; the source of [`active_interact`]'s viewport).
|
||||
viewport: InteractViewport,
|
||||
}
|
||||
|
||||
/// The single active interact: at most one plugin's custom UI is on the
|
||||
/// program viewer at a time (the inspector's current selection target).
|
||||
static ACTIVE_INTERACT: OnceLock<Mutex<Option<ActiveInteract>>> = OnceLock::new();
|
||||
|
||||
fn active_interact_slot() -> &'static Mutex<Option<ActiveInteract>> {
|
||||
ACTIVE_INTERACT.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
/// Recomputes the active interact for the current selection target. The
|
||||
/// program viewer calls this from its frame sync with the selected OFX
|
||||
/// effect's plugin instance handle (or `None` when the selection has no
|
||||
/// plugin candidate — no selected clip, the chain has no OFX plugin card,
|
||||
/// or the project closed).
|
||||
///
|
||||
/// Creates the interact on the target instance (`new_interact` →
|
||||
/// describe → create_instance; a plugin without an interact yields `None`
|
||||
/// and stays inert — a normal no-op), and destroys the previous one when
|
||||
/// the target changed. No-op while the target is unchanged.
|
||||
pub fn sync_active_interact(instance: Option<u64>) {
|
||||
// Unchanged target: nothing to do.
|
||||
{
|
||||
let mut slot = active_interact_slot().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if slot
|
||||
.as_ref()
|
||||
.is_some_and(|active| Some(active.instance) == instance)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Target changed or cleared: destroy the old interact first (the
|
||||
// kOfxActionDestroyInstanceInteract notification; idempotent). The
|
||||
// app lock is released before the plugin call — a plugin callback
|
||||
// must never re-enter this slot.
|
||||
if let Some(active) = slot.take() {
|
||||
drop(slot);
|
||||
active.interact.destroy();
|
||||
}
|
||||
}
|
||||
let Some(id) = instance else {
|
||||
return;
|
||||
};
|
||||
let Some(inst) = oakplugin::node_factory::instance_from_id(id) else {
|
||||
// The node/instance is gone (effect deleted): nothing to attach to.
|
||||
return;
|
||||
};
|
||||
let Some(interact) = inst.value.new_interact() else {
|
||||
// The plugin has no interact: normal no-op.
|
||||
return;
|
||||
};
|
||||
// Complete the instance sequence (ofxInteract.h: NewInteract →
|
||||
// Describe → CreateInstance before any draw/pen/key action).
|
||||
interact.describe();
|
||||
interact.create_instance();
|
||||
let mut slot = active_interact_slot().lock().unwrap_or_else(|e| e.into_inner());
|
||||
*slot = Some(ActiveInteract {
|
||||
instance: id,
|
||||
interact,
|
||||
viewport: InteractViewport {
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
pixel_scale: (1.0, 1.0),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// The active interact: `(instance, interact, viewport)`, or `None` when
|
||||
/// no interact is live (no selection target, or the plugin has no
|
||||
/// interact). The viewport reports the last drawn size and updates as the
|
||||
/// viewer's frame size changes.
|
||||
pub fn active_interact() -> Option<(u64, Arc<Interact>, InteractViewport)> {
|
||||
let slot = active_interact_slot().lock().unwrap_or_else(|e| e.into_inner());
|
||||
slot.as_ref().map(|a| (a.instance, a.interact.clone(), a.viewport))
|
||||
}
|
||||
|
||||
/// Records the viewport the interact was last drawn at (keeps
|
||||
/// [`active_interact`]'s viewport current).
|
||||
fn note_interact_viewport(instance: u64, viewport: InteractViewport) {
|
||||
let mut slot = active_interact_slot().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(active) = slot.as_mut() {
|
||||
if active.instance == instance {
|
||||
active.viewport = viewport;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overlay rendering + compositing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Converts the viewer's BGRA8 [`RenderImage`] (the engine's display
|
||||
/// format) into tightly packed F32 RGBA `(width, height, samples)` for the
|
||||
/// compositing path. Returns `None` when the image has no CPU bytes.
|
||||
fn bgra_image_to_f32_rgba(img: &RenderImage) -> Option<(u32, u32, Vec<f32>)> {
|
||||
let bytes = img.as_bytes(0)?;
|
||||
let size = img.size(0);
|
||||
let w = size.width.0 as usize;
|
||||
let h = size.height.0 as usize;
|
||||
let expected = w * h * 4;
|
||||
if bytes.len() < expected {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(expected);
|
||||
for px in bytes[..expected].chunks_exact(4) {
|
||||
out.push(px[2] as f32 / 255.0); // R
|
||||
out.push(px[1] as f32 / 255.0); // G
|
||||
out.push(px[0] as f32 / 255.0); // B
|
||||
out.push(px[3] as f32 / 255.0); // A
|
||||
}
|
||||
Some((w as u32, h as u32, out))
|
||||
}
|
||||
|
||||
/// Reads an `oakplugin` F32 RGBA image into a tightly packed `Vec<f32>`.
|
||||
fn read_image_f32(img: &oakplugin::image::Image) -> Vec<f32> {
|
||||
img.pixels()
|
||||
.chunks_exact(4)
|
||||
.map(|c| f32::from_ne_bytes(c[0..4].try_into().unwrap()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Composites the straight-alpha overlay over `base` (both tightly packed
|
||||
/// F32 RGBA, same length) with non-premultiplied "source-over":
|
||||
///
|
||||
/// ```text
|
||||
/// out.rgb = src.rgb * src.a + dst.rgb * (1 - src.a)
|
||||
/// out.a = src.a + dst.a * (1 - src.a)
|
||||
/// ```
|
||||
///
|
||||
/// # Why straight alpha
|
||||
///
|
||||
/// The plugin draws into a fresh RGBA32F FBO with GL blend factors
|
||||
/// `SRC_ALPHA` / `ONE_MINUS_SRC_ALPHA` (ofxDrawSuite.h's "over"
|
||||
/// compositing) after a transparent-black clear, so the readback holds
|
||||
/// *straight* (non-premultiplied) alpha. The formula above is the matching
|
||||
/// straight-alpha "over". `base` is the displayed frame (alpha 1.0), so
|
||||
/// the result alpha stays 1.0. Returns `None` on a length mismatch.
|
||||
pub fn composite_overlay(overlay: &[f32], base: &[f32]) -> Option<Vec<f32>> {
|
||||
if overlay.len() != base.len() || overlay.len() % 4 != 0 {
|
||||
return None;
|
||||
}
|
||||
let mut out = vec![0.0f32; overlay.len()];
|
||||
for i in (0..overlay.len()).step_by(4) {
|
||||
let a = overlay[i + 3].clamp(0.0, 1.0);
|
||||
let dst_a = base[i + 3].clamp(0.0, 1.0);
|
||||
out[i] = overlay[i] * a + base[i] * (1.0 - a);
|
||||
out[i + 1] = overlay[i + 1] * a + base[i + 1] * (1.0 - a);
|
||||
out[i + 2] = overlay[i + 2] * a + base[i + 2] * (1.0 - a);
|
||||
out[i + 3] = a + dst_a * (1.0 - a);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Renders the interact's overlay into a fresh offscreen FBO (RGBA32F) and
|
||||
/// returns the frame composited over `base` as a BGRA8 [`RenderImage`] for
|
||||
/// the viewer.
|
||||
///
|
||||
/// # The GL sequence
|
||||
///
|
||||
/// ```text
|
||||
/// app acquire → CGL current (whole sequence)
|
||||
/// create_output_texture → RGBA32F output texture (real GL name)
|
||||
/// create_fbo / bind → the plugin draws into this FBO
|
||||
/// clear to transparent 0 → straight-alpha "over" of the strokes
|
||||
/// Interact::draw → the plugin's draw action (re-acquires GL
|
||||
/// re-entrantly on the same thread, draws via
|
||||
/// native GL + the Draw suite)
|
||||
/// read_pixels_to_image → straight-alpha F32 RGBA overlay
|
||||
/// composite_overlay → alpha "over" the current frame
|
||||
/// ```
|
||||
///
|
||||
/// The app holds one guard across the whole sequence so the readback stays
|
||||
/// on the plugin's context; `Interact::draw` re-enters the guard per its
|
||||
/// own contract (gl_bridge nesting avoids the deadlock). Returns `None`
|
||||
/// when the interact is inert — no GL, a viewport/frame mismatch, the
|
||||
/// plugin failed, or draw returned a non-OK status — in which case the
|
||||
/// caller shows the base frame unchanged.
|
||||
pub fn draw_interact_composite(
|
||||
instance: u64,
|
||||
interact: &Interact,
|
||||
viewport: &InteractViewport,
|
||||
time: f64,
|
||||
base: &RenderImage,
|
||||
) -> Option<Arc<RenderImage>> {
|
||||
let (w, h) = (viewport.width as i32, viewport.height as i32);
|
||||
if w <= 0 || h <= 0 {
|
||||
return None;
|
||||
}
|
||||
let (bw, bh, base_f32) = bgra_image_to_f32_rgba(base)?;
|
||||
if bw != w as u32 || bh != h as u32 {
|
||||
return None;
|
||||
}
|
||||
let mut params = oakplugin::render::VideoParams::default();
|
||||
params.width = w;
|
||||
params.height = h;
|
||||
params.format = oakplugin::render::PIXEL_FORMAT_F32;
|
||||
|
||||
let _guard = oakplugin::gl_bridge::acquire().ok()?;
|
||||
let tex = oakplugin::gl_bridge::create_output_texture(w, h, ¶ms).ok()?;
|
||||
let fbo = match oakplugin::gl_bridge::create_fbo(tex, w, h) {
|
||||
Ok(fbo) => fbo,
|
||||
Err(_) => {
|
||||
oakplugin::gl_bridge::delete_gl_texture(tex);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
oakplugin::gl_bridge::bind_fbo(fbo);
|
||||
oakplugin::gl_bridge::set_viewport(w, h);
|
||||
// Clear to transparent black: the plugin's strokes composite "over"
|
||||
// nothing, so the readback holds straight alpha (see
|
||||
// [`composite_overlay`]).
|
||||
oakplugin::gl_bridge::gl_clear_color(0.0, 0.0, 0.0, 0.0);
|
||||
oakplugin::gl_bridge::gl_clear();
|
||||
|
||||
let st = interact.draw(
|
||||
(viewport.width, viewport.height),
|
||||
viewport.pixel_scale,
|
||||
time,
|
||||
// Background image: Phase 1 passes no composited background (the
|
||||
// interact draws over a flat transparent clear; the frame is
|
||||
// composited on the app side afterwards).
|
||||
None,
|
||||
);
|
||||
let overlay = oakplugin::gl_bridge::read_pixels_to_image(w, h, ¶ms);
|
||||
oakplugin::gl_bridge::delete_fbo(fbo);
|
||||
oakplugin::gl_bridge::delete_gl_texture(tex);
|
||||
let (Ok(overlay), st) = (overlay, st) else {
|
||||
return None;
|
||||
};
|
||||
if st != status::OK {
|
||||
return None;
|
||||
}
|
||||
let merged = composite_overlay(&read_image_f32(&overlay), &base_f32)?;
|
||||
note_interact_viewport(instance, *viewport);
|
||||
Some(Arc::new(super::frames::f32_rgba_to_bgra_image(bw, bh, &merged)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event forwarding (pen + key) and the idle pump
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Maps a pointer position in the picture area (local pixels, top-left
|
||||
/// origin) to the OFX pen coordinates — the plugin's viewport pixels (the
|
||||
/// displayed frame's pixel grid). The picture shows the frame with a
|
||||
/// "contain" fit (letterboxed), so the mapping is the inverse of the
|
||||
/// object-fit scale plus centering. Returns `None` when the pointer is in
|
||||
/// the letterbox (outside the frame's pixel rect).
|
||||
pub fn viewport_pixel_to_pen(
|
||||
local: Point<f32>,
|
||||
area: Size<f32>,
|
||||
frame: Size<f32>,
|
||||
) -> Option<(f64, f64)> {
|
||||
let (aw, ah) = (area.width as f64, area.height as f64);
|
||||
let (fw, fh) = (frame.width as f64, frame.height as f64);
|
||||
if aw <= 0.0 || ah <= 0.0 || fw <= 0.0 || fh <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let scale = (aw / fw).min(ah / fh);
|
||||
let offset_x = (aw - fw * scale) / 2.0;
|
||||
let offset_y = (ah - fh * scale) / 2.0;
|
||||
let fx = (local.x as f64 - offset_x) / scale;
|
||||
let fy = (local.y as f64 - offset_y) / scale;
|
||||
if fx < 0.0 || fy < 0.0 || fx >= fw || fy >= fh {
|
||||
return None;
|
||||
}
|
||||
Some((fx, fy))
|
||||
}
|
||||
|
||||
/// Maps a gpui keystroke to the OFX key symbol (`ofxKeySyms.h` values from
|
||||
/// `oakplugin::host::KEY_*`) and the key-string character
|
||||
/// (`kOfxPropKeyString`: the UTF-8 character, empty for keys without one).
|
||||
///
|
||||
/// Covers the common keys — alphanumerics, the arrows, return, escape,
|
||||
/// backspace/delete, tab, home/end, page up/down and the function keys;
|
||||
/// anything else maps to [`oakplugin::host::KEY_UNKNOWN`] with an empty
|
||||
/// string.
|
||||
pub fn key_symbol(keystroke: &Keystroke) -> (i32, String) {
|
||||
use oakplugin::host as ofx_key;
|
||||
let key = keystroke.key.as_str();
|
||||
let named = match key {
|
||||
"space" => Some(ofx_key::KEY_SPACE),
|
||||
"enter" | "return" => Some(ofx_key::KEY_RETURN),
|
||||
"escape" => Some(ofx_key::KEY_ESCAPE),
|
||||
"backspace" => Some(ofx_key::KEY_BACKSPACE),
|
||||
"delete" => Some(ofx_key::KEY_DELETE),
|
||||
"tab" => Some(ofx_key::KEY_TAB),
|
||||
"home" => Some(ofx_key::KEY_HOME),
|
||||
"end" => Some(ofx_key::KEY_END),
|
||||
"left" => Some(ofx_key::KEY_LEFT),
|
||||
"right" => Some(ofx_key::KEY_RIGHT),
|
||||
"up" => Some(ofx_key::KEY_UP),
|
||||
"down" => Some(ofx_key::KEY_DOWN),
|
||||
"pageup" => Some(ofx_key::KEY_PAGE_UP),
|
||||
"pagedown" => Some(ofx_key::KEY_PAGE_DOWN),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(sym) = named {
|
||||
return (sym, String::new());
|
||||
}
|
||||
// Function keys: f1..f35 → KEY_F1 + (n-1).
|
||||
if let Some(rest) = key.strip_prefix('f') {
|
||||
if let Ok(n) = rest.parse::<u32>() {
|
||||
if (1..=35).contains(&n) {
|
||||
return (ofx_key::KEY_F1 + (n as i32 - 1), String::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Printable single-character keys (letters/digits/punctuation): the
|
||||
// symbol is the ASCII code (ofxKeySyms maps printable ASCII 1:1;
|
||||
// KEY_A=0x61 … KEY_Z=0x7a, KEY_SPACE=0x20), the string is the char.
|
||||
if key.len() == 1 && key.is_ascii() {
|
||||
return (key.as_bytes()[0] as i32, key.to_string());
|
||||
}
|
||||
(ofx_key::KEY_UNKNOWN, String::new())
|
||||
}
|
||||
|
||||
/// The idle pump for the active interact. The program viewer calls this
|
||||
/// from its throttled timer (~10 Hz, not every render): the app forwards
|
||||
/// `kOfxInteractActionIdle` so the plugin can run lightweight UI work
|
||||
/// (marquee feedback, cursor animation). No-op without an active interact.
|
||||
pub fn pump_interact_idle() {
|
||||
use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
|
||||
// Throttle to ~10 Hz: the panel timer may fire faster than the OFX
|
||||
// idle cadence, and idle is meant to run when the app is otherwise
|
||||
// quiet, not every animation frame.
|
||||
static LAST_IDLE_MS: AtomicU64 = AtomicU64::new(0);
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
if now.saturating_sub(LAST_IDLE_MS.load(AtomicOrdering::Relaxed)) < 100 {
|
||||
return;
|
||||
}
|
||||
LAST_IDLE_MS.store(now, AtomicOrdering::Relaxed);
|
||||
if let Some((_, interact, _)) = active_interact() {
|
||||
let _ = interact.idle();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -245,4 +656,360 @@ mod tests {
|
||||
let slot = extent_slot();
|
||||
assert_eq!(*slot.lock().unwrap(), (1280.0, 720.0));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interact viewport mapping / key symbols / compositing (pure)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use gpui::{point, size};
|
||||
|
||||
/// 视口像素 → pen 坐标:the picture shows the frame with a contain fit,
|
||||
/// so a 2:1 area/frame scale maps local pixels 1:2 to frame pixels, and
|
||||
/// the letterbox returns None.
|
||||
#[test]
|
||||
fn viewport_pixel_to_pen_maps_contain_fit() {
|
||||
let area = size(640.0, 360.0);
|
||||
let frame = size(320.0, 180.0);
|
||||
// Frame fills the area at scale 2 (no letterbox): local 40,40 → 20,20.
|
||||
let p = viewport_pixel_to_pen(point(40.0, 40.0), area, frame);
|
||||
assert_eq!(p, Some((20.0, 20.0)));
|
||||
// Corner maps to the frame corner.
|
||||
let p = viewport_pixel_to_pen(point(0.0, 0.0), area, frame);
|
||||
assert_eq!(p, Some((0.0, 0.0)));
|
||||
let p = viewport_pixel_to_pen(point(640.0, 360.0), area, frame);
|
||||
// Exclusive upper bound: exactly the far edge is outside the frame.
|
||||
assert_eq!(p, None);
|
||||
}
|
||||
|
||||
/// Letterboxing: a 4:3 area showing a 16:9 frame leaves vertical bars;
|
||||
/// pointers in the bars map to None, the scaled rect maps 1:2.
|
||||
#[test]
|
||||
fn viewport_pixel_to_pen_respects_letterbox() {
|
||||
let area = size(640.0, 480.0);
|
||||
let frame = size(320.0, 180.0);
|
||||
// Scale = min(640/320, 480/180) = 2; the frame occupies 640×360,
|
||||
// leaving 60px top/bottom bars.
|
||||
let p = viewport_pixel_to_pen(point(60.0, 240.0), area, frame);
|
||||
assert_eq!(p, Some((30.0, 90.0)));
|
||||
// Inside the top letterbox bar.
|
||||
let p = viewport_pixel_to_pen(point(320.0, 10.0), area, frame);
|
||||
assert_eq!(p, None);
|
||||
// Zero-size inputs.
|
||||
assert_eq!(viewport_pixel_to_pen(point(0.0, 0.0), size(0.0, 0.0), frame), None);
|
||||
assert_eq!(viewport_pixel_to_pen(point(0.0, 0.0), area, size(0.0, 0.0)), None);
|
||||
}
|
||||
|
||||
/// Key mapping: the common keys land on the ofxKeySyms values; printable
|
||||
/// single characters carry their ASCII symbol + the character string.
|
||||
#[test]
|
||||
fn key_symbol_maps_common_keys() {
|
||||
use oakplugin::host as ofx_key;
|
||||
let ks = |key: &str| gpui::Keystroke::parse(key).unwrap();
|
||||
// Alphanumerics: symbol = ASCII code, string = the char.
|
||||
assert_eq!(key_symbol(&ks("a")), (ofx_key::KEY_A, "a".to_string()));
|
||||
assert_eq!(key_symbol(&ks("z")), (ofx_key::KEY_Z, "z".to_string()));
|
||||
assert_eq!(key_symbol(&ks("1")), (b'1' as i32, "1".to_string()));
|
||||
// Named keys: symbol only, empty string.
|
||||
assert_eq!(key_symbol(&ks("space")), (ofx_key::KEY_SPACE, String::new()));
|
||||
assert_eq!(key_symbol(&ks("enter")), (ofx_key::KEY_RETURN, String::new()));
|
||||
assert_eq!(key_symbol(&ks("escape")), (ofx_key::KEY_ESCAPE, String::new()));
|
||||
assert_eq!(key_symbol(&ks("backspace")), (ofx_key::KEY_BACKSPACE, String::new()));
|
||||
assert_eq!(key_symbol(&ks("delete")), (ofx_key::KEY_DELETE, String::new()));
|
||||
assert_eq!(key_symbol(&ks("left")), (ofx_key::KEY_LEFT, String::new()));
|
||||
assert_eq!(key_symbol(&ks("right")), (ofx_key::KEY_RIGHT, String::new()));
|
||||
assert_eq!(key_symbol(&ks("up")), (ofx_key::KEY_UP, String::new()));
|
||||
assert_eq!(key_symbol(&ks("down")), (ofx_key::KEY_DOWN, String::new()));
|
||||
assert_eq!(key_symbol(&ks("home")), (ofx_key::KEY_HOME, String::new()));
|
||||
assert_eq!(key_symbol(&ks("end")), (ofx_key::KEY_END, String::new()));
|
||||
assert_eq!(key_symbol(&ks("pageup")), (ofx_key::KEY_PAGE_UP, String::new()));
|
||||
assert_eq!(key_symbol(&ks("pagedown")), (ofx_key::KEY_PAGE_DOWN, String::new()));
|
||||
assert_eq!(key_symbol(&ks("tab")), (ofx_key::KEY_TAB, String::new()));
|
||||
assert_eq!(key_symbol(&ks("f1")), (ofx_key::KEY_F1, String::new()));
|
||||
assert_eq!(key_symbol(&ks("f12")), (ofx_key::KEY_F1 + 11, String::new()));
|
||||
// Unknown multi-character keys.
|
||||
let (sym, s) = key_symbol(&ks("insert"));
|
||||
assert_eq!(sym, ofx_key::KEY_UNKNOWN);
|
||||
assert!(s.is_empty());
|
||||
}
|
||||
|
||||
/// Straight-alpha "over" compositing: the overlay replaces the base where
|
||||
/// it is opaque, blends where it is translucent, and stays 1.0 alpha.
|
||||
#[test]
|
||||
fn composite_overlay_blends_alpha() {
|
||||
// Opaque red over blue → red.
|
||||
let overlay = [1.0, 0.0, 0.0, 1.0];
|
||||
let base = [0.0, 0.0, 1.0, 1.0];
|
||||
let out = composite_overlay(&overlay, &base).unwrap();
|
||||
assert_eq!(out, [1.0, 0.0, 0.0, 1.0]);
|
||||
// Half-alpha red over blue → 0.5 red + 0.5 blue.
|
||||
let overlay = [1.0, 0.0, 0.0, 0.5];
|
||||
let out = composite_overlay(&overlay, &base).unwrap();
|
||||
for (i, expected) in [(0, 0.5), (1, 0.0), (2, 0.5), (3, 1.0)] {
|
||||
assert!((out[i] - expected).abs() < 1e-6, "channel {i}: {} != {expected}", out[i]);
|
||||
}
|
||||
// Transparent overlay → base unchanged.
|
||||
let overlay = [0.0, 0.0, 0.0, 0.0];
|
||||
assert_eq!(composite_overlay(&overlay, &base).unwrap(), base);
|
||||
// Length mismatch → None.
|
||||
assert!(composite_overlay(&[0.0; 4], &[0.0; 8]).is_none());
|
||||
}
|
||||
|
||||
/// The engine frame's BGRA8 → F32 RGBA conversion round-trips through
|
||||
/// the viewer format (the compositing path's input).
|
||||
#[test]
|
||||
fn bgra_frame_roundtrips_to_f32_rgba() {
|
||||
let (w, h) = (2u32, 1u32);
|
||||
let samples = [1.0, 0.0, 0.5, 1.0, 0.25, 0.5, 0.75, 1.0];
|
||||
let img = super::super::frames::f32_rgba_to_bgra_image(w, h, &samples);
|
||||
let (got_w, got_h, rgba) = bgra_image_to_f32_rgba(&img).unwrap();
|
||||
assert_eq!((got_w, got_h), (w, h));
|
||||
for (i, expected) in [(0, 1.0), (1, 0.0), (2, 0.5), (3, 1.0), (4, 0.25), (5, 0.5), (6, 0.75), (7, 1.0)] {
|
||||
assert!((rgba[i] - expected).abs() < 0.01, "channel {i}: {} != {expected}", rgba[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// End-to-end with the minimal test plugin (cbits/oak_test_plugin.c)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const INTERACT_PLUGIN_ID: &str = "org.oak.test-plugin.interact";
|
||||
/// The plugin records every interact action into the file this env var
|
||||
/// names.
|
||||
const MARKER_ENV: &str = "OAK_TEST_PLUGIN_INTERACT_MARKER";
|
||||
/// Serializes the process-global Host singleton across host-touching
|
||||
/// tests (the plugin cache is a process singleton with no lock).
|
||||
static HOST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// The minimal test plugin's scan directory (the bundle is assembled
|
||||
/// from the dylib the app build script compiles into OUT_DIR), or `None`
|
||||
/// when the plugin is unavailable (the test skips).
|
||||
fn test_plugin_scan_dir() -> Option<std::path::PathBuf> {
|
||||
let out = std::path::PathBuf::from(env!("OUT_DIR"));
|
||||
let lib = if cfg!(target_os = "macos") {
|
||||
out.join("oak_test_plugin.dylib")
|
||||
} else {
|
||||
out.join("oak_test_plugin.so")
|
||||
};
|
||||
if !lib.is_file() {
|
||||
return None;
|
||||
}
|
||||
let bundle = std::env::temp_dir()
|
||||
.join(format!("oak-app-test-plugin-{}", std::process::id()))
|
||||
.join("oak-test-plugin.ofx.bundle");
|
||||
let platform = if cfg!(target_os = "macos") {
|
||||
"MacOS"
|
||||
} else {
|
||||
"Linux-x86-64"
|
||||
};
|
||||
let bin_dir = bundle.join("Contents").join(platform);
|
||||
std::fs::create_dir_all(&bin_dir).ok()?;
|
||||
let target = bin_dir.join("plugin");
|
||||
if !target.exists() {
|
||||
std::fs::copy(&lib, &target).ok()?;
|
||||
}
|
||||
Some(bundle.parent().unwrap().to_path_buf())
|
||||
}
|
||||
|
||||
fn scan_interact_plugin() -> bool {
|
||||
let Some(dir) = test_plugin_scan_dir() else {
|
||||
println!("SKIP: minimal test plugin not built");
|
||||
return false;
|
||||
};
|
||||
if oakplugin::host::Host::global().cache.scan_path(&dir).is_err() {
|
||||
println!("SKIP: test plugin scan failed");
|
||||
return false;
|
||||
}
|
||||
oakplugin::node_factory::register_plugin_nodes();
|
||||
true
|
||||
}
|
||||
|
||||
fn marker_path(tag: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"oak-app-interact-{}-{}.log",
|
||||
std::process::id(),
|
||||
tag
|
||||
))
|
||||
}
|
||||
|
||||
fn read_marker(path: &std::path::Path) -> Vec<String> {
|
||||
std::fs::read_to_string(path)
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.map(|l| l.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// App-layer interact creation + synthetic event forwarding, asserting
|
||||
/// the plugin really received each action and its arguments (the marker
|
||||
/// file the plugin appends to).
|
||||
#[test]
|
||||
fn interact_e2e_lifecycle_and_event_forwarding() {
|
||||
let _lock = HOST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !scan_interact_plugin() {
|
||||
return;
|
||||
}
|
||||
let marker = marker_path("e2e");
|
||||
let _ = std::fs::remove_file(&marker);
|
||||
unsafe { std::env::set_var(MARKER_ENV, &marker) };
|
||||
|
||||
// (1) app-layer creation: `sync_active_interact` on the plugin
|
||||
// instance handle creates the interact (new_interact → describe →
|
||||
// create_instance).
|
||||
let inst = oakplugin::host::Host::global()
|
||||
.create_instance(INTERACT_PLUGIN_ID, None)
|
||||
.expect("interact variant instance");
|
||||
let handle = oakplugin::node_factory::register_instance(inst);
|
||||
sync_active_interact(Some(handle));
|
||||
|
||||
let (active_handle, interact, _viewport) =
|
||||
active_interact().expect("active interact created");
|
||||
assert_eq!(active_handle, handle);
|
||||
|
||||
// (2) synthetic mouse forwarding: the picture-local → pen mapping
|
||||
// (the panel's forward path) then the pen actions, with the pen-down
|
||||
// state carried by the move's button state.
|
||||
let area = size(640.0, 360.0);
|
||||
let frame = size(320.0, 180.0);
|
||||
let (px, py) =
|
||||
viewport_pixel_to_pen(point(40.0, 40.0), area, frame).expect("contain fit maps 2:1");
|
||||
assert_eq!((px, py), (20.0, 20.0));
|
||||
assert_eq!(interact.pen_motion((px, py), true, 5.0), status::OK);
|
||||
assert_eq!(interact.pen_down((px, py), 5.0), status::OK);
|
||||
assert_eq!(interact.pen_up((px, py), 5.0), status::OK);
|
||||
|
||||
// (3) keys and idle.
|
||||
assert_eq!(
|
||||
interact.key_down(oakplugin::host::KEY_A, "a", 5.0),
|
||||
status::OK
|
||||
);
|
||||
assert_eq!(
|
||||
interact.key_up(oakplugin::host::KEY_A, "a", 5.0),
|
||||
status::OK
|
||||
);
|
||||
assert_eq!(interact.idle(), status::OK);
|
||||
|
||||
// (4) clearing the selection destroys the interact.
|
||||
sync_active_interact(None);
|
||||
assert!(
|
||||
active_interact().is_none(),
|
||||
"clearing the selection should destroy the active interact"
|
||||
);
|
||||
|
||||
unsafe { std::env::remove_var(MARKER_ENV) };
|
||||
let lines = read_marker(&marker);
|
||||
let _ = std::fs::remove_file(&marker);
|
||||
|
||||
// Lifecycle reached the plugin, in order.
|
||||
let seq = ["new_interact", "describe", "create", "destroy"];
|
||||
let pos: Vec<usize> = seq
|
||||
.iter()
|
||||
.map(|s| lines.iter().position(|l| l == s))
|
||||
.collect::<Option<Vec<_>>>()
|
||||
.unwrap_or_else(|| panic!("lifecycle actions missing: {lines:?}"));
|
||||
assert!(
|
||||
pos.windows(2).all(|w| w[0] < w[1]),
|
||||
"lifecycle order should be new_interact→describe→create→destroy"
|
||||
);
|
||||
|
||||
// The forwarded events with real arguments (C %g drops trailing
|
||||
// zeros).
|
||||
assert!(
|
||||
lines.iter().any(|l| l == "pen_motion vp=20,20 canon=20,20 pressure=1"),
|
||||
"pen_motion not recorded with pen-down state: {lines:?}"
|
||||
);
|
||||
assert!(
|
||||
lines.iter().any(|l| l == "pen_down vp=20,20 canon=20,20 pressure=1"),
|
||||
"pen_down not recorded: {lines:?}"
|
||||
);
|
||||
assert!(
|
||||
lines.iter().any(|l| l == "pen_up vp=20,20 canon=20,20 pressure=0"),
|
||||
"pen_up not recorded: {lines:?}"
|
||||
);
|
||||
assert!(
|
||||
lines.iter().any(|l| l == "key_down sym=97 str=a"),
|
||||
"key_down not recorded: {lines:?}"
|
||||
);
|
||||
assert!(
|
||||
lines.iter().any(|l| l == "idle"),
|
||||
"idle not recorded: {lines:?}"
|
||||
);
|
||||
|
||||
oakplugin::host::Host::global().shutdown();
|
||||
}
|
||||
|
||||
/// End-to-end overlay: the plugin's draw action really renders into the
|
||||
/// offscreen FBO, and the composite over a synthetic base frame carries
|
||||
/// the plugin-drawn colours (macOS real GL; gated by `OAK_GPU_TESTS`).
|
||||
#[test]
|
||||
fn interact_e2e_draw_overlay_composites_plugin_colours() {
|
||||
let _lock = HOST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !scan_interact_plugin() {
|
||||
return;
|
||||
}
|
||||
if !cfg!(target_os = "macos") || std::env::var_os("OAK_GPU_TESTS").is_none() {
|
||||
println!("SKIP: draw overlay needs macOS GL (set OAK_GPU_TESTS)");
|
||||
return;
|
||||
}
|
||||
let marker = marker_path("draw");
|
||||
let _ = std::fs::remove_file(&marker);
|
||||
unsafe { std::env::set_var(MARKER_ENV, &marker) };
|
||||
|
||||
let inst = oakplugin::host::Host::global()
|
||||
.create_instance(INTERACT_PLUGIN_ID, None)
|
||||
.expect("interact variant instance");
|
||||
let handle = oakplugin::node_factory::register_instance(inst);
|
||||
sync_active_interact(Some(handle));
|
||||
let (_, interact, _) = active_interact().expect("active interact created");
|
||||
|
||||
// A 64×64 opaque blue base frame.
|
||||
let (w, h) = (64u32, 64u32);
|
||||
let mut base_samples = vec![0.0f32; (w * h * 4) as usize];
|
||||
for px in base_samples.chunks_exact_mut(4) {
|
||||
px.copy_from_slice(&[0.0, 0.0, 1.0, 1.0]);
|
||||
}
|
||||
let base = Arc::new(super::super::frames::f32_rgba_to_bgra_image(w, h, &base_samples));
|
||||
let viewport = InteractViewport::at_frame_size(w, h);
|
||||
let composite = draw_interact_composite(handle, &interact, &viewport, 0.0, &base)
|
||||
.expect("GL overlay composite");
|
||||
|
||||
// The test plugin clears to opaque dark grey (0.05) and draws a
|
||||
// solid rectangle (0.9,0.1,0.2) at canonical 10..30 — pixel scale 1
|
||||
// maps it to pixels 10..30. The opaque overlay fully replaces the
|
||||
// base frame.
|
||||
let bytes = composite.as_bytes(0).expect("composite bytes");
|
||||
let px_at = |x: u32, y: u32| {
|
||||
let i = ((y * w + x) * 4) as usize;
|
||||
(
|
||||
bytes[i + 2] as f32 / 255.0,
|
||||
bytes[i + 1] as f32 / 255.0,
|
||||
bytes[i] as f32 / 255.0,
|
||||
)
|
||||
};
|
||||
let inside = px_at(20, 20);
|
||||
assert!(
|
||||
(inside.0 - 0.9).abs() < 0.03
|
||||
&& (inside.1 - 0.1).abs() < 0.03
|
||||
&& (inside.2 - 0.2).abs() < 0.03,
|
||||
"plugin-drawn rectangle should composite at (20,20): {inside:?}"
|
||||
);
|
||||
let outside = px_at(5, 5);
|
||||
assert!(
|
||||
(outside.0 - 0.05).abs() < 0.03
|
||||
&& (outside.1 - 0.05).abs() < 0.03
|
||||
&& (outside.2 - 0.05).abs() < 0.03,
|
||||
"plugin clear colour should fill the rest: {outside:?}"
|
||||
);
|
||||
|
||||
// The draw action reached the plugin with the viewport args.
|
||||
unsafe { std::env::remove_var(MARKER_ENV) };
|
||||
let lines = read_marker(&marker);
|
||||
let _ = std::fs::remove_file(&marker);
|
||||
assert!(
|
||||
lines.iter().any(|l| l.starts_with("draw vp=64x64")),
|
||||
"draw not recorded with viewport: {lines:?}"
|
||||
);
|
||||
|
||||
sync_active_interact(None);
|
||||
oakplugin::host::Host::global().shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3295,6 +3295,26 @@ impl AppEngine for RealEngine {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ofx_interact_target(&self, _cx: &App) -> Option<u64> {
|
||||
let Some(project) = self.project_ref() else {
|
||||
return None;
|
||||
};
|
||||
let Some(host) = self.selected_clip_node() else {
|
||||
return None;
|
||||
};
|
||||
let guard = graphops::lock(project);
|
||||
for node in super::effectchain::chain(&guard.graph, host) {
|
||||
// The inspector's current selection: the first *expanded*
|
||||
// OFX plugin card (its parameter UI is on screen, so its
|
||||
// custom interact drives the viewer overlay).
|
||||
if !self.expanded_effects.contains(&node.identity()) {
|
||||
continue;
|
||||
}
|
||||
return super::effectchain::plugin_instance_handle(&guard.graph, node);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn apply_effect_event(&mut self, event: &EffectStackEvent, cx: &mut Context<Self>) {
|
||||
match event {
|
||||
EffectStackEvent::EnableToggled { effect, enabled } => {
|
||||
|
||||
+658
-14
@@ -27,7 +27,9 @@
|
||||
//! - 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
|
||||
//! - vec2 / vec3 → one [`SpinBox`] per component
|
||||
//! - color → a swatch + deferred popup picker ([`OfxColorPicker`]:
|
||||
//! R/G/B/A sliders, live preview, hex input, Cancel/OK)
|
||||
//! - push button → a clickable button (`AppEngine::effect_push_button`)
|
||||
//!
|
||||
//! Secret (HIDDEN) inputs never reach the snapshot, so they render
|
||||
@@ -38,15 +40,22 @@
|
||||
//! created fresh per expanded-card render), so it carries no state of its
|
||||
//! own; values are re-synced from the engine each frame.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::effect_stack::EffectId;
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::{
|
||||
div, prelude::*, px, ClickEvent, Context, Entity, Render, SharedString, Window,
|
||||
div, prelude::*, px, rgb, size, point, ClickEvent, Context, Entity, EventEmitter, Render,
|
||||
SharedString, Window,
|
||||
};
|
||||
use gpui::{
|
||||
Anchor, App, Bounds, ElementId, Hsla, KeyDownEvent, MouseButton, MouseDownEvent, MouseUpEvent,
|
||||
Point, Pixels, Rgba, anchored, canvas, deferred, fill,
|
||||
};
|
||||
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::slider::{Slider, SliderEvent, SliderModel};
|
||||
use gpui_widgets::spinbox::{SpinBox, SpinBoxEvent};
|
||||
use gpui_widgets::value::{SliderValue, ValueKind};
|
||||
|
||||
@@ -74,9 +83,11 @@ enum ControlKind {
|
||||
CheckBox(Entity<CheckBox>),
|
||||
/// A combo box (combo / string combo).
|
||||
Combo(Entity<ComboBox>),
|
||||
/// One spinbox per component (vec2 / vec3 / color); the usize is the
|
||||
/// One spinbox per component (vec2 / vec3); the usize is the
|
||||
/// component index within the value.
|
||||
Spin(Vec<(Entity<SpinBox>, usize)>),
|
||||
/// A colour swatch + popup picker (color).
|
||||
Color(Entity<OfxColorPicker>),
|
||||
/// A text field (string).
|
||||
Text(Entity<EditableTextState>),
|
||||
/// A push button (rendered inline, no entity).
|
||||
@@ -155,6 +166,18 @@ impl<E: AppEngine> OfxParamsView<E> {
|
||||
}
|
||||
}
|
||||
}
|
||||
ControlKind::Color(picker) => {
|
||||
if let NodeValue::Color(v) = param.value {
|
||||
let color = Rgba {
|
||||
r: v[0] as f32,
|
||||
g: v[1] as f32,
|
||||
b: v[2] as f32,
|
||||
a: v[3] as f32,
|
||||
};
|
||||
let picker = picker.clone();
|
||||
picker.update(cx, |picker, cx| picker.set_committed(color, cx));
|
||||
}
|
||||
}
|
||||
ControlKind::Text(editor) => {
|
||||
let text = match ¶m.value {
|
||||
NodeValue::Text(s) => s.clone(),
|
||||
@@ -330,21 +353,14 @@ fn build_control<E: AppEngine>(
|
||||
*next_id += 1;
|
||||
ControlKind::Text(editor)
|
||||
}
|
||||
ValueType::Vec2 | ValueType::Vec3 | ValueType::Color => {
|
||||
ValueType::Vec2 | ValueType::Vec3 => {
|
||||
let components = value_components(¶m.value);
|
||||
let count = if param.value_type == ValueType::Vec2 {
|
||||
2
|
||||
} else if param.value_type == ValueType::Vec3 {
|
||||
} else {
|
||||
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 (min, max) = 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);
|
||||
@@ -355,6 +371,20 @@ fn build_control<E: AppEngine>(
|
||||
}
|
||||
ControlKind::Spin(spins)
|
||||
}
|
||||
ValueType::Color => {
|
||||
// Colour params get the swatch + popup picker (channels are
|
||||
// always 0..1 regardless of any attached min/max).
|
||||
let components = value_components(¶m.value);
|
||||
let color = Rgba {
|
||||
r: components.get(0).copied().unwrap_or(0.0) as f32,
|
||||
g: components.get(1).copied().unwrap_or(0.0) as f32,
|
||||
b: components.get(2).copied().unwrap_or(0.0) as f32,
|
||||
a: components.get(3).copied().unwrap_or(1.0) as f32,
|
||||
};
|
||||
let picker = cx.new(|cx| OfxColorPicker::new(*next_id, color, window, cx));
|
||||
*next_id += 1;
|
||||
ControlKind::Color(picker)
|
||||
}
|
||||
ValueType::PushButton => ControlKind::PushButton,
|
||||
// Custom / binary and anything without an editable control: a
|
||||
// read-only line (or nothing).
|
||||
@@ -466,6 +496,25 @@ fn wire_controls<E: AppEngine>(view: &OfxParamsView<E>, cx: &mut Context<OfxPara
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
ControlKind::Color(picker) => {
|
||||
let picker = picker.clone();
|
||||
cx.subscribe(&picker, move |_, _, event: &OfxColorEvent, cx| {
|
||||
// Only OK commits; slider drags update the draft inside
|
||||
// the picker, so a drag session is one undo row.
|
||||
if let OfxColorEvent::Committed(color) = event {
|
||||
let nv = NodeValue::Color([
|
||||
color.r as f64,
|
||||
color.g as f64,
|
||||
color.b as f64,
|
||||
color.a as f64,
|
||||
]);
|
||||
engine.update(cx, |engine, cx| {
|
||||
let _ = engine.set_effect_param(effect, &input_id, nv, cx);
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
ControlKind::Text(_editor) => {
|
||||
// The text field commits explicitly (the commit button in the
|
||||
// row). No event subscription here: the params view is rebuilt
|
||||
@@ -565,6 +614,9 @@ impl<E: AppEngine> Render for OfxParamsView<E> {
|
||||
}
|
||||
row.into_any_element()
|
||||
}
|
||||
ControlKind::Color(picker) => {
|
||||
div().flex_1().child(picker.clone()).into_any_element()
|
||||
}
|
||||
ControlKind::Text(editor) => {
|
||||
let weak = editor.downgrade();
|
||||
let engine = self.engine.clone();
|
||||
@@ -642,3 +694,595 @@ impl<E: AppEngine> Render for OfxParamsView<E> {
|
||||
body
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OfxColorPicker — colour swatch + deferred popup picker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A request emitted by an [`OfxColorPicker`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum OfxColorEvent {
|
||||
/// The popup was opened (draft reset to the committed colour).
|
||||
Opened,
|
||||
/// The popup was dismissed without committing.
|
||||
Cancelled,
|
||||
/// OK pressed: the caller should commit this colour (undoable).
|
||||
Committed(Rgba),
|
||||
}
|
||||
|
||||
/// A colour swatch with a deferred popup picker, used for OFX colour
|
||||
/// parameters (the replacement for the old per-channel spinboxes).
|
||||
///
|
||||
/// - The swatch shows the **committed** colour (the engine value) over a
|
||||
/// two-tone checkerboard so alpha is visible.
|
||||
/// - Clicking opens a deferred popup with four 0..1 channel sliders, a
|
||||
/// live preview swatch, a hex field (`#RRGGBB` / `#RRGGBBAA`, validated)
|
||||
/// and Cancel / OK buttons.
|
||||
/// - Slider drags only mutate the **draft**; only OK emits
|
||||
/// [`OfxColorEvent::Committed`], which the params view routes through
|
||||
/// [`AppEngine::set_effect_param`] (undoable). A drag session is
|
||||
/// therefore a single undo row. Cancel / Escape / outside click discard
|
||||
/// the draft.
|
||||
///
|
||||
/// The picker is a child entity of the params view and carries its own
|
||||
/// state across frames; [`OfxColorPicker::set_committed`] re-syncs it from
|
||||
/// the engine each frame (undo / redo / external edits land on the swatch).
|
||||
struct OfxColorPicker {
|
||||
/// Stable control id (element ids / slider ids).
|
||||
control: usize,
|
||||
/// Whether the popup is open.
|
||||
open: bool,
|
||||
/// Popup anchor position (window space, set when opening).
|
||||
position: Point<Pixels>,
|
||||
/// The committed colour (what the swatch shows).
|
||||
committed: Rgba,
|
||||
/// The draft colour while the popup is open (what OK commits).
|
||||
draft: Rgba,
|
||||
/// Whether the last hex parse failed (error hint in the popup).
|
||||
hex_error: bool,
|
||||
/// Whether the popup was open when the swatch was pressed (a second
|
||||
/// click closes it).
|
||||
was_open_at_down: bool,
|
||||
/// Channel sliders, R/G/B/A in 0..1.
|
||||
r: Entity<Slider>,
|
||||
g: Entity<Slider>,
|
||||
b: Entity<Slider>,
|
||||
a: Entity<Slider>,
|
||||
/// The hex editor (`#RRGGBB` / `#RRGGBBAA`).
|
||||
hex: Entity<EditableTextState>,
|
||||
}
|
||||
|
||||
impl OfxColorPicker {
|
||||
/// Create a picker for `control` showing `color`.
|
||||
pub(crate) fn new(
|
||||
control: usize,
|
||||
color: Rgba,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let mut picker = Self {
|
||||
control,
|
||||
open: false,
|
||||
position: Point::default(),
|
||||
committed: color,
|
||||
draft: color,
|
||||
hex_error: false,
|
||||
was_open_at_down: false,
|
||||
r: Self::channel_slider(cx, window, 0, color.r as f64),
|
||||
g: Self::channel_slider(cx, window, 1, color.g as f64),
|
||||
b: Self::channel_slider(cx, window, 2, color.b as f64),
|
||||
a: Self::channel_slider(cx, window, 3, color.a as f64),
|
||||
hex: cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)),
|
||||
};
|
||||
let hex_text = format_hex(color);
|
||||
picker.hex.update(cx, |hex, cx| hex.emplace(&hex_text, cx));
|
||||
picker
|
||||
}
|
||||
|
||||
/// Build a 0..1 float slider for one channel and subscribe its edits to
|
||||
/// [`Self::on_slider`].
|
||||
fn channel_slider(
|
||||
cx: &mut Context<Self>,
|
||||
window: &mut Window,
|
||||
channel: usize,
|
||||
value: f64,
|
||||
) -> Entity<Slider> {
|
||||
let model = SliderModel::new(ValueKind::Float, 0.0, 1.0, 0.01, value.clamp(0.0, 1.0));
|
||||
let slider = cx.new(|cx| Slider::new(channel * 1000 + 100, model, window, cx));
|
||||
cx.subscribe(
|
||||
&slider,
|
||||
move |this: &mut Self, _: Entity<Slider>, event: &SliderEvent, cx| {
|
||||
this.on_slider(channel, event, cx);
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
slider
|
||||
}
|
||||
|
||||
/// A slider changed: update the draft channel and re-format the hex
|
||||
/// field (no commit — the value only lands on OK).
|
||||
fn on_slider(&mut self, channel: usize, event: &SliderEvent, cx: &mut Context<Self>) {
|
||||
if let SliderEvent::ValueChanged { value, .. } = event {
|
||||
let v = value.to_f64().clamp(0.0, 1.0) as f32;
|
||||
match channel {
|
||||
0 => self.draft.r = v,
|
||||
1 => self.draft.g = v,
|
||||
2 => self.draft.b = v,
|
||||
3 => self.draft.a = v,
|
||||
_ => return,
|
||||
}
|
||||
self.hex_error = false;
|
||||
let hex_entity = self.hex.clone();
|
||||
let hex_text = format_hex(self.draft);
|
||||
hex_entity.update(cx, |hex, cx| {
|
||||
if hex.as_str() != hex_text {
|
||||
hex.emplace(&hex_text, cx);
|
||||
}
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-sync the sliders and the hex field from the current draft (no
|
||||
/// events emitted; used when the draft is reset or committed).
|
||||
fn sync_from_draft(&self, cx: &mut Context<Self>) {
|
||||
let values = [
|
||||
self.draft.r as f64,
|
||||
self.draft.g as f64,
|
||||
self.draft.b as f64,
|
||||
self.draft.a as f64,
|
||||
];
|
||||
let sliders = [&self.r, &self.g, &self.b, &self.a];
|
||||
for (slider, value) in sliders.iter().zip(values.iter()) {
|
||||
let slider = slider.clone();
|
||||
slider.update(cx, |slider, _| {
|
||||
slider.set_value(SliderValue::Float(*value));
|
||||
});
|
||||
}
|
||||
let hex_entity = self.hex.clone();
|
||||
let hex_text = format_hex(self.draft);
|
||||
hex_entity.update(cx, |hex, cx| {
|
||||
if hex.as_str() != hex_text {
|
||||
hex.emplace(&hex_text, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Apply a committed colour from the engine (called every frame from
|
||||
/// the params view's value sync). While the popup is open the draft is
|
||||
/// left alone so an in-progress edit is not clobbered by re-syncs.
|
||||
pub(crate) fn set_committed(&mut self, color: Rgba, cx: &mut Context<Self>) {
|
||||
if self.committed == color {
|
||||
return;
|
||||
}
|
||||
self.committed = color;
|
||||
if !self.open {
|
||||
self.draft = color;
|
||||
self.sync_from_draft(cx);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn open_menu(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) {
|
||||
if !self.open {
|
||||
self.open = true;
|
||||
self.position = position;
|
||||
// Start from the committed colour.
|
||||
self.draft = self.committed;
|
||||
self.hex_error = false;
|
||||
self.sync_from_draft(cx);
|
||||
cx.emit(OfxColorEvent::Opened);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel: discard the draft, keep the committed colour.
|
||||
fn close_menu(&mut self, cx: &mut Context<Self>) {
|
||||
if self.open {
|
||||
self.open = false;
|
||||
self.hex_error = false;
|
||||
self.sync_from_draft(cx);
|
||||
cx.emit(OfxColorEvent::Cancelled);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// OK: validate a hand-typed hex edit, then commit the draft.
|
||||
fn commit(&mut self, cx: &mut Context<Self>) {
|
||||
let text = self.hex.read(cx).as_str().trim().to_string();
|
||||
if !text.is_empty() {
|
||||
match parse_hex(&text) {
|
||||
Some(color) => self.draft = color,
|
||||
None => {
|
||||
self.hex_error = true;
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.hex_error = false;
|
||||
let color = self.draft;
|
||||
self.open = false;
|
||||
self.committed = color;
|
||||
self.sync_from_draft(cx);
|
||||
cx.emit(OfxColorEvent::Committed(color));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The popup's anchored subtree (deferred, above the card).
|
||||
fn popup_anchored(
|
||||
&self,
|
||||
cx: &mut Context<Self>,
|
||||
colors: &Arc<gpui::colors::Colors>,
|
||||
) -> gpui::Deferred {
|
||||
let control = self.control;
|
||||
let draft = self.draft;
|
||||
let hex_weak = self.hex.downgrade();
|
||||
let hex_error = self.hex_error;
|
||||
|
||||
// Four labelled 0..1 channel sliders.
|
||||
let mut slider_rows = div().flex().flex_col().gap_1();
|
||||
for (label, slider) in [
|
||||
("R", &self.r),
|
||||
("G", &self.g),
|
||||
("B", &self.b),
|
||||
("A", &self.a),
|
||||
] {
|
||||
slider_rows = slider_rows.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.w(px(12.0))
|
||||
.text_sm()
|
||||
.text_color(colors.text)
|
||||
.child(label),
|
||||
)
|
||||
.child(div().flex_1().child(slider.clone())),
|
||||
);
|
||||
}
|
||||
|
||||
// Live preview swatch (checkerboard + draft) next to the hex field.
|
||||
let preview_canvas = canvas(
|
||||
|bounds, _window, _cx| bounds,
|
||||
move |bounds, _content, window, cx| {
|
||||
paint_checker_swatch(bounds, draft, window, cx);
|
||||
},
|
||||
);
|
||||
let preview = div()
|
||||
.w(px(36.0))
|
||||
.h(px(24.0))
|
||||
.rounded_sm()
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.overflow_hidden()
|
||||
.child(preview_canvas);
|
||||
let hex_row = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(preview)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.background)
|
||||
.px_2()
|
||||
.py_1()
|
||||
.child(
|
||||
text_input(format!("ofx-color-hex-{control}"))
|
||||
.state(hex_weak)
|
||||
.accepts_input(true),
|
||||
),
|
||||
);
|
||||
|
||||
// Hex parse error hint.
|
||||
let error_hint = if hex_error {
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(gpui::rgba(0xff5555))
|
||||
.child(crate::i18n::tr("ofx.color.invalid"))
|
||||
.into_any_element()
|
||||
} else {
|
||||
div().into_any_element()
|
||||
};
|
||||
|
||||
// Cancel / OK.
|
||||
let cancel = div()
|
||||
.id(SharedString::from(format!("ofx-color-cancel-{control}")))
|
||||
.cursor_pointer()
|
||||
.rounded_sm()
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.background)
|
||||
.text_sm()
|
||||
.text_color(colors.text)
|
||||
.px_2()
|
||||
.py_1()
|
||||
.child(crate::i18n::tr("ofx.color.cancel"))
|
||||
.on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
||||
this.close_menu(cx);
|
||||
}));
|
||||
let ok = div()
|
||||
.id(SharedString::from(format!("ofx-color-ok-{control}")))
|
||||
.cursor_pointer()
|
||||
.rounded_sm()
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.selected)
|
||||
.text_sm()
|
||||
.text_color(colors.text)
|
||||
.px_2()
|
||||
.py_1()
|
||||
.child(crate::i18n::tr("ofx.color.ok"))
|
||||
.on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
||||
this.commit(cx);
|
||||
}));
|
||||
let buttons = div().flex().justify_between().gap_1().child(cancel).child(ok);
|
||||
|
||||
deferred(
|
||||
anchored()
|
||||
.position(self.position)
|
||||
.anchor(Anchor::TopLeft)
|
||||
.offset(point(px(0.0), px(36.0)))
|
||||
.snap_to_window_with_margin(px(8.0))
|
||||
.child(
|
||||
div()
|
||||
.w(px(240.0))
|
||||
.p_2()
|
||||
.rounded_lg()
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.container)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.debug_selector(|| "ofx-color-popup".into())
|
||||
.on_mouse_up_out(
|
||||
MouseButton::Left,
|
||||
cx.listener(|this, _event: &MouseUpEvent, _window, cx| {
|
||||
this.close_menu(cx);
|
||||
}),
|
||||
)
|
||||
.on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| {
|
||||
if event.keystroke.key == "escape" {
|
||||
this.close_menu(cx);
|
||||
}
|
||||
}))
|
||||
.child(slider_rows)
|
||||
.child(hex_row)
|
||||
.child(error_hint)
|
||||
.child(buttons),
|
||||
),
|
||||
)
|
||||
.with_priority(1)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<OfxColorEvent> for OfxColorPicker {}
|
||||
|
||||
impl Render for OfxColorPicker {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
let control = self.control;
|
||||
let committed = self.committed;
|
||||
|
||||
let swatch = div()
|
||||
.id(ElementId::named_usize("ofx-color-swatch", control))
|
||||
.w(px(28.0))
|
||||
.h(px(28.0))
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.border_color(if self.open {
|
||||
colors.selected
|
||||
} else {
|
||||
colors.border
|
||||
})
|
||||
.cursor_pointer()
|
||||
.overflow_hidden()
|
||||
.debug_selector(|| "ofx-color-swatch".into())
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(|this, _event: &MouseDownEvent, _window, _cx| {
|
||||
this.was_open_at_down = this.open;
|
||||
}),
|
||||
)
|
||||
.on_click(cx.listener(|this, event: &ClickEvent, _window, cx| {
|
||||
if this.was_open_at_down {
|
||||
this.close_menu(cx);
|
||||
} else {
|
||||
this.open_menu(event.position(), cx);
|
||||
}
|
||||
cx.stop_propagation();
|
||||
}))
|
||||
.child(canvas(
|
||||
|bounds, _window, _cx| bounds,
|
||||
move |bounds, _content, window, cx| {
|
||||
paint_checker_swatch(bounds, committed, window, cx);
|
||||
},
|
||||
));
|
||||
|
||||
let popup = if self.open {
|
||||
self.popup_anchored(cx, &colors)
|
||||
} else {
|
||||
deferred(div())
|
||||
};
|
||||
|
||||
div().relative().child(swatch).child(popup)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `#RRGGBB` or `#RRGGBBAA` into an [`Rgba`] (0..1 components).
|
||||
/// Rejects a missing `#`, wrong lengths and non-hex digits.
|
||||
fn parse_hex(input: &str) -> Option<Rgba> {
|
||||
let s = input.trim();
|
||||
let s = s.strip_prefix('#')?;
|
||||
if s.len() != 6 && s.len() != 8 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = [0u8; 4];
|
||||
for i in 0..s.len() / 2 {
|
||||
bytes[i] = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?;
|
||||
}
|
||||
let (r, g, b, a) = if s.len() == 8 {
|
||||
(bytes[0], bytes[1], bytes[2], bytes[3])
|
||||
} else {
|
||||
(bytes[0], bytes[1], bytes[2], 255)
|
||||
};
|
||||
Some(Rgba {
|
||||
r: r as f32 / 255.0,
|
||||
g: g as f32 / 255.0,
|
||||
b: b as f32 / 255.0,
|
||||
a: a as f32 / 255.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Format an [`Rgba`] as `#RRGGBB` (opaque alpha) or `#RRGGBBAA`.
|
||||
fn format_hex(color: Rgba) -> String {
|
||||
let to = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let (r, g, b, a) = (to(color.r), to(color.g), to(color.b), to(color.a));
|
||||
if a == 255 {
|
||||
format!("#{r:02X}{g:02X}{b:02X}")
|
||||
} else {
|
||||
format!("#{r:02X}{g:02X}{b:02X}{a:02X}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint a two-tone checkerboard (8 px cells) with `color` over it — the
|
||||
/// alpha channel reads through the checkerboard.
|
||||
fn paint_checker_swatch(
|
||||
bounds: Bounds<Pixels>,
|
||||
color: Rgba,
|
||||
window: &mut Window,
|
||||
_cx: &mut App,
|
||||
) {
|
||||
const CELL: f32 = 8.0;
|
||||
let width = f32::from(bounds.size.width);
|
||||
let height = f32::from(bounds.size.height);
|
||||
let cols = (width / CELL).ceil() as i32;
|
||||
let rows = (height / CELL).ceil() as i32;
|
||||
let light = Hsla::from(rgb(0xe8e8e8));
|
||||
let dark = Hsla::from(rgb(0xc0c0c0));
|
||||
for y in 0..rows {
|
||||
for x in 0..cols {
|
||||
let cell = Bounds::new(
|
||||
point(
|
||||
bounds.left() + px(x as f32 * CELL),
|
||||
bounds.top() + px(y as f32 * CELL),
|
||||
),
|
||||
size(px(CELL), px(CELL)),
|
||||
);
|
||||
let shade = if (x + y) % 2 == 0 { light } else { dark };
|
||||
window.paint_quad(fill(cell, shade));
|
||||
}
|
||||
}
|
||||
// The colour on top; a transparent alpha blends over the checkerboard.
|
||||
window.paint_quad(fill(bounds, Hsla::from(color)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::TestAppContext;
|
||||
|
||||
#[test]
|
||||
fn hex_parsing_and_formatting() {
|
||||
// #RRGGBB parses with opaque alpha; #RRGGBBAA keeps the alpha.
|
||||
let c = parse_hex("#1A80E6").expect("6-digit hex parses");
|
||||
assert!((c.r - 0x1A as f32 / 255.0).abs() < 1e-6);
|
||||
assert!((c.g - 0x80 as f32 / 255.0).abs() < 1e-6);
|
||||
assert!((c.b - 0xE6 as f32 / 255.0).abs() < 1e-6);
|
||||
assert!((c.a - 1.0).abs() < 1e-6);
|
||||
let c = parse_hex("#1A80E6FF").expect("8-digit opaque hex parses");
|
||||
assert!((c.a - 1.0).abs() < 1e-6);
|
||||
let c = parse_hex("#1A80E67F").expect("8-digit alpha hex parses");
|
||||
assert!((c.a - 0x7F as f32 / 255.0).abs() < 1e-6);
|
||||
|
||||
// format -> parse round-trips exactly (both sides are 8-bit).
|
||||
for hex in ["#102030", "#0A0B0C", "#11223344", "#FF000080"] {
|
||||
let parsed = parse_hex(hex).expect("round-trip source parses");
|
||||
assert_eq!(format_hex(parsed), hex.to_ascii_uppercase());
|
||||
}
|
||||
|
||||
// Opaque colours format to 6 digits, translucent to 8.
|
||||
assert_eq!(
|
||||
format_hex(Rgba {
|
||||
r: 0.0,
|
||||
g: 0.0,
|
||||
b: 0.0,
|
||||
a: 1.0,
|
||||
}),
|
||||
"#000000"
|
||||
);
|
||||
assert_eq!(
|
||||
format_hex(Rgba {
|
||||
r: 1.0,
|
||||
g: 1.0,
|
||||
b: 1.0,
|
||||
a: 0.5,
|
||||
}),
|
||||
"#FFFFFF80"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_parse_rejects_malformed() {
|
||||
for bad in [
|
||||
"", // empty
|
||||
"102030", // missing '#'
|
||||
"#12345", // too short
|
||||
"#1234567", // 7 digits
|
||||
"#GGHHII", // non-hex digits
|
||||
"#123456789", // too long
|
||||
"# 123456", // whitespace inside
|
||||
] {
|
||||
assert!(parse_hex(bad).is_none(), "expected {bad:?} to be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour picker (and its four sliders + hex editor) constructs
|
||||
/// without panicking and paints a swatch.
|
||||
#[gpui::test]
|
||||
async fn color_picker_constructs_without_panicking(cx: &mut TestAppContext) {
|
||||
struct Host {
|
||||
picker: Entity<OfxColorPicker>,
|
||||
}
|
||||
impl Render for Host {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().size_full().child(self.picker.clone())
|
||||
}
|
||||
}
|
||||
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(320.0), px(200.0)), |window, cx| {
|
||||
let picker = cx.new(|cx| {
|
||||
OfxColorPicker::new(
|
||||
1,
|
||||
Rgba {
|
||||
r: 0.4,
|
||||
g: 0.2,
|
||||
b: 0.8,
|
||||
a: 0.5,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
Host { picker }
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let mut visual = gpui::VisualTestContext::from_window(window.into(), cx).into_mut();
|
||||
visual.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
assert!(
|
||||
visual.debug_bounds("ofx-color-swatch").is_some(),
|
||||
"the colour swatch should be painted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+242
-16
@@ -23,15 +23,16 @@
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::dock::{DockPanel, PanelEvent};
|
||||
use gpui::{
|
||||
div, prelude::*, px, AnyElement, App, ClickEvent, Context, Entity, EventEmitter, MouseButton,
|
||||
Render, SharedString, Window,
|
||||
div, prelude::*, px, size, AnyElement, App, ClickEvent, Context, Entity, EventEmitter,
|
||||
MouseButton, Point, Render, SharedString, Window,
|
||||
};
|
||||
use gpui_widgets::audio_meter::AudioLevelMeter;
|
||||
use gpui_widgets::scopes::{ChromaDataSource, Histogram, LumaDataSource, Vectorscope, Waveform};
|
||||
use gpui_widgets::viewer::{ViewerEvent, ViewerWidget};
|
||||
use gpui_widgets::viewer::{InteractPointerKind, PlaybackClock, ViewerEvent, ViewerWidget};
|
||||
|
||||
use crate::actions::ActionId;
|
||||
use crate::menus::context::{ContextMenuHandle, ContextMenuTriggered};
|
||||
use crate::oakui::ofx::InteractViewport;
|
||||
use crate::oakui::timecode::{format_fps, format_resolution};
|
||||
use crate::oakui::{AppEngine, Monitor};
|
||||
use crate::panels::commands::{self as panel_commands, PanelCommandHandler};
|
||||
@@ -72,6 +73,22 @@ impl ChromaDataSource for ScopeState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The cached overlay composite the viewer displays while an OFX interact
|
||||
/// is active (redrawn only when the frame/time/viewport changed or the
|
||||
/// plugin requested a repaint).
|
||||
struct OverlayComposite {
|
||||
/// The base frame the composite was built from (Arc identity compare).
|
||||
frame: std::sync::Arc<gpui::RenderImage>,
|
||||
/// The plugin instance the overlay came from.
|
||||
instance: u64,
|
||||
/// The viewport the overlay was drawn at.
|
||||
viewport: InteractViewport,
|
||||
/// The playhead seconds used for the draw.
|
||||
time: f64,
|
||||
/// The composited image shown in the viewer.
|
||||
image: std::sync::Arc<gpui::RenderImage>,
|
||||
}
|
||||
|
||||
/// The program viewer panel.
|
||||
pub struct ProgramViewerPanel<E: AppEngine> {
|
||||
viewer: Entity<ViewerWidget<E::Clock>>,
|
||||
@@ -83,6 +100,12 @@ pub struct ProgramViewerPanel<E: AppEngine> {
|
||||
/// The last CPU frame handed to the viewer (compared by `Arc` identity so
|
||||
/// a paused playhead does not re-upload the picture every frame).
|
||||
last_cpu_frame: Option<std::sync::Arc<gpui::RenderImage>>,
|
||||
/// The last base frame the scope samples were analyzed from (the scopes
|
||||
/// follow the raw frame, not the overlay composite).
|
||||
scope_frame: Option<std::sync::Arc<gpui::RenderImage>>,
|
||||
/// The cached overlay composite (active OFX interact only); `None` when
|
||||
/// the viewer shows the raw engine frame.
|
||||
overlay: Option<OverlayComposite>,
|
||||
/// The active body tab.
|
||||
tab: ProgramViewTab,
|
||||
/// The scope samples backing the three scope widgets.
|
||||
@@ -108,18 +131,51 @@ impl<E: AppEngine> ProgramViewerPanel<E> {
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let viewer = cx.new(|cx| ViewerWidget::new(3, clock.clone(), window, cx));
|
||||
// Route every transport request to the engine's program monitor.
|
||||
cx.subscribe(&viewer, |this, _viewer, event: &ViewerEvent, cx| {
|
||||
let monitor = Monitor::Program;
|
||||
this.engine.update(cx, |engine, cx| match event {
|
||||
ViewerEvent::PlayRequested { .. } => engine.play(monitor, cx),
|
||||
ViewerEvent::PauseRequested { .. } => engine.pause(monitor, cx),
|
||||
ViewerEvent::StepRequested { delta, .. } => engine.step(monitor, *delta, cx),
|
||||
other => println!("[program viewer] request: {other:?}"),
|
||||
});
|
||||
// Route every transport request to the engine's program monitor, and
|
||||
// forward the picture's pointer/key events to the active OFX interact
|
||||
// (no-op when none is live).
|
||||
cx.subscribe(&viewer, |this, _viewer, event: &ViewerEvent, cx| match event {
|
||||
ViewerEvent::InteractPointer {
|
||||
kind,
|
||||
position,
|
||||
button,
|
||||
pressed,
|
||||
} => this.forward_interact_pointer(*kind, *position, *button, *pressed, cx),
|
||||
ViewerEvent::InteractKey { down, keystroke } => {
|
||||
this.forward_interact_key(*down, keystroke, cx)
|
||||
}
|
||||
event => {
|
||||
let monitor = Monitor::Program;
|
||||
this.engine.update(cx, |engine, cx| match event {
|
||||
ViewerEvent::PlayRequested { .. } => engine.play(monitor, cx),
|
||||
ViewerEvent::PauseRequested { .. } => engine.pause(monitor, cx),
|
||||
ViewerEvent::StepRequested { delta, .. } => engine.step(monitor, *delta, cx),
|
||||
other => println!("[program viewer] request: {other:?}"),
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
// A throttled idle pump for the OFX interact: the plugin's UI work
|
||||
// loop is served on the viewer's own timer (the app tick is
|
||||
// shell-owned), so an active interact's `idle` action runs without
|
||||
// coupling to the shell.
|
||||
let this = cx.weak_entity();
|
||||
window
|
||||
.spawn(cx, async move |cx: &mut gpui::AsyncWindowContext| loop {
|
||||
cx.background_executor()
|
||||
.timer(std::time::Duration::from_millis(50))
|
||||
.await;
|
||||
let _ = cx.update(|_window, app| {
|
||||
if let Some(this) = this.upgrade() {
|
||||
this.update(app, |_this, _cx| {
|
||||
crate::oakui::ofx::pump_interact_idle();
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
|
||||
let context_menu =
|
||||
ContextMenuHandle::new(Self::on_local_menu_item, window, cx);
|
||||
|
||||
@@ -137,6 +193,8 @@ impl<E: AppEngine> ProgramViewerPanel<E> {
|
||||
engine,
|
||||
clock,
|
||||
last_cpu_frame: None,
|
||||
scope_frame: None,
|
||||
overlay: None,
|
||||
tab: ProgramViewTab::Picture,
|
||||
scope_state,
|
||||
histogram,
|
||||
@@ -175,23 +233,191 @@ impl<E: AppEngine> ProgramViewerPanel<E> {
|
||||
/// Pushes the engine's current frame into the viewer and the scopes, but
|
||||
/// only when it actually changed (the engine caches one image per
|
||||
/// playhead frame, with the scope samples analyzed in the same pass).
|
||||
///
|
||||
/// When an OFX interact is live, the displayed picture is the engine
|
||||
/// frame with the interact's GL-drawn overlay composited over it; the
|
||||
/// composite is cached and only redrawn when the frame / playhead /
|
||||
/// viewport changed or the plugin requested a repaint (so a paused
|
||||
/// viewer stays inert).
|
||||
fn sync_frame(&mut self, cx: &mut Context<Self>) {
|
||||
// Keep the main-process interact in sync with the inspector's
|
||||
// current selection (creates/destroys the interact as the target
|
||||
// moves; a no-op while it is unchanged).
|
||||
let target = self.engine.read(cx).ofx_interact_target(cx);
|
||||
crate::oakui::ofx::sync_active_interact(target);
|
||||
|
||||
let frame = self.engine.read(cx).cpu_frame(Monitor::Program, cx);
|
||||
|
||||
// The scopes follow the *base* frame (the overlay is not part of the
|
||||
// analysed picture).
|
||||
if self
|
||||
.last_cpu_frame
|
||||
.scope_frame
|
||||
.as_ref()
|
||||
.is_none_or(|last| !std::sync::Arc::ptr_eq(last, &frame))
|
||||
{
|
||||
self.last_cpu_frame = Some(frame.clone());
|
||||
self.scope_frame = Some(frame.clone());
|
||||
let scope = self.engine.read(cx).scope_data(Monitor::Program, cx);
|
||||
self.scope_state.update(cx, |state, cx| {
|
||||
state.luma = (*scope.luma).clone();
|
||||
state.chroma = (*scope.chroma).clone();
|
||||
cx.notify();
|
||||
});
|
||||
let frame = frame.clone();
|
||||
}
|
||||
|
||||
// The picture actually shown: the raw frame, or the overlay
|
||||
// composite while an interact is live.
|
||||
let displayed = self.displayed_frame(&frame, cx);
|
||||
|
||||
if self
|
||||
.last_cpu_frame
|
||||
.as_ref()
|
||||
.is_none_or(|last| !std::sync::Arc::ptr_eq(last, &displayed))
|
||||
{
|
||||
self.last_cpu_frame = Some(displayed.clone());
|
||||
let displayed = displayed.clone();
|
||||
self.viewer
|
||||
.update(cx, |viewer, cx| viewer.set_cpu_frame(Some(frame), cx));
|
||||
.update(cx, |viewer, cx| viewer.set_cpu_frame(Some(displayed), cx));
|
||||
}
|
||||
}
|
||||
|
||||
/// The picture for the current engine `frame`: the frame itself, or —
|
||||
/// when an interact is live — the cached overlay composite, redrawing it
|
||||
/// when any redraw condition changed.
|
||||
fn displayed_frame(
|
||||
&mut self,
|
||||
frame: &std::sync::Arc<gpui::RenderImage>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> std::sync::Arc<gpui::RenderImage> {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let Some((instance, interact, _)) = crate::oakui::ofx::active_interact() else {
|
||||
self.overlay = None;
|
||||
return frame.clone();
|
||||
};
|
||||
let frame_size = frame.size(0);
|
||||
let viewport = InteractViewport::at_frame_size(
|
||||
frame_size.width.0 as u32,
|
||||
frame_size.height.0 as u32,
|
||||
);
|
||||
let time = self.playhead_seconds(cx);
|
||||
|
||||
// Redraw when the base frame, the target instance, the playhead, or
|
||||
// the viewport changed, or when the plugin asked for a repaint via
|
||||
// interactRedraw / interactSwapBuffers (polled and cleared here —
|
||||
// outside the cache predicate so a redraw request during a cache-miss
|
||||
// frame is not replayed).
|
||||
let plugin_redraw = interact.redraw_requested.swap(false, Ordering::Relaxed)
|
||||
|| interact.swap_requested.swap(false, Ordering::Relaxed);
|
||||
let stale = plugin_redraw
|
||||
|| self.overlay.as_ref().is_none_or(|o| {
|
||||
!std::sync::Arc::ptr_eq(&o.frame, frame)
|
||||
|| o.instance != instance
|
||||
|| o.time != time
|
||||
|| o.viewport != viewport
|
||||
});
|
||||
if stale {
|
||||
if let Some(image) = crate::oakui::ofx::draw_interact_composite(
|
||||
instance,
|
||||
&interact,
|
||||
&viewport,
|
||||
time,
|
||||
frame,
|
||||
) {
|
||||
self.overlay = Some(OverlayComposite {
|
||||
frame: frame.clone(),
|
||||
instance,
|
||||
viewport,
|
||||
time,
|
||||
image: image.clone(),
|
||||
});
|
||||
return image;
|
||||
}
|
||||
// The interact is inert (no GL / the plugin failed to draw):
|
||||
// show the base frame. The next frame re-probes (cheap when GL
|
||||
// is unavailable).
|
||||
self.overlay = None;
|
||||
return frame.clone();
|
||||
}
|
||||
self.overlay.as_ref().unwrap().image.clone()
|
||||
}
|
||||
|
||||
/// The program monitor's playhead in seconds (the interact's draw/pen
|
||||
/// `time`).
|
||||
fn playhead_seconds(&self, cx: &App) -> f64 {
|
||||
let clock = self.clock.read(cx);
|
||||
gpui::timeline::frame_to_seconds(clock.current_frame(), clock.frame_rate())
|
||||
}
|
||||
|
||||
/// Forwards a picture pointer event to the active OFX interact, mapping
|
||||
/// the picture-local position into the interact's viewport pixels (the
|
||||
/// frame's pixel grid). Only the primary (left) button drives
|
||||
/// pen_down/pen_up (the OFX pen is a two-state device); moves forward
|
||||
/// pen_motion with the pen-down state reflecting whether a button is
|
||||
/// held. No-op without an active interact or outside the frame rect.
|
||||
fn forward_interact_pointer(
|
||||
&mut self,
|
||||
kind: InteractPointerKind,
|
||||
position: Point<f32>,
|
||||
button: Option<MouseButton>,
|
||||
pressed: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some((_, interact, _)) = crate::oakui::ofx::active_interact() else {
|
||||
return;
|
||||
};
|
||||
let Some(bounds) = self.viewer.read(cx).picture_bounds() else {
|
||||
return;
|
||||
};
|
||||
let area = size(f32::from(bounds.size.width), f32::from(bounds.size.height));
|
||||
let frame_size = self
|
||||
.engine
|
||||
.read(cx)
|
||||
.cpu_frame(Monitor::Program, cx)
|
||||
.size(0);
|
||||
let frame = size(frame_size.width.0 as f32, frame_size.height.0 as f32);
|
||||
let Some((px, py)) = crate::oakui::ofx::viewport_pixel_to_pen(position, area, frame)
|
||||
else {
|
||||
// The pointer is in the letterbox (outside the frame rect).
|
||||
return;
|
||||
};
|
||||
let time = self.playhead_seconds(cx);
|
||||
match kind {
|
||||
InteractPointerKind::Move => {
|
||||
let _ = interact.pen_motion((px, py), pressed, time);
|
||||
}
|
||||
InteractPointerKind::Down if button == Some(MouseButton::Left) => {
|
||||
let _ = interact.pen_down((px, py), time);
|
||||
}
|
||||
InteractPointerKind::Up if button == Some(MouseButton::Left) => {
|
||||
let _ = interact.pen_up((px, py), time);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forwards a key event to the active OFX interact. No-op without an
|
||||
/// active interact.
|
||||
///
|
||||
/// # Consumption semantics
|
||||
///
|
||||
/// gpui dispatches the global keybindings *before* the focused element's
|
||||
/// key handlers, so a key consumed by the app's action system (space =
|
||||
/// play/pause, J/K/L = shuttle, …) never reaches the picture's key
|
||||
/// handler and therefore never reaches the interact — the existing
|
||||
/// shortcut system keeps priority. A key that reaches here was not
|
||||
/// consumed by any binding. The plugin's return status is deliberately
|
||||
/// not acted on: the app does not steal key repeat or other listeners,
|
||||
/// so the interact is a passive consumer of otherwise-unused keys.
|
||||
fn forward_interact_key(&mut self, down: bool, keystroke: &gpui::Keystroke, cx: &mut Context<Self>) {
|
||||
let Some((_, interact, _)) = crate::oakui::ofx::active_interact() else {
|
||||
return;
|
||||
};
|
||||
let (sym, key_string) = crate::oakui::ofx::key_symbol(keystroke);
|
||||
let time = self.playhead_seconds(cx);
|
||||
if down {
|
||||
let _ = interact.key_down(sym, &key_string, time);
|
||||
} else {
|
||||
let _ = interact.key_up(sym, &key_string, time);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,10 @@ impl<E: AppEngine> SourceViewerPanel<E> {
|
||||
ViewerEvent::PlayRequested { .. } => engine.play(monitor, cx),
|
||||
ViewerEvent::PauseRequested { .. } => engine.pause(monitor, cx),
|
||||
ViewerEvent::StepRequested { delta, .. } => engine.step(monitor, *delta, cx),
|
||||
// The source monitor hosts no OFX interact: the picture's
|
||||
// pointer/key events are not forwarded here (the program
|
||||
// viewer's panel forwards them when an interact is live).
|
||||
ViewerEvent::InteractPointer { .. } | ViewerEvent::InteractKey { .. } => {}
|
||||
other => println!("[source viewer] request: {other:?}"),
|
||||
});
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user