Files
oak-gpui/crates/gpui/src/timeline/clip.rs
T
Mike-Solar ad7c965c5a feat(gpui): add dock, effect stack, node graph, and timeline widgets
Implements four workspace widget modules with accompanying learn examples:

- dock: dockable panel layout system (tabs, splits, drag-to-dock) with
  serde-based persistence via PanelRegistry / DockLayoutState
- effect_stack: linear effect-stack inspector widget
- node_graph: node-graph editor (nodes, ports, wires, pan/zoom canvas)
- timeline: video-editing timeline (tracks, clips, ruler, playhead)

Timeline snapping prefers the earlier frame when two snap points are
equally close, with SnapKind priority breaking same-frame ties.
2026-08-09 03:31:06 +08:00

348 lines
13 KiB
Rust

//! Clip rendering: the clip body element and pluggable content decorators.
//!
//! [`ClipElement`] draws one clip in the clip area: the body rect, label,
//! trim handles, transition wedges, and the visual states (hover, selected,
//! disabled, locked-track). Rich content — filmstrip thumbnails for video,
//! waveform for audio — is **not** painted by the element itself; it is
//! delegated to a [`ClipDecorator`] so hosts with their own decode/analysis
//! caches (Oak's codec and render caches) can plug them in without forking
//! the widget.
//!
//! # Hit zones
//!
//! The outer [`TRIM_HANDLE_WIDTH`] pixels at each clip edge are trim
//! handles (cursor changes to a horizontal resize cursor, drag starts a trim
//! gesture). The interior is the move-grab region. The zone width is a
//! *screen-space* constant, so clips narrower than two handles prioritize
//! trimming — the move region may vanish on very short clips.
use std::sync::Arc;
use crate::{
App, BorderStyle, Bounds, Hsla, PathBuilder, Pixels, SharedString, Window, canvas, div, fill,
hsla, outline, point, prelude::*, px,
};
use super::{
data::ClipId,
time::{Frame, FrameRange},
};
/// Screen-space width of each trim-handle hit zone, in pixels.
pub const TRIM_HANDLE_WIDTH: f32 = 6.0;
/// Fallback pixels-per-frame used for internal decoration geometry.
///
/// [`ClipElement`] receives no zoom (see the type docs), so transition wedge
/// widths and the decorator's visible frame range are approximated at
/// 1 px/frame. That is exact at 100% zoom and drifts as the user zooms.
/// TODO(timeline): thread the timeline zoom (or the clip's on-screen frame
/// range) through [`ClipElement`] so wedges and decorator content track the
/// zoom level.
const PX_PER_FRAME_FALLBACK: f32 = 1.0;
/// What the clip content area should show, per track kind and zoom.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ClipContent {
/// Just the body color and label (also the fallback when zoomed out).
#[default]
None,
/// Video thumbnails — painted via [`ClipDecorator::paint_thumbnail`].
Thumbnails,
/// Audio waveform — painted via [`ClipDecorator::paint_waveform`].
Waveform,
}
/// Paints rich clip content (thumbnails, waveforms) into the clip body.
///
/// The timeline calls these hooks during paint with the clip's identity, the
/// currently visible frame range of that clip, and the bounds to paint into.
/// Implementations should cache aggressively — paint is called every frame —
/// which is exactly where Oak plugs in its codec frame cache and audio
/// peak caches.
///
/// Both hooks have default no-op implementations, so a decorator can provide
/// only what it needs; [`NoopClipDecorator`] provides neither.
///
/// The trait is object-safe and used behind `Arc`.
pub trait ClipDecorator: 'static {
/// Paints a video thumbnail filmstrip for `clip` covering
/// `visible_range` (the part of the clip currently on screen) into
/// `bounds`.
///
/// `media_in` semantics matter here: the strip starts at
/// [`ClipData::media_in`](super::ClipData::media_in), so frame
/// `visible_range.start` of the *timeline* maps to source frame
/// `media_in + (visible_range.start - clip.range().start)`.
fn paint_thumbnail(
&mut self,
_window: &mut Window,
_clip: ClipId,
_visible_range: FrameRange,
_bounds: Bounds<Pixels>,
) {
// no-op by default
}
/// Paints an audio waveform for `clip` covering `visible_range` into
/// `bounds`. Same media-time mapping as
/// [`ClipDecorator::paint_thumbnail`]. Typically drawn with
/// [`PathBuilder`](crate::PathBuilder) from cached peak data.
fn paint_waveform(
&mut self,
_window: &mut Window,
_clip: ClipId,
_visible_range: FrameRange,
_bounds: Bounds<Pixels>,
) {
// no-op by default
}
}
/// The default decorator: paints no thumbnails and no waveforms.
///
/// Used when the host doesn't need rich clip content (or hasn't wired its
/// caches yet — Oak will replace this with a decorator backed by its codec
/// and render caches).
#[derive(Debug, Default)]
pub struct NoopClipDecorator;
impl ClipDecorator for NoopClipDecorator {}
/// One clip body in the clip area.
///
/// Constructed per visible clip per frame by
/// [`TimelineView`](super::TimelineView) from a
/// [`ClipData`](super::ClipData) snapshot. The element owns no frame→pixel
/// mapping: the view positions and sizes it via its container, and internal
/// decorations (transition wedges, decorator content) fall back to
/// [`PX_PER_FRAME_FALLBACK`] until a zoom is threaded through — see that
/// constant for the limitation.
#[derive(IntoElement)]
pub struct ClipElement {
id: ClipId,
label: SharedString,
color: Hsla,
selected: bool,
enabled: bool,
locked: bool,
in_transition: Option<FrameRange>,
out_transition: Option<FrameRange>,
content: ClipContent,
decorator: Arc<std::sync::RwLock<dyn ClipDecorator>>,
}
impl ClipElement {
/// Creates a clip element.
///
/// * `id` / `label` / `color` — from the clip's
/// [`ClipData`](super::ClipData); `color` is the clip color or the
/// track-kind default resolved by the caller.
/// * `in_transition` / `out_transition` — the frame ranges (in
/// *clip-local* time) of the head/tail transition wedges, if any.
/// * `decorator` — shared decorator instance; see [`ClipDecorator`].
#[allow(clippy::too_many_arguments)]
pub fn new(
id: ClipId,
label: SharedString,
color: Hsla,
in_transition: Option<FrameRange>,
out_transition: Option<FrameRange>,
decorator: Arc<std::sync::RwLock<dyn ClipDecorator>>,
) -> Self {
ClipElement {
id,
label,
color,
selected: false,
enabled: true,
locked: false,
in_transition,
out_transition,
content: ClipContent::None,
decorator,
}
}
/// Builder: render in the selected state (selection outline).
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
/// Builder: render in the disabled state (dimmed, no snapping target).
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
/// Builder: render as belonging to a locked track (no trim handles, no
/// drag cursor).
pub fn locked(mut self, locked: bool) -> Self {
self.locked = locked;
self
}
/// Builder: what content the decorator should paint inside the body.
pub fn content(mut self, content: ClipContent) -> Self {
self.content = content;
self
}
/// The clip this element renders.
pub fn clip_id(&self) -> ClipId {
self.id
}
/// The trim-handle hit zone width, in pixels.
pub fn trim_handle_width(&self) -> Pixels {
crate::px(TRIM_HANDLE_WIDTH)
}
// TODO(implementor): register the trim-handle hit zones when the element
// grows interactive handles; they are currently part of the view's
// interaction layer.
}
/// Paint-time snapshot of a [`ClipElement`], captured by the canvas prepaint
/// callback and consumed by the paint callback.
struct ClipPaint {
id: ClipId,
color: Hsla,
selected: bool,
enabled: bool,
locked: bool,
in_transition: Option<FrameRange>,
out_transition: Option<FrameRange>,
content: ClipContent,
decorator: Arc<std::sync::RwLock<dyn ClipDecorator>>,
}
impl RenderOnce for ClipElement {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let ClipElement {
id,
label,
color,
selected,
enabled,
locked,
in_transition,
out_transition,
content,
decorator,
..
} = self;
// The clip body is custom-painted (body quad, transition wedges,
// state overlays, decorator content); the label rides in a styled
// overlay div so it inherits the usual text elision.
div()
.relative()
.size_full()
.child(canvas(
move |_bounds, _window, _cx| ClipPaint {
id,
color,
selected,
enabled,
locked,
in_transition,
out_transition,
content,
decorator,
},
|bounds, paint, window, _cx| {
// Body quad, dimmed when the clip is disabled.
let body_color = if paint.enabled {
paint.color
} else {
Hsla {
h: paint.color.h,
s: paint.color.s,
l: paint.color.l,
a: paint.color.a * 0.5,
}
};
window.paint_quad(fill(bounds, body_color));
// Transition wedges: triangles tapering into the clip
// from each edge, capped at 40% of the body width so tiny
// clips don't vanish.
let max_wedge = bounds.size.width.0 * 0.4;
if let Some(range) = paint.in_transition {
let w = (range.len().0 as f32 * PX_PER_FRAME_FALLBACK).min(max_wedge);
if w > 0.0 {
let mut path = PathBuilder::fill();
path.move_to(point(bounds.left(), bounds.top()));
path.line_to(point(bounds.left() + px(w), bounds.top()));
path.line_to(point(bounds.left(), bounds.bottom()));
path.close();
window.paint_path(path.build().expect("wedge path is valid"), paint.color);
}
}
if let Some(range) = paint.out_transition {
let w = (range.len().0 as f32 * PX_PER_FRAME_FALLBACK).min(max_wedge);
if w > 0.0 {
let mut path = PathBuilder::fill();
path.move_to(point(bounds.right(), bounds.top()));
path.line_to(point(bounds.right() - px(w), bounds.top()));
path.line_to(point(bounds.right(), bounds.bottom()));
path.close();
window.paint_path(path.build().expect("wedge path is valid"), paint.color);
}
}
// State overlays, in back-to-front order.
if !paint.enabled {
window.paint_quad(fill(bounds, hsla(0., 0., 0.05, 0.55)));
}
if paint.locked {
window.paint_quad(fill(bounds, hsla(0., 0., 0.1, 0.25)));
}
// Rich content via the decorator, scoped to the frames
// visible inside this body.
let visible_range = FrameRange::new(
Frame::ZERO,
Frame((bounds.size.width.0 / PX_PER_FRAME_FALLBACK) as i64),
);
match paint.content {
ClipContent::Thumbnails => paint
.decorator
.write()
.expect("clip decorator lock is not poisoned")
.paint_thumbnail(window, paint.id, visible_range, bounds),
ClipContent::Waveform => paint
.decorator
.write()
.expect("clip decorator lock is not poisoned")
.paint_waveform(window, paint.id, visible_range, bounds),
ClipContent::None => {}
}
// Selection outline on top of everything.
if paint.selected {
window.paint_quad(
outline(bounds, hsla(0.6, 0.8, 0.6, 1.), BorderStyle::Solid),
);
}
},
)
.size_full())
.child(
div()
.absolute()
.left(px(4.))
.top(px(4.))
.right(px(4.))
.overflow_hidden()
.whitespace_nowrap()
.text_ellipsis()
.text_size(px(11.))
.text_color(hsla(0., 0., 1., 0.92))
.child(label),
)
}
}