feat(widgets): viewer interact pointer/key events for OFX interacts

ViewerEvent gains InteractPointer/InteractKey variants and the picture
area captures pointer and key input for them (the OFX Interact overlay
integration forwards these to the plugin).
This commit is contained in:
2026-08-20 15:11:29 +08:00
parent c4c1d34bd7
commit 0b3e30c095
+144 -4
View File
@@ -14,10 +14,13 @@ pub use transport::*;
use gpui::timeline::{FrameRate, TimeDisplay, format_timecode};
use gpui::{
AnyElement, App, AsyncWindowContext, ClickEvent, Context, Entity, EventEmitter, FocusHandle,
Focusable, ObjectFit, Render, RenderImage, SharedString, SurfaceSource, Window,
colors::DefaultColors, div, img, prelude::*, px, surface,
AnyElement, App, AsyncWindowContext, Bounds, ClickEvent, Context, Entity, EventEmitter,
FocusHandle, Focusable, Keystroke, KeyDownEvent, KeyUpEvent, MouseButton, MouseMoveEvent,
ObjectFit, Point, Pixels, Render, RenderImage, SharedString, SurfaceSource, Window,
canvas, colors::DefaultColors, div, img, prelude::*, px, surface,
};
use std::cell::Cell;
use std::rc::Rc;
use std::sync::Arc;
use crate::{icons, tooltip::tooltip_view};
@@ -67,6 +70,41 @@ pub enum ViewerEvent {
/// The viewer's stable id.
control: usize,
},
/// A pointer (pen) event inside the picture area, emitted so the host
/// can forward it to an OFX interact. `position` is local to the
/// picture area (top-left origin, logical pixels).
InteractPointer {
/// Whether this is a move, a press or a release.
kind: InteractPointerKind,
/// Position in the picture area's local pixels (top-left origin).
position: Point<f32>,
/// The button involved in a press/release, if any.
button: Option<MouseButton>,
/// Whether a pointer button is held at this event (the pen-down
/// state for a move).
pressed: bool,
},
/// A key press/release while the picture area has keyboard focus,
/// emitted for OFX interact forwarding. Keys consumed by a global
/// keybinding never reach this event (gpui dispatches bindings before
/// the focus-path key handlers).
InteractKey {
/// true = press, false = release.
down: bool,
/// The keystroke (modifiers + key name).
keystroke: Keystroke,
},
}
/// The kind of a pointer event inside the picture area.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InteractPointerKind {
/// A mouse move (possibly with a button held).
Move,
/// A mouse button press.
Down,
/// A mouse button release.
Up,
}
/// The picture source of a [`ViewerWidget`].
@@ -95,6 +133,10 @@ pub struct ViewerWidget<C: PlaybackClock> {
focus_handle: FocusHandle,
show_safe_frames: bool,
zoom: bool,
/// The picture area's bounds in window pixels, captured each render
/// (the interact pointer forwarding maps window events to the picture's
/// local pixels from these).
picture_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
}
impl<C: PlaybackClock> ViewerWidget<C> {
@@ -133,6 +175,7 @@ impl<C: PlaybackClock> ViewerWidget<C> {
focus_handle: cx.focus_handle(),
show_safe_frames: false,
zoom: false,
picture_bounds: Rc::new(Cell::new(None)),
}
}
@@ -159,6 +202,30 @@ impl<C: PlaybackClock> ViewerWidget<C> {
cx.notify();
}
/// The picture area's current bounds in window pixels, or `None` before
/// the picture was first painted. The host uses these to convert the
/// [`ViewerEvent::InteractPointer`] positions (picture-local) into the
/// interact's viewport coordinates.
pub fn picture_bounds(&self) -> Option<Bounds<Pixels>> {
self.picture_bounds.get()
}
fn emit_interact_pointer(
&mut self,
kind: InteractPointerKind,
position: Point<Pixels>,
button: Option<MouseButton>,
pressed: bool,
cx: &mut Context<Self>,
) {
cx.emit(ViewerEvent::InteractPointer {
kind,
position: Point::new(f32::from(position.x), f32::from(position.y)),
button,
pressed,
});
}
fn poll_clock(&mut self, cx: &mut Context<Self>) {
let clock = self.clock.read(cx);
let frame = clock.current_frame();
@@ -203,7 +270,62 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
s: 0.0,
l: 0.0,
a: 1.0,
});
})
// The picture is focusable so keyboard events reach the OFX
// interact forwarding while the user is interacting with the
// picture (the panel decides what an active interact consumes).
.focusable()
.on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| {
if let Some(bounds) = this.picture_bounds.get() {
this.emit_interact_pointer(
InteractPointerKind::Move,
event.position - bounds.origin,
None,
event.pressed_button.is_some(),
cx,
);
}
}))
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, event: &gpui::MouseDownEvent, _window, cx| {
if let Some(bounds) = this.picture_bounds.get() {
this.emit_interact_pointer(
InteractPointerKind::Down,
event.position - bounds.origin,
Some(event.button),
true,
cx,
);
}
}),
)
.on_mouse_up(
MouseButton::Left,
cx.listener(|this, event: &gpui::MouseUpEvent, _window, cx| {
if let Some(bounds) = this.picture_bounds.get() {
this.emit_interact_pointer(
InteractPointerKind::Up,
event.position - bounds.origin,
Some(event.button),
false,
cx,
);
}
}),
)
.on_key_down(cx.listener(|_this, event: &KeyDownEvent, _window, cx| {
cx.emit(ViewerEvent::InteractKey {
down: true,
keystroke: event.keystroke.clone(),
});
}))
.on_key_up(cx.listener(|_this, event: &KeyUpEvent, _window, cx| {
cx.emit(ViewerEvent::InteractKey {
down: false,
keystroke: event.keystroke.clone(),
});
}));
if let Some(source) = &self.frame_source {
let fit = if self.zoom {
@@ -257,6 +379,24 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
);
}
// Capture the picture area's bounds each frame so the pointer
// handlers above can map window coordinates to picture-local
// pixels. The canvas paints nothing; it only records bounds.
let picture_bounds = self.picture_bounds.clone();
picture = picture.child(
canvas(
move |bounds, _window, _cx| {
picture_bounds.set(Some(bounds));
},
|_bounds, (), _window, _cx| {},
)
.absolute()
.left_0()
.right_0()
.top_0()
.bottom_0(),
);
// Transport bar. The transport controls are icon buttons (16px icon
// on a 24px hit target, localized tooltips); without a registered
// icon resolver the buttons fall back to the glyph labels below.