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.
This commit is contained in:
2026-08-09 03:31:06 +08:00
parent 6043651563
commit ad7c965c5a
33 changed files with 10601 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
//! Dock Layout Example (intended-usage sketch)
//!
//! Sketch of how the Oak video editor will wire up `gpui::dock`: an app with
//! a single [`DockArea`] hosting four placeholder panels — project bin,
//! viewer, inspector, and timeline — plus a [`PanelRegistry`] so layouts can
//! be saved and restored.
//!
//! NOTE: the dock implementation is not finished yet (all of its methods are
//! `todo!()`), so this example compiles but panics at runtime until the
//! implementation lands.
// The modules under demo are skeletons whose bodies are `todo!()` by design.
#![allow(clippy::todo)]
#[path = "../shared/prelude.rs"]
mod example_prelude;
use example_prelude::init_example;
use gpui::dock::{
DockArea, DockEvent, DockLayoutState, DockPanel, PanelEvent, PanelHandle, PanelId,
PanelRegistry,
};
use gpui::{
AnyElement, App, Bounds, Context, Entity, EventEmitter, Render, SharedString, Window,
WindowBounds, WindowOptions, div, prelude::*, px, size,
};
use std::sync::Arc;
// ============================================================================
// Demo panels
//
// Each placeholder is a normal GPUI view plus a `DockPanel` impl. The panel
// ids double as the persistence mapping below.
// ============================================================================
const PROJECT_BIN_ID: PanelId = PanelId::new(1);
const VIEWER_ID: PanelId = PanelId::new(2);
const INSPECTOR_ID: PanelId = PanelId::new(3);
const TIMELINE_ID: PanelId = PanelId::new(4);
/// Shared shape of the demo placeholders: a labeled box.
struct PlaceholderPanel {
id: PanelId,
title: &'static str,
}
impl PlaceholderPanel {
fn new(id: PanelId, title: &'static str) -> Self {
Self { id, title }
}
}
impl Render for PlaceholderPanel {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.flex()
.items_center()
.justify_center()
.child(format!("{} (placeholder)", self.title))
}
}
impl EventEmitter<PanelEvent> for PlaceholderPanel {}
impl DockPanel for PlaceholderPanel {
fn panel_id(&self) -> PanelId {
self.id
}
fn title(&self, _cx: &App) -> SharedString {
self.title.into()
}
fn tab_content(&self, _cx: &App) -> AnyElement {
div().child(self.title).into_any_element()
}
}
// ============================================================================
// Panel registry: bridges string keys (persisted) and live panel views.
// ============================================================================
struct DemoPanelRegistry;
impl PanelRegistry for DemoPanelRegistry {
fn panel_key(&self, id: PanelId) -> Option<String> {
match id {
PROJECT_BIN_ID => Some("project-bin".into()),
VIEWER_ID => Some("viewer".into()),
INSPECTOR_ID => Some("inspector".into()),
TIMELINE_ID => Some("timeline".into()),
_ => None,
}
}
fn build_panel(&self, key: &str, _window: &mut Window, cx: &mut App) -> Option<PanelHandle> {
let (id, title) = match key {
"project-bin" => (PROJECT_BIN_ID, "Project Bin"),
"viewer" => (VIEWER_ID, "Viewer"),
"inspector" => (INSPECTOR_ID, "Inspector"),
"timeline" => (TIMELINE_ID, "Timeline"),
_ => return None,
};
Some(PanelHandle::new(
cx.new(|_| PlaceholderPanel::new(id, title)),
cx,
))
}
}
// ============================================================================
// Root view: just hosts the dock area and logs its events.
// ============================================================================
struct DockLayoutExample {
dock: Entity<DockArea>,
}
impl DockLayoutExample {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let dock = cx.new(|cx| DockArea::new(cx).with_registry(Arc::new(DemoPanelRegistry)));
// Seed a default workspace. Once the dock is implemented this will
// instead attempt `restore_state` from a persisted `DockLayoutState`
// first, falling back to this default when none exists.
let panels: Vec<PanelHandle> = ["project-bin", "viewer", "inspector", "timeline"]
.into_iter()
.filter_map(|key| DemoPanelRegistry.build_panel(key, window, cx))
.collect();
dock.update(cx, |dock, cx| {
for panel in panels {
dock.add_panel(panel, None, cx);
}
});
// Autosave hook: persist on every layout change.
cx.subscribe(&dock, |_this, dock: Entity<DockArea>, event: &DockEvent, cx| {
if let DockEvent::LayoutChanged = event {
let _state: DockLayoutState = dock.read(cx).save_state();
todo!("serialize `_state` with serde_json and write it to the app data dir");
}
})
.detach();
Self { dock }
}
}
impl Render for DockLayoutExample {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.dock.clone())
}
}
fn main() {
gpui_platform::application().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(1200.), px(800.)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|window, cx| cx.new(|cx| DockLayoutExample::new(window, cx)),
)
.expect("Failed to open window");
init_example(cx, "Dock Layout");
});
}
+242
View File
@@ -0,0 +1,242 @@
//! Intended-usage sketch of the `gpui::effect_stack` widget.
//!
//! Builds a mock stack (Media → Transform → OCIO LUT → Output), hosts an
//! [`EffectStackView`], and logs every edit request it emits. The mock data
//! source simply applies requests in place and calls `cx.notify()`; a real
//! app (Oak) would route them through its engine and undo stack.
//!
//! NOTE: the widget implementation itself is still a skeleton (`todo!()`),
//! so this example compiles but does not render yet.
// The modules under demo are skeletons whose bodies are `todo!()` by design.
#![allow(clippy::todo)]
#[path = "../shared/prelude.rs"]
mod example_prelude;
use std::sync::Arc;
use example_prelude::init_example;
use gpui::effect_stack::{
EffectCardKind, EffectData, EffectId, EffectStackDataSource, EffectStackEvent, EffectStackView,
};
use gpui::{
App, Bounds, Context, Entity, Render, SharedString, Window, WindowBounds, WindowOptions, div,
prelude::*, px, size,
};
// ---------------------------------------------------------------------------
// Mock model
// ---------------------------------------------------------------------------
struct MockEffect {
id: EffectId,
kind: EffectCardKind,
title: String,
subtitle: Option<String>,
enabled: bool,
expanded: bool,
badge: Option<usize>,
}
impl EffectData for MockEffect {
fn id(&self) -> EffectId {
self.id
}
fn kind(&self) -> EffectCardKind {
self.kind
}
fn title(&self) -> SharedString {
self.title.clone().into()
}
fn subtitle(&self) -> Option<SharedString> {
self.subtitle.clone().map(Into::into)
}
fn is_enabled(&self) -> bool {
self.enabled
}
fn is_expanded(&self) -> bool {
self.expanded
}
fn badge_count(&self) -> Option<usize> {
self.badge
}
}
/// The mock data source. In Oak this would be a view-model entity deriving
/// the ordered card list from the node-graph path of the selected clip.
struct MockStack {
clip_name: String,
effects: Vec<MockEffect>,
/// Reserved for allocating ids to effects added at runtime.
#[allow(dead_code)]
next_id: u64,
}
impl MockStack {
fn demo() -> Self {
Self {
clip_name: "A001_C002_0103.mov".to_string(),
effects: vec![
MockEffect {
id: EffectId(0),
kind: EffectCardKind::Source,
title: "Media".into(),
subtitle: Some("A001_C002_0103.mov".into()),
enabled: true,
expanded: false,
badge: None,
},
MockEffect {
id: EffectId(1),
kind: EffectCardKind::Effect,
title: "Transform".into(),
subtitle: Some("scale 100%, rotate 0°".into()),
enabled: true,
expanded: true,
badge: Some(2),
},
MockEffect {
id: EffectId(2),
kind: EffectCardKind::Effect,
title: "OCIO LUT".into(),
subtitle: Some("filmic_to_display.cube".into()),
enabled: true,
expanded: false,
badge: None,
},
MockEffect {
id: EffectId(3),
kind: EffectCardKind::Output,
title: "Output".into(),
subtitle: None,
enabled: true,
expanded: false,
badge: None,
},
],
next_id: 4,
}
}
}
impl EffectStackDataSource for MockStack {
fn effects(&self) -> Vec<Arc<dyn EffectData>> {
self.effects
.iter()
.map(|effect| {
Arc::new(MockEffect {
id: effect.id,
kind: effect.kind,
title: effect.title.clone(),
subtitle: effect.subtitle.clone(),
enabled: effect.enabled,
expanded: effect.expanded,
badge: effect.badge,
}) as Arc<dyn EffectData>
})
.collect()
}
fn target_label(&self) -> Option<SharedString> {
Some(self.clip_name.clone().into())
}
}
// ---------------------------------------------------------------------------
// Root view: hosts the stack and applies edit requests to the mock model.
// ---------------------------------------------------------------------------
/// Placeholder parameter view. A real app builds the effect's controls here
/// and calls [`EffectStackView::notify_parameter_changed`] after edits.
struct MockParams {
effect: EffectId,
}
impl Render for MockParams {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().child(format!("parameters for {} (mock)", self.effect))
}
}
struct StackDemoRoot {
data: Entity<MockStack>,
stack: Entity<EffectStackView<MockStack>>,
}
impl StackDemoRoot {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let data = cx.new(|_cx| MockStack::demo());
let stack = cx.new(|cx| {
EffectStackView::new(data.clone(), cx).params_renderer(|id, _window, cx| {
// Real apps build the effect's parameter controls here. The
// mock just shows a placeholder label.
cx.new(|_cx| MockParams { effect: *id }).into()
})
});
// The "edits are requests" loop: log each request, apply it to the
// model (Oak: engine command + undo), then notify.
cx.subscribe_in(&stack, window, {
let data = data.clone();
move |_root, _stack, event: &EffectStackEvent, _window, cx| {
println!("[effect_stack] request: {event:?}");
data.update(cx, |data, cx| {
match event {
EffectStackEvent::EnableToggled { effect, enabled } => {
if let Some(e) = data.effects.iter_mut().find(|e| e.id == *effect) {
e.enabled = *enabled;
}
}
EffectStackEvent::ExpansionToggled { effect, expanded } => {
if let Some(e) = data.effects.iter_mut().find(|e| e.id == *effect) {
e.expanded = *expanded;
}
}
EffectStackEvent::ReorderRequested { .. }
| EffectStackEvent::RemoveRequested(_)
| EffectStackEvent::AddRequested { .. }
| EffectStackEvent::ContextMenuRequested { .. }
| EffectStackEvent::ParameterChanged { .. } => {
todo!("apply {event:?} to the mock model (or engine, in a real app)")
}
}
cx.notify();
});
}
})
.detach();
Self { data, stack }
}
}
impl Render for StackDemoRoot {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let _ = &self.data;
let _ = cx;
div()
.size_full()
.flex()
.items_center()
.justify_center()
.child(self.stack.clone())
}
}
fn main() {
gpui_platform::application().run(|cx: &mut App| {
init_example(cx, "Effect Stack");
let bounds = Bounds::centered(None, size(px(420.0), px(640.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|window, cx| cx.new(|cx| StackDemoRoot::new(window, cx)),
)
.unwrap();
});
}
+273
View File
@@ -0,0 +1,273 @@
//! Intended-usage sketch for the `gpui::node_graph` node-graph editor.
//!
//! Builds a mock video pipeline (media → transform → output), implements the
//! data-source traits over it, and subscribes to the view's edit-request
//! events, logging each one. The real Oak integration maps these events onto
//! engine operations wrapped in undo commands — see the "Wiring into Oak"
//! section of [`gpui::node_graph`].
//!
//! NOTE: the widget itself is still an API skeleton (`todo!()` bodies), so
//! running this example will panic as soon as the view renders. It exists to
//! pin down the intended usage and keep it compiling.
// The modules under demo are skeletons whose bodies are `todo!()` by design.
#![allow(clippy::todo)]
use gpui::{
App, Bounds, Context, Entity, Hsla, Pixels, Point, Render, SharedString, Window, WindowBounds,
WindowOptions, div, point, prelude::*, px, size,
};
use gpui::node_graph::{
EdgeData, EdgeId, NodeData, NodeGraphDataSource, NodeGraphEvent, NodeGraphView, NodeId,
PortData, PortDataType, PortId, PortKind,
};
// ---------------------------------------------------------------------------
// Mock graph data
// ---------------------------------------------------------------------------
#[derive(Clone)]
struct MockPort {
id: PortId,
kind: PortKind,
label: &'static str,
data_type: PortDataType,
connected: bool,
}
impl PortData for MockPort {
fn id(&self) -> PortId {
self.id
}
fn kind(&self) -> PortKind {
self.kind
}
fn label(&self) -> SharedString {
self.label.into()
}
fn data_type(&self) -> PortDataType {
self.data_type.clone()
}
fn is_connected(&self) -> bool {
self.connected
}
}
#[derive(Clone)]
struct MockNode {
id: NodeId,
title: &'static str,
position: Point<Pixels>,
inputs: Vec<MockPort>,
outputs: Vec<MockPort>,
header_color: Option<Hsla>,
}
impl NodeData for MockNode {
type Port = MockPort;
fn id(&self) -> NodeId {
self.id
}
fn title(&self) -> SharedString {
self.title.into()
}
fn position(&self) -> Point<Pixels> {
self.position
}
fn inputs(&self) -> Vec<MockPort> {
self.inputs.clone()
}
fn outputs(&self) -> Vec<MockPort> {
self.outputs.clone()
}
fn header_color(&self) -> Option<Hsla> {
self.header_color
}
fn is_collapsed(&self) -> bool {
false
}
fn is_enabled(&self) -> bool {
true
}
}
#[derive(Clone)]
struct MockEdge {
id: EdgeId,
from_node: NodeId,
from_port: PortId,
to_node: NodeId,
to_port: PortId,
}
impl EdgeData for MockEdge {
fn id(&self) -> EdgeId {
self.id
}
fn from_node(&self) -> NodeId {
self.from_node
}
fn from_port(&self) -> PortId {
self.from_port
}
fn to_node(&self) -> NodeId {
self.to_node
}
fn to_port(&self) -> PortId {
self.to_port
}
}
/// The mock pipeline: media → transform → output.
struct MockGraph {
nodes: Vec<MockNode>,
edges: Vec<MockEdge>,
}
impl MockGraph {
fn new() -> Self {
let video = PortDataType::new("video", Hsla::blue());
// Port id packing: node id in the high bits, port index low. Inputs
// and outputs share one index space per node.
let port = |node: u64, index: u64| PortId((node << 32) | index);
let media = MockNode {
id: NodeId(1),
title: "Media",
position: point(px(40.), px(80.)),
inputs: vec![],
outputs: vec![MockPort {
id: port(1, 0),
kind: PortKind::Output,
label: "video",
data_type: video.clone(),
connected: true,
}],
header_color: Some(Hsla::green()),
};
let transform = MockNode {
id: NodeId(2),
title: "Transform",
position: point(px(320.), px(140.)),
inputs: vec![MockPort {
id: port(2, 0),
kind: PortKind::Input,
label: "in",
data_type: video.clone(),
connected: true,
}],
outputs: vec![MockPort {
id: port(2, 1),
kind: PortKind::Output,
label: "out",
data_type: video.clone(),
connected: true,
}],
header_color: None,
};
let output = MockNode {
id: NodeId(3),
title: "Output",
position: point(px(600.), px(200.)),
inputs: vec![MockPort {
id: port(3, 0),
kind: PortKind::Input,
label: "in",
data_type: video,
connected: true,
}],
outputs: vec![],
header_color: Some(Hsla::red()),
};
let edges = vec![
MockEdge {
id: EdgeId(1),
from_node: NodeId(1),
from_port: port(1, 0),
to_node: NodeId(2),
to_port: port(2, 0),
},
MockEdge {
id: EdgeId(2),
from_node: NodeId(2),
from_port: port(2, 1),
to_node: NodeId(3),
to_port: port(3, 0),
},
];
Self {
nodes: vec![media, transform, output],
edges,
}
}
}
impl NodeGraphDataSource for MockGraph {
type Node = MockNode;
type Edge = MockEdge;
fn nodes(&self) -> Vec<MockNode> {
self.nodes.clone()
}
fn edges(&self) -> Vec<MockEdge> {
self.edges.clone()
}
fn can_connect(&self, _from: PortId, _to: PortId) -> bool {
// A real app checks type compatibility, cycles and cardinality here.
// The mock allows everything between distinct ports.
true
}
}
// ---------------------------------------------------------------------------
// App view: hosts the graph view and logs edit requests
// ---------------------------------------------------------------------------
struct NodeGraphExample {
graph: Entity<MockGraph>,
view: Entity<NodeGraphView<MockGraph>>,
}
impl NodeGraphExample {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let graph = cx.new(|_cx| MockGraph::new());
let view = cx.new(|cx| NodeGraphView::new(graph.clone(), window, cx));
// In Oak, each event becomes an engine operation wrapped in an undo
// command; here we just log the request.
cx.subscribe(&view, |_this, _view, event: &NodeGraphEvent, cx| {
println!("[node_graph] edit request: {event:?}");
// After applying a request to the model, notify so the view
// re-reads it, e.g.: `graph.update(cx, |_, cx| cx.notify())`.
let _ = cx;
})
.detach();
Self { graph, view }
}
}
impl Render for NodeGraphExample {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
let _ = &self.graph;
div().size_full().child(self.view.clone())
}
}
fn main() {
gpui_platform::application().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(900.), px(600.)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|window, cx| cx.new(|cx| NodeGraphExample::new(window, cx)),
)
.expect("failed to open window");
cx.activate(true);
});
}
+207
View File
@@ -0,0 +1,207 @@
//! Demo of the `gpui::timeline` video-editing timeline widget.
//!
//! This is the intended-usage sketch: a mock [`TimelineDataSource`] with two
//! video and two audio tracks carrying a handful of static clips, a
//! [`TimelineView`] placed in a window, and an event subscription that logs
//! the edit requests the widget emits.
//!
//! In a real host (Oak), the `match` arm in `TimelineExample::new` is where
//! each [`TimelineEvent`] becomes an undoable engine command, followed by a
//! `cx.notify()` on the model entity.
//!
//! NOTE: the timeline's rendering and interaction internals are still
//! `todo!()`; this example compiles and shows the wiring, not a usable UI.
// The modules under demo are skeletons whose bodies are `todo!()` by design.
#![allow(clippy::todo)]
use gpui::{
App, Bounds, Context, Entity, Pixels, Render, SharedString, Window, WindowBounds,
WindowOptions, div, prelude::*, px, size,
};
use gpui::timeline::{
ClipData, ClipId, Frame, FrameRange, FrameRate, TimelineDataSource, TimelineEvent,
TimelineView, TrackData, TrackKind,
};
#[path = "../shared/prelude.rs"]
mod example_prelude;
// --- mock model ------------------------------------------------------------
struct MockClip {
id: ClipId,
range: FrameRange,
media_in: Frame,
label: SharedString,
}
impl ClipData for MockClip {
fn id(&self) -> ClipId {
self.id
}
fn range(&self) -> FrameRange {
self.range
}
fn media_in(&self) -> Frame {
self.media_in
}
fn label(&self) -> SharedString {
self.label.clone()
}
}
struct MockTrack {
kind: TrackKind,
name: SharedString,
height: Pixels,
clips: Vec<MockClip>,
}
impl TrackData for MockTrack {
type Clip = MockClip;
fn kind(&self) -> TrackKind {
self.kind
}
fn name(&self) -> SharedString {
self.name.clone()
}
fn height(&self) -> Pixels {
self.height
}
fn clips(&self) -> &[Self::Clip] {
&self.clips
}
}
struct MockSequence {
tracks: Vec<MockTrack>,
}
impl MockSequence {
fn demo() -> Self {
let clip = |id: u64, start: i64, end: i64, label: &str| MockClip {
id: ClipId(id),
range: FrameRange::new(Frame(start), Frame(end)),
media_in: Frame::ZERO,
label: label.into(),
};
MockSequence {
tracks: vec![
MockTrack {
kind: TrackKind::Video,
name: "V1".into(),
height: px(64.),
clips: vec![clip(1, 0, 240, "opening.mov"), clip(2, 240, 600, "b-roll.mp4")],
},
MockTrack {
kind: TrackKind::Video,
name: "V2".into(),
height: px(64.),
clips: vec![clip(3, 120, 300, "title.mov")],
},
MockTrack {
kind: TrackKind::Audio,
name: "A1".into(),
height: px(48.),
clips: vec![clip(4, 0, 600, "dialog.wav")],
},
MockTrack {
kind: TrackKind::Audio,
name: "A2".into(),
height: px(48.),
clips: vec![clip(5, 0, 480, "score.flac")],
},
],
}
}
}
impl TimelineDataSource for MockSequence {
type Track = MockTrack;
fn frame_rate(&self) -> FrameRate {
FrameRate::NTSC_2997
}
fn sequence_length(&self) -> Frame {
Frame(600)
}
fn track_count(&self) -> usize {
self.tracks.len()
}
fn track(&self, index: usize) -> Option<Self::Track> {
// A real host returns a lightweight snapshot; the mock simply
// reports the track's existence. Returning an owned value here is
// what the trait requires, so the mock clones its clips.
self.tracks.get(index).map(|t| MockTrack {
kind: t.kind,
name: t.name.clone(),
height: t.height,
clips: t
.clips
.iter()
.map(|c| MockClip {
id: c.id,
range: c.range,
media_in: c.media_in,
label: c.label.clone(),
})
.collect(),
})
}
}
// --- the example view ------------------------------------------------------
struct TimelineExample {
timeline: Entity<TimelineView<MockSequence>>,
}
impl TimelineExample {
fn new(model: Entity<MockSequence>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let timeline = cx.new(|cx| TimelineView::new(model, window, cx).zoom(2.0));
cx.subscribe(
&timeline,
|_this, _timeline, event: &TimelineEvent, _cx| {
// In Oak, each request becomes an undoable engine command
// here, followed by `model.update(cx, |_, cx| cx.notify())`.
println!("timeline edit request: {event:?}");
},
)
.detach();
TimelineExample { timeline }
}
}
impl Render for TimelineExample {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.timeline.clone())
}
}
fn main() {
gpui_platform::application().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(1000.), px(480.)), cx);
let model = cx.new(|_cx| MockSequence::demo());
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|window, cx| cx.new(|cx| TimelineExample::new(model, window, cx)),
)
.expect("Failed to open window");
example_prelude::init_example(cx, "Timeline");
});
}