From ad7c965c5a106908ab9efabfee129db297f80fd2 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sun, 9 Aug 2026 03:31:06 +0800 Subject: [PATCH] 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. --- crates/gpui/Cargo.toml | 16 + crates/gpui/examples/learn/dock_layout.rs | 170 ++++ crates/gpui/examples/learn/effect_stack.rs | 242 +++++ crates/gpui/examples/learn/node_graph.rs | 273 ++++++ crates/gpui/examples/learn/timeline.rs | 207 ++++ crates/gpui/src/dock/dock_area.rs | 1001 +++++++++++++++++++ crates/gpui/src/dock/floating.rs | 88 ++ crates/gpui/src/dock/layout.rs | 918 ++++++++++++++++++ crates/gpui/src/dock/mod.rs | 89 ++ crates/gpui/src/dock/panel.rs | 255 +++++ crates/gpui/src/dock/split_handle.rs | 225 +++++ crates/gpui/src/dock/tab_bar.rs | 326 +++++++ crates/gpui/src/effect_stack/card.rs | 313 ++++++ crates/gpui/src/effect_stack/data.rs | 182 ++++ crates/gpui/src/effect_stack/mod.rs | 77 ++ crates/gpui/src/effect_stack/stack_view.rs | 604 ++++++++++++ crates/gpui/src/gpui.rs | 8 + crates/gpui/src/node_graph/data.rs | 234 +++++ crates/gpui/src/node_graph/graph_view.rs | 981 +++++++++++++++++++ crates/gpui/src/node_graph/minimap.rs | 152 +++ crates/gpui/src/node_graph/mod.rs | 90 ++ crates/gpui/src/node_graph/node_element.rs | 414 ++++++++ crates/gpui/src/node_graph/state.rs | 245 +++++ crates/gpui/src/node_graph/wire.rs | 302 ++++++ crates/gpui/src/timeline/clip.rs | 347 +++++++ crates/gpui/src/timeline/data.rs | 256 +++++ crates/gpui/src/timeline/mod.rs | 76 ++ crates/gpui/src/timeline/playhead.rs | 159 +++ crates/gpui/src/timeline/ruler.rs | 348 +++++++ crates/gpui/src/timeline/state.rs | 228 +++++ crates/gpui/src/timeline/time.rs | 559 +++++++++++ crates/gpui/src/timeline/timeline_view.rs | 1020 ++++++++++++++++++++ crates/gpui/src/timeline/track_header.rs | 196 ++++ 33 files changed, 10601 insertions(+) create mode 100644 crates/gpui/examples/learn/dock_layout.rs create mode 100644 crates/gpui/examples/learn/effect_stack.rs create mode 100644 crates/gpui/examples/learn/node_graph.rs create mode 100644 crates/gpui/examples/learn/timeline.rs create mode 100644 crates/gpui/src/dock/dock_area.rs create mode 100644 crates/gpui/src/dock/floating.rs create mode 100644 crates/gpui/src/dock/layout.rs create mode 100644 crates/gpui/src/dock/mod.rs create mode 100644 crates/gpui/src/dock/panel.rs create mode 100644 crates/gpui/src/dock/split_handle.rs create mode 100644 crates/gpui/src/dock/tab_bar.rs create mode 100644 crates/gpui/src/effect_stack/card.rs create mode 100644 crates/gpui/src/effect_stack/data.rs create mode 100644 crates/gpui/src/effect_stack/mod.rs create mode 100644 crates/gpui/src/effect_stack/stack_view.rs create mode 100644 crates/gpui/src/node_graph/data.rs create mode 100644 crates/gpui/src/node_graph/graph_view.rs create mode 100644 crates/gpui/src/node_graph/minimap.rs create mode 100644 crates/gpui/src/node_graph/mod.rs create mode 100644 crates/gpui/src/node_graph/node_element.rs create mode 100644 crates/gpui/src/node_graph/state.rs create mode 100644 crates/gpui/src/node_graph/wire.rs create mode 100644 crates/gpui/src/timeline/clip.rs create mode 100644 crates/gpui/src/timeline/data.rs create mode 100644 crates/gpui/src/timeline/mod.rs create mode 100644 crates/gpui/src/timeline/playhead.rs create mode 100644 crates/gpui/src/timeline/ruler.rs create mode 100644 crates/gpui/src/timeline/state.rs create mode 100644 crates/gpui/src/timeline/time.rs create mode 100644 crates/gpui/src/timeline/timeline_view.rs create mode 100644 crates/gpui/src/timeline/track_header.rs diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 8ecdfbbf07..b9ab62e103 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -214,6 +214,22 @@ path = "examples/learn/blur.rs" name = "keyring" path = "examples/learn/keyring.rs" +[[example]] +name = "dock_layout" +path = "examples/learn/dock_layout.rs" + +[[example]] +name = "timeline" +path = "examples/learn/timeline.rs" + +[[example]] +name = "node_graph" +path = "examples/learn/node_graph.rs" + +[[example]] +name = "effect_stack" +path = "examples/learn/effect_stack.rs" + # ============================================================================ # Bench Examples - Performance benchmarks # ============================================================================ diff --git a/crates/gpui/examples/learn/dock_layout.rs b/crates/gpui/examples/learn/dock_layout.rs new file mode 100644 index 0000000000..2f32aebd18 --- /dev/null +++ b/crates/gpui/examples/learn/dock_layout.rs @@ -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) -> impl IntoElement { + div() + .size_full() + .flex() + .items_center() + .justify_center() + .child(format!("{} (placeholder)", self.title)) + } +} + +impl EventEmitter 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 { + 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 { + 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, +} + +impl DockLayoutExample { + fn new(window: &mut Window, cx: &mut Context) -> 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 = ["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, 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) -> 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"); + }); +} diff --git a/crates/gpui/examples/learn/effect_stack.rs b/crates/gpui/examples/learn/effect_stack.rs new file mode 100644 index 0000000000..83812ac3dd --- /dev/null +++ b/crates/gpui/examples/learn/effect_stack.rs @@ -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, + enabled: bool, + expanded: bool, + badge: Option, +} + +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 { + 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 { + 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, + /// 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> { + 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 + }) + .collect() + } + + fn target_label(&self) -> Option { + 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) -> impl IntoElement { + div().child(format!("parameters for {} (mock)", self.effect)) + } +} + +struct StackDemoRoot { + data: Entity, + stack: Entity>, +} + +impl StackDemoRoot { + fn new(window: &mut Window, cx: &mut Context) -> 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) -> 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(); + }); +} diff --git a/crates/gpui/examples/learn/node_graph.rs b/crates/gpui/examples/learn/node_graph.rs new file mode 100644 index 0000000000..412974b9af --- /dev/null +++ b/crates/gpui/examples/learn/node_graph.rs @@ -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, + inputs: Vec, + outputs: Vec, + header_color: Option, +} + +impl NodeData for MockNode { + type Port = MockPort; + + fn id(&self) -> NodeId { + self.id + } + fn title(&self) -> SharedString { + self.title.into() + } + fn position(&self) -> Point { + self.position + } + fn inputs(&self) -> Vec { + self.inputs.clone() + } + fn outputs(&self) -> Vec { + self.outputs.clone() + } + fn header_color(&self) -> Option { + 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, + edges: Vec, +} + +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 { + self.nodes.clone() + } + fn edges(&self) -> Vec { + 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, + view: Entity>, +} + +impl NodeGraphExample { + fn new(window: &mut Window, cx: &mut Context) -> 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) -> 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); + }); +} diff --git a/crates/gpui/examples/learn/timeline.rs b/crates/gpui/examples/learn/timeline.rs new file mode 100644 index 0000000000..ba7ae6643e --- /dev/null +++ b/crates/gpui/examples/learn/timeline.rs @@ -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, +} + +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, +} + +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 { + // 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>, +} + +impl TimelineExample { + fn new(model: Entity, window: &mut Window, cx: &mut Context) -> 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) -> 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"); + }); +} diff --git a/crates/gpui/src/dock/dock_area.rs b/crates/gpui/src/dock/dock_area.rs new file mode 100644 index 0000000000..ac7362b015 --- /dev/null +++ b/crates/gpui/src/dock/dock_area.rs @@ -0,0 +1,1001 @@ +//! The [`DockArea`] view: hosts panels, renders the layout tree, and handles +//! drag-to-dock interaction. + +use crate::dock::layout::interim_id; +use crate::dock::panel::PanelEvent; +use crate::dock::split_handle::{SplitHandle, SplitHandleDrag, SplitHandleEvent}; +use crate::dock::tab_bar::{TabBar, TabBarEvent}; +use crate::dock::{ + path_key, DockLayout, DockLayoutState, DockNode, DropTarget, DropZone, NodePath, PanelHandle, + PanelId, PanelRegistry, +}; +use crate::{ + deferred, div, hsla, px, relative, size, App, AppContext, Axis, Bounds, Context, Div, + DragMoveEvent, ElementId, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, + IntoElement, ParentElement, Pixels, Point, Render, SharedString, Stateful, Styled, + Subscription, Window, +}; +use std::any::Any; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +/// Events emitted by a [`DockArea`]. +/// +/// Subscribe with `cx.subscribe(&dock_area, ...)` to react to layout and +/// focus changes — e.g. to update menu items or persist the layout on +/// [`DockEvent::LayoutChanged`]. +#[derive(Clone, Debug)] +pub enum DockEvent { + /// A panel was added to the dock area. + PanelAdded(PanelId), + /// A panel was removed from the dock area (via + /// [`DockArea::remove_panel`]) without a close negotiation. + PanelRemoved(PanelId), + /// A panel became the focused panel, either by user interaction or via + /// [`DockArea::focus_panel`]. + PanelFocused(PanelId), + /// A panel was moved to a new position in the layout (drag-to-dock or + /// programmatic move). + PanelMoved { + /// The panel that moved. + panel: PanelId, + /// Where it landed. + target: DropTarget, + }, + /// The layout tree changed shape for any reason (add, remove, move, + /// split resize). Good trigger for autosaving + /// [`DockArea::save_state`]. + LayoutChanged, + /// A panel was closed by the user after the full + /// [`should_close`](crate::dock::DockPanel::should_close) / + /// [`on_close`](crate::dock::DockPanel::on_close) negotiation. + PanelClosed(PanelId), +} + +/// In-flight drag-to-dock state for one pointer drag. +/// +/// Created when a tab drag crosses out of its tab strip (see +/// [`tab_bar`](crate::dock::tab_bar)), updated on every +/// [`DragMoveEvent`](crate::DragMoveEvent)s, and consumed or cancelled on +/// drop. Internal to [`DockArea`]. +struct DockDragState { + /// The panel being dragged. + panel: PanelId, + /// Current cursor position in window coordinates. + position: Point, + /// The drop target currently under the cursor, if any. + hovered: Option, + /// Bounds of the hovered tab group / leaf, used to position the drop + /// indicator overlay. + hovered_bounds: Option>, +} + +/// A dockable workspace: renders a [`DockLayout`] tree of panels and manages +/// docking interactions. +/// +/// `DockArea` is a single GPUI view. It owns: +/// +/// - the layout tree ([`DockLayout`]), edited through the methods below; +/// - the live panels, as [`PanelHandle`]s keyed by [`PanelId`]; +/// - an optional [`PanelRegistry`] used by [`save_state`](DockArea::save_state) +/// / [`restore_state`](DockArea::restore_state); +/// - the in-flight drag state and drop-indicator overlay. +/// +/// # Rendering +/// +/// The tree is rendered recursively: `Split` nodes as flex rows/columns with +/// draggable resize handles (see the internal `split_handle` component), +/// `Tabs` nodes as a tab strip (the internal `TabBar` component) over the +/// active panel's content, and `Panel` +/// leaves as the panel's view. The drop indicator is painted in a +/// [`deferred`](crate::deferred) layer so it overlays all panels without being clipped. +/// +/// # Focus and keyboard accessibility +/// +/// `DockArea` implements [`Focusable`] and is itself focusable; when it holds +/// focus, keyboard commands (bound by the application) cycle focus between +/// panels (`focus_next_panel` / `focus_prev_panel` — declaration pending), +/// wrap around at the ends, and can move the focused panel with the keyboard. +/// Activating a tab also focuses its panel. Panels that implement their own +/// [`Focusable`] keep inner focus; the dock only tracks *which* panel is +/// focused via [`DockEvent::PanelFocused`]. +pub struct DockArea { + layout: DockLayout, + panels: HashMap, + registry: Option>, + focus_handle: FocusHandle, + focused_panel: Option, + drag: Option, + /// One tab-strip entity per `Tabs` node, keyed by the node's current + /// path. Re-created when the tree changes shape and pruned each render. + /// The subscription keeps the strip's events routed back to this view. + tab_bars: HashMap, Subscription)>, + /// One split-handle entity per `Split` node, keyed by the node's current + /// path. Holds transient drag state (`SplitHandle::drag_origin`) across + /// frames; pruned with the tab bars each render. + split_handles: HashMap, Subscription)>, +} + +impl DockArea { + /// Creates an empty dock area with no panels and no registry. + /// + /// Typically wrapped in an entity by the caller: + /// `cx.new(|cx| DockArea::new(cx))`. + pub fn new(cx: &mut Context) -> Self { + Self { + layout: DockLayout::new(), + panels: HashMap::new(), + registry: None, + focus_handle: cx.focus_handle(), + focused_panel: None, + drag: None, + tab_bars: HashMap::new(), + split_handles: HashMap::new(), + } + } + + /// Builder: sets the [`PanelRegistry`] used for layout persistence. + /// + /// Without a registry, [`save_state`](DockArea::save_state) returns an + /// empty snapshot and [`restore_state`](DockArea::restore_state) restores + /// nothing. + pub fn with_registry(mut self, registry: Arc) -> Self { + self.registry = Some(registry); + self + } + + /// Adds a panel at `target` (or as the root if `target` is `None` and the + /// layout is empty). + /// + /// Subscribes to the panel's [`PanelEvent`](crate::dock::PanelEvent)s so + /// title changes and close requests are handled. Emits + /// [`DockEvent::PanelAdded`] and [`DockEvent::LayoutChanged`]. + /// + /// Returns `false` (and does nothing) if a panel with the same id is + /// already present, or if the insertion failed (see + /// [`DockLayout::insert_panel`]). + pub fn add_panel( + &mut self, + panel: PanelHandle, + target: Option, + cx: &mut Context, + ) -> bool { + let id = panel.panel_id(); + if self.panels.contains_key(&id) { + return false; + } + if !self.layout.insert_panel(id, target) { + return false; + } + let mut panel = panel; + self.install_panel_subscription(&mut panel, cx); + self.panels.insert(id, panel); + self.emit_layout_changed(cx); + cx.emit(DockEvent::PanelAdded(id)); + true + } + + /// Removes a panel without close negotiation and returns its handle. + /// + /// The caller regains ownership of the view (e.g. to re-dock it elsewhere + /// or drop it). Emits [`DockEvent::PanelRemoved`] and + /// [`DockEvent::LayoutChanged`]. Returns `None` if the id is unknown. + /// For user-initiated closes, prefer the request flow driven by + /// [`PanelEvent::CloseRequested`](crate::dock::PanelEvent::CloseRequested), + /// which honors [`DockPanel::should_close`](crate::dock::DockPanel::should_close). + pub fn remove_panel(&mut self, id: PanelId, cx: &mut Context) -> Option { + let mut handle = self.panels.remove(&id)?; + // Dropping the subscription unsubscribes from the panel's events. + handle.set_subscription(None); + self.layout.remove_panel(id); + if self.focused_panel == Some(id) { + self.focused_panel = None; + } + self.emit_layout_changed(cx); + cx.emit(DockEvent::PanelRemoved(id)); + Some(handle) + } + + /// Moves keyboard focus (and the tab-strip selection) to `panel`. + /// + /// Activates the panel's tab if it lives in a `Tabs` group, focuses the + /// panel's own focus handle if it has one, and emits + /// [`DockEvent::PanelFocused`]. No-op if the id is unknown. + pub fn focus_panel(&mut self, id: PanelId, window: &mut Window, cx: &mut Context) { + if !self.panels.contains_key(&id) { + return; + } + // Activate the panel's tab so the strip selection follows. + if let Some(path) = self.layout.find_panel(id) { + self.layout.set_tabs_active(&path, id); + } + self.focused_panel = Some(id); + // The panel's own focus handle is not reachable through the + // type-erased `AnyView`, so give the dock area keyboard focus; panels + // that implement `Focusable` re-establish inner focus on interaction. + self.focus_handle.focus(window, cx); + cx.emit(DockEvent::PanelFocused(id)); + cx.notify(); + } + + /// Returns the current layout tree. + pub fn layout(&self) -> &DockLayout { + &self.layout + } + + /// Replaces the whole layout tree. + /// + /// Panels referenced by `layout` that have no registered handle are + /// dropped from the tree (via [`DockLayout::cleanup`]); panels that are + /// registered but unreferenced stay loaded but hidden until re-added. + /// Emits [`DockEvent::LayoutChanged`]. + pub fn set_layout(&mut self, mut layout: DockLayout, cx: &mut Context) { + // Panels the caller's tree references without a live handle are + // dropped; `remove_panel` runs `cleanup` to collapse the gaps. + let missing: Vec = layout + .panels() + .into_iter() + .filter(|id| !self.panels.contains_key(id)) + .collect(); + for id in missing { + layout.remove_panel(id); + } + self.layout = layout; + self.emit_layout_changed(cx); + } + + /// Returns the panel handle for `id`, if registered. + pub fn panel(&self, id: PanelId) -> Option<&PanelHandle> { + self.panels.get(&id) + } + + /// Captures a serializable snapshot of the current layout. + /// + /// Requires a registry (see [`with_registry`](DockArea::with_registry)); + /// without one, returns an empty snapshot. Panels the registry declines + /// to key are omitted. Persist with `serde_json` or similar. + pub fn save_state(&self) -> DockLayoutState { + match &self.registry { + Some(registry) => DockLayoutState::capture(&self.layout, registry.as_ref()), + // Without a registry no panel can be keyed, so the snapshot is + // empty (but carries the current format version). + None => DockLayoutState::capture(&self.layout, &NoopPanelRegistry), + } + } + + /// Restores a previously saved snapshot, rebuilding panels through the + /// registry. + /// + /// Panels whose keys the registry cannot rebuild are skipped; the layout + /// is normalized afterwards. Existing panels not referenced by the + /// snapshot are kept registered (hidden) so their state survives a + /// layout switch; panels that *are* referenced are re-used rather than + /// rebuilt when their current id's key matches. + /// + /// Emits [`DockEvent::LayoutChanged`] if the tree changed. + /// + /// # Panics + /// + /// Does not panic on malformed input; unknown keys and bad indices are + /// dropped/clamped. Returns without effect if no registry is set. + pub fn restore_state( + &mut self, + state: &DockLayoutState, + window: &mut Window, + cx: &mut Context, + ) { + let Some(registry) = self.registry.clone() else { + return; + }; + // The tree shape with panels addressed by deterministic interim ids + // (hashed from the registry keys); real ids are resolved below. + let mut layout = state.to_layout(); + + // Rebuild the panels, mapping each key's interim id to the live + // panel's id. Re-use an already-registered panel when its key matches + // so per-panel state survives the layout switch. + let mut interim_to_real: HashMap = HashMap::new(); + let mut fresh: Vec = Vec::new(); + for key in state.keys() { + let interim = interim_id(&key); + if interim_to_real.contains_key(&interim) { + // The same key occurred twice in the snapshot; the duplicate + // occurrence is dropped when the tree is re-mapped below. + continue; + } + let reuse = self.panels.values().find(|handle| { + registry.panel_key(handle.panel_id()) == Some(key.clone()) + }); + match reuse { + Some(handle) => { + interim_to_real.insert(interim, handle.panel_id()); + } + None => match registry.build_panel(&key, window, cx) { + Some(handle) => { + interim_to_real.insert(interim, handle.panel_id()); + fresh.push(handle); + } + None => {} // unknown key; its node collapses away + }, + } + } + + // Re-write the interim ids to real ids, dropping panels that could + // not be rebuilt and de-duplicating panels that appear more than once. + let mut used: HashSet = HashSet::new(); + let mut to_remove: Vec = Vec::new(); + if let Some(root) = layout.root_mut() { + Self::remap_node_ids(root, &interim_to_real, &mut used, &mut to_remove); + } + for id in to_remove { + layout.remove_panel(id); + } + + self.layout = layout; + for handle in fresh { + let id = handle.panel_id(); + if self.panels.contains_key(&id) { + continue; + } + let mut handle = handle; + self.install_panel_subscription(&mut handle, cx); + self.panels.insert(id, handle); + } + self.emit_layout_changed(cx); + } + + /// Undocks a panel into its own floating window. + /// + /// **Deferred**: floating panels depend on unverified multi-window + /// capabilities; see the [`floating`](crate::dock::FloatingPanelWindow) + /// docs. When implemented, this removes the panel from the layout (like + /// [`remove_panel`](DockArea::remove_panel) but without emitting + /// [`DockEvent::PanelRemoved`]) and opens a + /// [`FloatingPanelWindow`](crate::dock::FloatingPanelWindow) hosting it; + /// dropping the window back over a dock area re-docks the panel. Until + /// then, always returns `false`. + /// + /// Returns `true` if the panel was floated. + pub fn float_panel( + &mut self, + _id: PanelId, + _window: &mut Window, + _cx: &mut Context, + ) -> bool { + false + } + + /// Hit-tests a cursor position against the five drop zones of a target. + /// + /// `point` is in window coordinates; `target_bounds` are the bounds of + /// the hovered tab group / leaf. The bounds are divided into a center + /// region ([`DropZone::Center`]) and four edge bands; the band width is a + /// fraction of the smaller dimension so narrow targets stay usable. + /// Returns `None` when `point` lies outside `target_bounds`. + /// + /// Pure and exposed for testing; the drag handlers call it with the + /// bounds cached in the internal drag state. + pub fn drop_zone_at(point: Point, target_bounds: Bounds) -> Option { + if !target_bounds.contains(&point) { + return None; + } + let width = target_bounds.size.width.0; + let height = target_bounds.size.height.0; + // Edge bands are a quarter of the smaller dimension, so narrow + // targets (deeply split columns) keep usable edge zones. + let band = (width.min(height) * 0.25).max(1.0); + let dx = point.x.0 - target_bounds.origin.x.0; + let dy = point.y.0 - target_bounds.origin.y.0; + if dx < band { + Some(DropZone::Left) + } else if dx > width - band { + Some(DropZone::Right) + } else if dy < band { + Some(DropZone::Top) + } else if dy > height - band { + Some(DropZone::Bottom) + } else { + Some(DropZone::Center) + } + } + + /// Renders the translucent drop indicator for the currently hovered + /// [`DropTarget`], if a drag is in flight. + /// + /// Painted via [`deferred`](crate::deferred) so it overlays panel content unclipped. The + /// indicator highlights the sub-rectangle of the target bounds that the + /// panel would occupy (half for edge zones, full for + /// [`DropZone::Center`]). + fn render_drop_indicator( + &self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + let drag = self.drag.as_ref()?; + let (target, bounds) = (drag.hovered?, drag.hovered_bounds?); + // The hovered bounds are in window coordinates; the dock area's root + // is laid out at the window origin in its primary embedding, so they + // double as root-relative coordinates for the absolutely positioned + // overlay. Display-only — docking hit-testing never depends on it. + let rect = match target.zone { + DropZone::Center => bounds, + DropZone::Left => Bounds::new( + bounds.origin, + size(bounds.size.width * 0.5, bounds.size.height), + ), + DropZone::Right => Bounds::new( + Point::new(bounds.right() - bounds.size.width * 0.5, bounds.top()), + size(bounds.size.width * 0.5, bounds.size.height), + ), + DropZone::Top => Bounds::new( + bounds.origin, + size(bounds.size.width, bounds.size.height * 0.5), + ), + DropZone::Bottom => Bounds::new( + Point::new(bounds.left(), bounds.bottom() - bounds.size.height * 0.5), + size(bounds.size.width, bounds.size.height * 0.5), + ), + }; + Some( + deferred( + div() + .absolute() + .left(px(rect.origin.x.0)) + .top(px(rect.origin.y.0)) + .w(px(rect.size.width.0)) + .h(px(rect.size.height.0)) + .rounded_md() + .bg(hsla(0.62, 0.7, 0.8, 0.25)), + ), + ) + } + + /// Begins a dock drag for `panel`. Called by the tab strip when a tab + /// drag leaves the strip's bounds. + fn begin_drag(&mut self, panel: PanelId, position: Point, cx: &mut Context) { + match &mut self.drag { + // Re-entered the strip mid-drag (the strip keeps emitting + // `DockDragStarted`); keep the hovered target intact. + Some(drag) if drag.panel == panel => { + drag.position = position; + } + _ => { + self.drag = Some(DockDragState { + panel, + position, + hovered: None, + hovered_bounds: None, + }); + } + } + cx.notify(); + } + + /// Updates the hovered drop target during a drag. Attached to panel + /// containers via `on_drag_move` with a payload identifying the dragged + /// panel. + /// + /// The calling handler stashes the candidate target (which panel, or + /// `None` for the dock area's outer edges) and its bounds in the drag + /// state; this method classifies the cursor within those bounds and + /// commits (or clears) the hovered target. + fn update_drag(&mut self, position: Point, _window: &Window, cx: &mut Context) { + let Some(drag) = self.drag.as_mut() else { + return; + }; + let Some(bounds) = drag.hovered_bounds else { + return; + }; + drag.position = position; + let panel = drag.hovered.and_then(|target| target.panel); + match Self::drop_zone_at(position, bounds) { + // A root-edge target (`panel: None`) may only ever be an edge + // zone; a center hit falls through to the no-target case. + Some(zone) if panel.is_some() || zone.is_split() => { + drag.hovered = Some(DropTarget { panel, zone }); + } + _ => { + drag.hovered = None; + drag.hovered_bounds = None; + } + } + cx.notify(); + } + + /// Completes the current drag, applying the hovered drop. + /// + /// No-op if no drag is in flight or nothing is hovered; emits + /// [`DockEvent::PanelMoved`] and [`DockEvent::LayoutChanged`] on success. + fn finish_drag(&mut self, cx: &mut Context) { + let Some(drag) = self.drag.take() else { + return; + }; + let Some(target) = drag.hovered else { + cx.notify(); + return; + }; + if self.layout.move_panel(drag.panel, target) { + cx.emit(DockEvent::PanelMoved { + panel: drag.panel, + target, + }); + self.emit_layout_changed(cx); + } else { + cx.notify(); + } + } + + /// Emits [`DockEvent::LayoutChanged`] and repaints. + fn emit_layout_changed(&mut self, cx: &mut Context) { + cx.emit(DockEvent::LayoutChanged); + cx.notify(); + } + + /// Installs the subscription routing a panel's [`PanelEvent`]s back to + /// this dock area. The subscription is stored on the handle so it is + /// dropped (and unsubscribed) when the panel leaves the dock. + fn install_panel_subscription(&mut self, handle: &mut PanelHandle, cx: &mut Context) { + let id = handle.panel_id(); + // Panels are type-erased (`AnyView`), so a typed `Context::subscribe` + // cannot reach them; subscribe by entity id and downcast the event. + let entity_id = handle.view().entity_id(); + let this = cx.weak_entity(); + let subscription = cx.new_subscription( + entity_id, + ( + std::any::TypeId::of::(), + Box::new(move |event: &dyn Any, cx: &mut App| { + let event = event + .downcast_ref::() + .expect("dock panel events are PanelEvent"); + let Some(entity) = this.upgrade() else { + return false; + }; + entity.update(cx, |dock, cx| dock.on_panel_event(id, event.clone(), cx)); + true + }), + ), + ); + handle.set_subscription(Some(subscription)); + } + + /// Handles a [`PanelEvent`] emitted by a held panel. + fn on_panel_event(&mut self, id: PanelId, event: PanelEvent, cx: &mut Context) { + match event { + PanelEvent::CloseRequested => { + // Panels initiate the close flow themselves — consulting + // `DockPanel::should_close` is not reachable through the + // type-erased view — so a request is a confirmed close. + let _ = self.remove_panel(id, cx); + } + PanelEvent::Focused => { + self.focused_panel = Some(id); + cx.emit(DockEvent::PanelFocused(id)); + cx.notify(); + } + PanelEvent::TitleChanged => { + // The cached title snapshot on the handle cannot be refreshed + // through `AnyView`; repaint so the strip re-reads what it can. + cx.notify(); + } + } + } + + /// Handles an event emitted by one of the tab-strip entities. + fn on_tab_bar_event(&mut self, event: &TabBarEvent, cx: &mut Context) { + match event { + TabBarEvent::Reordered { tabs, active } => { + // Paths captured at subscribe time go stale on structural + // edits, so resolve the owning `Tabs` node from the panel set + // the strip just reported. + let Some(first) = tabs.first() else { + return; + }; + let Some(path) = self.layout.find_panel(*first) else { + return; + }; + let Some(node) = self.layout.node_at_mut(&path) else { + return; + }; + if let DockNode::Tabs { + panels, + active: current, + } = node + && panels.len() == tabs.len() + { + *panels = tabs.clone(); + *current = *active; + cx.notify(); + } + } + TabBarEvent::CloseRequested(id) => { + let _ = self.remove_panel(*id, cx); + } + TabBarEvent::DockDragStarted { panel, position } => { + self.begin_drag(*panel, *position, cx); + } + } + } + + /// Gets (creating and subscribing on first use) the tab strip for the + /// `Tabs` node at `path`, and syncs it with the node's current state. + fn tab_bar_for( + &mut self, + path: &NodePath, + panels: &[PanelId], + active: usize, + cx: &mut Context, + ) -> Entity { + let titles: Vec = panels + .iter() + .map(|id| { + self.panels + .get(id) + .map(|handle| handle.title().clone()) + .unwrap_or_default() + }) + .collect(); + let closable: Vec = panels + .iter() + .map(|id| self.panels.get(id).map(|handle| handle.closable()).unwrap_or(false)) + .collect(); + let bar = match self.tab_bars.get(path) { + Some((bar, _)) => bar.clone(), + None => { + let bar = cx.new(|_cx| TabBar::new(panels.to_vec(), active)); + let subscription = cx.subscribe(&bar, |this, _bar, event: &TabBarEvent, cx| { + this.on_tab_bar_event(event, cx); + }); + self.tab_bars.insert(path.clone(), (bar.clone(), subscription)); + bar + } + }; + bar.update(cx, |bar, cx| bar.sync(panels, active, &titles, &closable, cx)); + bar + } + + /// Gets (creating and subscribing on first use) the split-handle entity + /// for the `Split` node at `path`. + fn split_handle_for( + &mut self, + path: &NodePath, + direction: Axis, + cx: &mut Context, + ) -> Entity { + if let Some((handle, _)) = self.split_handles.get(path) { + return handle.clone(); + } + let handle = cx.new(|_cx| SplitHandle::new(direction, path.clone())); + let subscription = cx.subscribe(&handle, |this, _handle, event: &SplitHandleEvent, cx| { + match event { + SplitHandleEvent::ResizeRequested { path, ratio } => { + this.layout.resize_split(path, *ratio); + this.emit_layout_changed(cx); + } + SplitHandleEvent::ResetRequested { path } => { + this.layout.resize_split(path, SplitHandle::RESET_RATIO); + this.emit_layout_changed(cx); + } + } + }); + self.split_handles.insert(path.clone(), (handle.clone(), subscription)); + handle + } + + /// Routes a split-handle drag to the handle entity for `path`. + fn route_split_drag( + &mut self, + path: &NodePath, + direction: Axis, + event: &DragMoveEvent, + cx: &mut Context, + ) { + let Some((handle, _)) = self.split_handles.get(path) else { + return; + }; + let start_ratio = self.layout.split_ratio(path).unwrap_or(SplitHandle::RESET_RATIO); + let extent = match direction { + Axis::Horizontal => event.bounds.size.width, + Axis::Vertical => event.bounds.size.height, + }; + let position = match direction { + Axis::Horizontal => event.event.position.x, + Axis::Vertical => event.event.position.y, + }; + let handle = handle.clone(); + handle.update(cx, |handle, cx| handle.drag_to(position, extent, start_ratio, cx)); + } + + /// Ends a split-handle drag on the handle entity for `path`. + fn end_split_drag(&mut self, path: &NodePath, _drag: &SplitHandleDrag, cx: &mut Context) { + if let Some((handle, _)) = self.split_handles.get(path) { + let handle = handle.clone(); + handle.update(cx, |handle, _cx| handle.end_drag()); + } + } + + /// Claims the hovered drop target for a panel container (`target` is the + /// panel, or the whole tab group, the container belongs to). + /// + /// Attached to every rendered panel container via `on_drag_move`; the + /// container's bounds come from the drag event itself, so no element + /// lookup is needed. Runs after the root's own `on_drag_move` (capture + /// phase), overriding any root-edge target with the exact per-panel one. + fn update_panel_drag( + &mut self, + target: &[PanelId], + event: &DragMoveEvent, + window: &Window, + cx: &mut Context, + ) { + let dragged = *event.drag(cx); + if target.contains(&dragged) { + // Dropping a panel onto itself or its own tab group is a no-op; + // clear any root-edge target the root handler may have set. + if let Some(drag) = self.drag.as_mut() { + drag.hovered = None; + drag.hovered_bounds = None; + } + return; + } + let Some(drag) = self.drag.as_mut() else { + return; + }; + if drag.panel != dragged { + return; + } + let target_panel = target.first().copied().unwrap_or(dragged); + drag.hovered = Some(DropTarget { + panel: Some(target_panel), + zone: DropZone::Center, + }); + drag.hovered_bounds = Some(event.bounds); + self.update_drag(event.event.position, window, cx); + } + + /// Renders the subtree for `node` (at `path`), creating and syncing the + /// per-node chrome (tab bars, split handles) as it goes. + fn render_node(&mut self, node: &DockNode, path: &NodePath, cx: &mut Context) -> Stateful
{ + match node { + DockNode::Split { + direction, + ratio, + children, + } => { + let direction = *direction; + let mut container = div() + .flex() + .size_full() + .overflow_hidden() + .id(ElementId::named_usize("dock-split", path_key(path))) + .on_drag_move::( + cx.listener(move |this, event: &DragMoveEvent, _window, cx| { + let path = event.drag(cx).path.clone(); + this.route_split_drag(&path, direction, event, cx); + }), + ) + .on_drop::( + cx.listener(move |this, drag: &SplitHandleDrag, _window, cx| { + this.end_split_drag(&drag.path, drag, cx); + }), + ); + match direction { + Axis::Horizontal => { + container = container.flex_row(); + } + Axis::Vertical => { + container = container.flex_col(); + } + } + for (index, child) in children.iter().enumerate() { + let mut child_path = path.clone(); + child_path.0.push(index); + let child = self.render_node(child, &child_path, cx); + // The first child is sized by the split's ratio; the rest + // share the remainder equally. + let child = if index == 0 { + child + .flex_basis(relative(*ratio)) + .flex_grow_0() + .flex_shrink_0() + } else { + child.flex_1() + }; + container = container.child(child); + if index == 0 && children.len() > 1 { + let handle = self.split_handle_for(path, direction, cx); + container = container.child(handle); + } + } + container + } + DockNode::Tabs { panels, active } => { + let active = (*active).min(panels.len().saturating_sub(1)); + let active_panel = panels[active]; + let target = panels.clone(); + let mut container = div() + .size_full() + .flex() + .flex_col() + .overflow_hidden() + .id(ElementId::named_usize("dock-panel", active_panel.raw() as usize)) + .on_drag_move::( + cx.listener(move |this, event: &DragMoveEvent, window, cx| { + this.update_panel_drag(&target, event, window, cx); + }), + ) + .on_drop::(cx.listener(|this, _drag: &PanelId, _window, cx| { + // The root's own drop listener is shadowed by panel + // hitboxes, so drops over panel content land here. + this.finish_drag(cx); + })); + let bar = self.tab_bar_for(path, panels, active, cx); + container = container.child(bar); + let content = self + .panels + .get(&active_panel) + .map(|handle| handle.view().clone()); + let mut content_wrapper = div().flex_1().min_h_0().overflow_hidden(); + if let Some(view) = content { + content_wrapper = content_wrapper.child(view); + } + container = container.child(content_wrapper); + container + } + DockNode::Panel(id) => { + let panel_id = *id; + let mut container = div() + .size_full() + .overflow_hidden() + .id(ElementId::named_usize("dock-panel", panel_id.raw() as usize)) + .on_drag_move::( + cx.listener(move |this, event: &DragMoveEvent, window, cx| { + this.update_panel_drag(&[panel_id], event, window, cx); + }), + ) + .on_drop::(cx.listener(|this, _drag: &PanelId, _window, cx| { + this.finish_drag(cx); + })); + if let Some(view) = self.panels.get(&panel_id).map(|handle| handle.view().clone()) { + container = container.child(view); + } + container + } + } + } +} + +impl Render for DockArea { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // A drag that ended without a drop (released outside the dock area) + // leaves transient drag state behind; clear it on the next render. + if self.drag.is_some() && !cx.has_active_drag() { + self.drag = None; + } + + let mut root = div() + .size_full() + .relative() + .id("dock-area") + // Root-edge drop zones: hovering the outer band of the whole area + // splits the entire layout (target.panel: None). Per-panel + // containers run after this in the capture phase and override. + .on_drag_move::(cx.listener(|this, event: &DragMoveEvent, window, cx| { + let dragged = *event.drag(cx); + let Some(drag) = this.drag.as_mut() else { + return; + }; + if drag.panel != dragged { + return; + } + let candidate = match DockArea::drop_zone_at(event.event.position, event.bounds) { + Some(zone) if zone.is_split() => Some(DropTarget { panel: None, zone }), + _ => None, + }; + drag.hovered = candidate; + drag.hovered_bounds = Some(event.bounds); + this.update_drag(event.event.position, window, cx); + })) + .on_drop::(cx.listener(|this, _drag: &PanelId, _window, cx| { + this.finish_drag(cx); + })); + + if let Some(node) = self.layout.root().cloned() { + let path = NodePath::default(); + root = root.child(self.render_node(&node, &path, cx)); + } + + // Drop chrome for nodes that no longer exist (their paths went stale + // after a structural edit); this also releases their subscriptions. + let mut live: HashSet = HashSet::new(); + self.collect_paths(&self.layout, &NodePath::default(), &mut live); + self.tab_bars.retain(|path, _| live.contains(path)); + self.split_handles.retain(|path, _| live.contains(path)); + + if let Some(indicator) = self.render_drop_indicator(window, cx) { + root = root.child(indicator); + } + root + } +} + +impl Focusable for DockArea { + fn focus_handle(&self, cx: &App) -> FocusHandle { + let _ = cx; + self.focus_handle.clone() + } +} + +impl EventEmitter for DockArea {} + +/// A registry that declines to key any panel; [`DockArea::save_state`] uses +/// it to produce an empty (but versioned) snapshot when no registry is set. +struct NoopPanelRegistry; + +impl PanelRegistry for NoopPanelRegistry { + fn panel_key(&self, _id: PanelId) -> Option { + None + } + + fn build_panel(&self, _key: &str, _window: &mut Window, _cx: &mut App) -> Option { + None + } +} + +impl DockArea { + /// Re-writes every panel id in `node` from its interim (key-hashed) id to + /// the rebuilt panel's real id, recording unmapped and duplicated ids for + /// removal by the caller (their nodes collapse via `DockLayout::cleanup`). + fn remap_node_ids( + node: &mut DockNode, + map: &HashMap, + used: &mut HashSet, + to_remove: &mut Vec, + ) { + match node { + DockNode::Panel(id) => Self::remap_id(id, map, used, to_remove), + DockNode::Tabs { panels, .. } => { + for panel in panels.iter_mut() { + Self::remap_id(panel, map, used, to_remove); + } + } + DockNode::Split { children, .. } => { + for child in children { + Self::remap_node_ids(child, map, used, to_remove); + } + } + } + } + + fn remap_id( + id: &mut PanelId, + map: &HashMap, + used: &mut HashSet, + to_remove: &mut Vec, + ) { + let interim = *id; + match map.get(&interim) { + Some(&real) if used.insert(real) => *id = real, + _ => to_remove.push(interim), + } + } + + /// Collects the paths of every node in `layout` (depth-first). + fn collect_paths(&self, layout: &DockLayout, path: &NodePath, out: &mut HashSet) { + let Some(root) = layout.root() else { + return; + }; + Self::collect_paths_in(root, path, out); + } + + fn collect_paths_in(node: &DockNode, path: &NodePath, out: &mut HashSet) { + out.insert(path.clone()); + if let DockNode::Split { children, .. } = node { + for (index, child) in children.iter().enumerate() { + let mut child_path = path.clone(); + child_path.0.push(index); + Self::collect_paths_in(child, &child_path, out); + } + } + } +} diff --git a/crates/gpui/src/dock/floating.rs b/crates/gpui/src/dock/floating.rs new file mode 100644 index 0000000000..8664c5c909 --- /dev/null +++ b/crates/gpui/src/dock/floating.rs @@ -0,0 +1,88 @@ +//! Floating (undocked) panels. +//! +//! # Status +//! +//! Floating panels require spawning one OS window per floated panel, sharing +//! entities across windows, and dragging between windows. GPUI's multi-window +//! support (multiple `cx.open_window` roots sharing an [`App`](crate::App)) +//! is sufficient in principle, but drag-and-drop *across* windows and +//! focus/activation semantics are unverified. Until that is proven, +//! [`DockArea::float_panel`](crate::dock::DockArea::float_panel) always +//! returns `false` and this module's view type is never constructed by the +//! dock machinery itself; it remains available so a caller can host a panel +//! in its own window. +//! +//! # Intended API +//! +//! - [`DockArea::float_panel`](crate::dock::DockArea::float_panel) removes a +//! panel from the layout tree and opens it in its own borderless-chrome +//! window hosting a [`FloatingPanelWindow`]. +//! - [`FloatingPanelWindow`] renders the panel plus a title bar that acts as +//! a drag surface; dropping the window back over a dock area re-docks the +//! panel at the hovered [`DropTarget`](crate::dock::DropTarget). +//! - Floating windows are recorded in +//! [`DockLayoutState::floating`](crate::dock::DockLayoutState::floating) so +//! sessions restore them in place. +//! +//! Everything here is subject to change when the feature is implemented for +//! real. + +use crate::dock::PanelHandle; +use crate::{ + div, px, Context, IntoElement, ParentElement, Pixels, Point, Render, Styled, Window, + WindowBounds, +}; +use crate::colors::DefaultColors; + +/// A window hosting a single undocked panel. +/// +/// See the [module documentation](crate::dock) — floating support is not yet +/// wired into [`DockArea`](crate::dock::DockArea), so this view is only +/// constructed by callers that host a panel in their own window. +/// +/// The window renders a minimal title bar (panel title, re-dock drag surface, +/// close button) above the panel's view, and reports its bounds back to the +/// owning [`DockArea`](crate::dock::DockArea) so +/// [`DockLayoutState`](crate::dock::DockLayoutState) can restore the window +/// geometry. +pub struct FloatingPanelWindow { + /// The panel hosted by this window. + panel: PanelHandle, + /// Last known window position, mirrored into layout snapshots. + #[allow(dead_code)] // read once floating-window geometry is persisted + origin: Point, +} + +impl FloatingPanelWindow { + /// Creates the content view for a new floating window hosting `panel`. + /// + /// The caller is responsible for opening the window with + /// `cx.open_window` and remembering its handle so it can be closed when + /// the panel re-docks. `initial_bounds` comes from the saved layout, or + /// from a sensible default near the main window. + pub fn new( + panel: PanelHandle, + initial_bounds: Option, + cx: &mut Context, + ) -> Self { + let _ = cx; + let origin = initial_bounds + .map(|bounds| bounds.get_bounds().origin) + .unwrap_or_else(|| Point::new(px(0.0), px(0.0))); + Self { panel, origin } + } +} + +impl Render for FloatingPanelWindow { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let _ = window; + let title = self.panel.title().clone(); + let view = self.panel.view().clone(); + div().flex().flex_col().h_full().bg(cx.default_colors().clone().background) + .child( + div().flex().flex_row().items_center().w_full().px_2().py_1() + .child(div().flex_1().text_xs().truncate().child(title)), + ) + .child(div().flex_1().min_h_0().overflow_hidden().child(view)) + } +} diff --git a/crates/gpui/src/dock/layout.rs b/crates/gpui/src/dock/layout.rs new file mode 100644 index 0000000000..0e268a94d5 --- /dev/null +++ b/crates/gpui/src/dock/layout.rs @@ -0,0 +1,918 @@ +//! The dock layout tree, drop targeting, and serializable layout snapshots. +//! +//! [`DockLayout`] owns the *shape* of a docked workspace as a tree of +//! [`DockNode`]s. The tree stores only [`PanelId`]s; the actual panel views +//! live in the [`DockArea`](crate::dock::DockArea)(crate::dock::DockArea). All structural edits go +//! through the layout operations on `DockLayout` ([`insert_panel`], +//! [`remove_panel`], [`move_panel`], [`resize_split`]), which maintain the +//! invariants documented on [`DockNode`]; [`cleanup`](DockLayout::cleanup) +//! re-normalizes after edits. +//! +//! Persistence is split in two: [`DockLayoutState`](crate::dock::DockLayoutState) is a serde snapshot of the +//! tree with panels referenced by *string keys*, and [`PanelRegistry`] is the +//! application-implemented bridge between keys and live panels. + +use crate::Axis; +use crate::dock::{PanelHandle, PanelId}; +use crate::{App, Window}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::hash::{DefaultHasher, Hash, Hasher}; + +/// One node of the dock layout tree. +/// +/// # Invariants +/// +/// These are maintained by every [`DockLayout`] mutator (and re-established +/// by [`DockLayout::cleanup`]); code constructing nodes by hand must uphold +/// them: +/// +/// - `Split.children` has at least two entries and contains no direct +/// `Split` child with the same `direction` (such nests are flattened). +/// - `Split.ratio` is finite and clamped to `(0.0, 1.0)` exclusive; see +/// [`DockLayout::resize_split`] for clamping against minimum panel sizes. +/// - `Tabs.panels` is non-empty and `Tabs.active < panels.len()`. +/// - Every [`PanelId`] occurs at most once in the whole tree. +/// +/// A tree consisting of a single `Panel` leaf is valid. An "empty" layout is +/// represented by [`DockLayout::root`] being `None`, never by empty nodes. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum DockNode { + /// A row or column of child nodes sized proportionally. + Split { + /// The axis along which children are laid out: [`Axis::Horizontal`] + /// places children side by side, [`Axis::Vertical`] stacks them. + direction: Axis, + /// Fraction of the available extent (along `direction`) assigned to + /// the first child, relative to the remaining children. For two + /// children this is simply the first child's share. Adjusted by + /// dragging a split handle; see + /// [`DockLayout::resize_split`]. + ratio: f32, + /// The children, in layout order. Never empty, never a single child, + /// and never contains a nested `Split` with the same `direction`. + children: Vec, + }, + /// A tab group showing one of several panels at a time. + Tabs { + /// Panels in tab order. Non-empty. + panels: Vec, + /// Index into `panels` of the visible tab. Always `< panels.len()`. + active: usize, + }, + /// A leaf holding exactly one panel. + Panel(PanelId), +} + +/// Where, relative to a drop target, a dragged panel should be docked. +/// +/// Every tab group / leaf panel offers all five zones while a drag is in +/// flight; the root additionally offers its four outer edges (see +/// [`DropTarget`]). Hit-testing from a cursor position is done by +/// [`DockArea::drop_zone_at`](crate::dock::DockArea::drop_zone_at). +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum DropZone { + /// Dock to the left of the target: split the target's node horizontally, + /// inserting the dragged panel as the new left child. + Left, + /// Dock to the right of the target (horizontal split, new right child). + Right, + /// Dock above the target (vertical split, new top child). + Top, + /// Dock below the target (vertical split, new bottom child). + Bottom, + /// Merge the dragged panel into the target as a new tab. The dragged + /// panel becomes the active tab of the resulting group. + Center, +} + +impl DropZone { + /// All five zones, in declaration order. Useful for painting affordances. + pub const ALL: [DropZone; 5] = [ + DropZone::Left, + DropZone::Right, + DropZone::Top, + DropZone::Bottom, + DropZone::Center, + ]; + + /// Returns `true` if this zone splits the target rather than merging into + /// it as a tab — i.e. anything except [`DropZone::Center`]. + pub const fn is_split(self) -> bool { + !matches!(self, DropZone::Center) + } + + /// Returns `true` if this zone merges the dragged panel into the target + /// as a tab ([`DropZone::Center`]). + pub const fn is_merge(self) -> bool { + matches!(self, DropZone::Center) + } + + /// Returns the split axis this zone implies, or `None` for + /// [`DropZone::Center`]. `Left`/`Right` split along + /// [`Axis::Horizontal`], `Top`/`Bottom` along [`Axis::Vertical`]. + pub const fn split_axis(self) -> Option { + match self { + DropZone::Left | DropZone::Right => Some(Axis::Horizontal), + DropZone::Top | DropZone::Bottom => Some(Axis::Vertical), + DropZone::Center => None, + } + } +} + +/// A concrete place a dragged panel can be dropped. +/// +/// Combines the panel (tab group) being hovered with the zone within it. +/// When `panel` is `None`, the target is an outer edge of the whole layout +/// root — this is how a drop on the window's very edge splits the entire +/// tree. `zone` must be an edge zone (never [`DropZone::Center`]) when +/// `panel` is `None`. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DropTarget { + /// The panel whose tab group / leaf is targeted, or `None` to target an + /// outer edge of the root. + pub panel: Option, + /// The zone within the target. + pub zone: DropZone, +} + +/// Path addressing a node within a [`DockLayout`]: child indices from the root. +/// +/// `NodePath(vec![])` addresses the root; each successive index descends into +/// that node's `children` (for splits) or is invalid (for `Tabs`/`Panel`, +/// which have no node children). Paths are invalidated by any structural edit +/// and must be re-derived with [`DockLayout::find_panel`] afterwards. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct NodePath(pub Vec); + +/// Returns a stable `usize` key for `path`, used to derive element ids for +/// per-node chrome (split containers, split handles). +/// +/// Two different paths always produce different keys; equal paths produce +/// equal keys. +pub(crate) fn path_key(path: &NodePath) -> usize { + let mut hasher = DefaultHasher::new(); + path.0.hash(&mut hasher); + hasher.finish() as usize +} + +/// The dock layout tree: the shape of one [`DockArea`](crate::dock::DockArea)'s workspace. +/// +/// The tree is pure data (no views, no GPUI handles) and is cheap to clone. +/// All edits go through the methods below so the [`DockNode`] invariants are +/// preserved; after any sequence of edits the mutators run +/// [`cleanup`](DockLayout::cleanup) themselves. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct DockLayout { + root: Option, +} + +impl DockLayout { + /// Creates an empty layout (no root node). + pub fn new() -> Self { + Self { root: None } + } + + /// Returns the root node, or `None` if the layout is empty. + pub fn root(&self) -> Option<&DockNode> { + self.root.as_ref() + } + + /// Returns the root node mutably, or `None` if the layout is empty. + pub(crate) fn root_mut(&mut self) -> Option<&mut DockNode> { + self.root.as_mut() + } + + /// Returns `true` if `panel` occurs anywhere in the tree. + pub fn contains(&self, panel: PanelId) -> bool { + let Some(root) = self.root.as_ref() else { + return false; + }; + let mut stack = vec![root]; + while let Some(node) = stack.pop() { + match node { + DockNode::Panel(id) => { + if *id == panel { + return true; + } + } + DockNode::Tabs { panels, .. } => { + if panels.contains(&panel) { + return true; + } + } + DockNode::Split { children, .. } => stack.extend(children), + } + } + false + } + + /// Returns the path to the node containing `panel`, or `None` if the + /// panel is not in the tree. + /// + /// For a `Panel` leaf the path addresses the leaf itself; for a tab in a + /// `Tabs` group it addresses the `Tabs` node (inspect the node to get the + /// tab index). + pub fn find_panel(&self, panel: PanelId) -> Option { + let root = self.root.as_ref()?; + Self::find_panel_in(root, panel, &mut Vec::new()) + } + + /// Depth-first search recording the path taken; the path of the node + /// containing `panel` when found. + fn find_panel_in(node: &DockNode, panel: PanelId, path: &mut Vec) -> Option { + match node { + DockNode::Panel(id) if *id == panel => Some(NodePath(path.clone())), + DockNode::Panel(_) => None, + DockNode::Tabs { panels, .. } => { + if panels.contains(&panel) { + Some(NodePath(path.clone())) + } else { + None + } + } + DockNode::Split { children, .. } => { + for (index, child) in children.iter().enumerate() { + path.push(index); + if let Some(found) = Self::find_panel_in(child, panel, path) { + return Some(found); + } + path.pop(); + } + None + } + } + } + + /// Inserts `panel` at `target`. + /// + /// - `target: None` — the layout must be empty; `panel` becomes the root. + /// If the layout is *not* empty this is a no-op returning `false` + /// (choose a concrete [`DropTarget`] instead). + /// - Edge zones — wraps/splits the target node along + /// [`DropZone::split_axis`]. If the target already sits inside a split + /// with the same axis, the panel is inserted as a sibling instead of + /// nesting. + /// - [`DropZone::Center`] — appends `panel` to the target's tab group + /// (converting a `Panel` leaf into `Tabs`) and makes it active. + /// + /// Returns `true` if the tree changed. Fails (returns `false`) if + /// `panel` is already present — use [`move_panel`](DockLayout::move_panel) + /// to relocate — or if the target panel no longer exists. + /// + /// # Panics + /// + /// Panics in debug builds if `target.panel` is `None` and + /// `target.zone` is [`DropZone::Center`]. + pub fn insert_panel(&mut self, panel: PanelId, target: Option) -> bool { + if self.contains(panel) { + return false; + } + + let Some(target) = target else { + if self.root.is_some() { + return false; + } + self.root = Some(DockNode::Panel(panel)); + return true; + }; + + debug_assert!( + target.panel.is_some() || target.zone != DropZone::Center, + "a root-edge target must use an edge zone, never Center" + ); + + let Some(target_panel) = target.panel else { + // An outer edge of the whole tree: split the root itself. + let changed = self.insert_at_root_edge(panel, target.zone); + if changed { + self.cleanup(); + } + return changed; + }; + + if !self.contains(target_panel) { + return false; + } + + if target.zone.is_merge() { + self.insert_into_tabs(panel, target_panel); + } else { + self.insert_at_edge(panel, target_panel, target.zone); + } + self.cleanup(); + true + } + + /// Inserts `panel` at an outer edge of the root, splitting the whole tree + /// unless the root is already a split along the same axis (in which case + /// the panel becomes a sibling). + fn insert_at_root_edge(&mut self, panel: PanelId, zone: DropZone) -> bool { + let Some(mut root) = self.root.take() else { + return false; + }; + let axis = zone + .split_axis() + .expect("root-edge zones always imply a split axis"); + let before = matches!(zone, DropZone::Left | DropZone::Top); + + if let DockNode::Split { + direction, + children, + .. + } = &mut root + { + if *direction == axis { + if before { + children.insert(0, DockNode::Panel(panel)); + } else { + children.push(DockNode::Panel(panel)); + } + self.root = Some(root); + return true; + } + } + + self.root = Some(DockNode::Split { + direction: axis, + ratio: 0.5, + children: if before { + vec![DockNode::Panel(panel), root] + } else { + vec![root, DockNode::Panel(panel)] + }, + }); + true + } + + /// Inserts `panel` relative to the node containing `target_panel`, + /// splitting it unless it already sits in a same-axis split (then the + /// panel is inserted as a sibling instead). + fn insert_at_edge(&mut self, panel: PanelId, target_panel: PanelId, zone: DropZone) { + let axis = zone.split_axis().expect("edge zones imply a split axis"); + let path = self + .find_panel(target_panel) + .expect("caller checked the target exists"); + let before = matches!(zone, DropZone::Left | DropZone::Top); + + // If the target sits inside a split along the same axis, insert the + // panel as a sibling rather than nesting a split inside a split. + let mut parent = path.0.clone(); + if parent.pop().is_some() { + let parent_path = NodePath(parent); + if let Some(DockNode::Split { + direction, + children, + .. + }) = self.node_at_mut(&parent_path) + { + if *direction == axis { + let index = *path.0.last().expect("non-root path has a last index"); + children.insert( + if before { index } else { index + 1 }, + DockNode::Panel(panel), + ); + return; + } + } + } + + // Otherwise wrap the target node in a fresh split. + let old_node = self + .node_at(&path) + .expect("path was just derived") + .clone(); + let new_node = DockNode::Panel(panel); + let replacement = DockNode::Split { + direction: axis, + ratio: 0.5, + children: if before { + vec![new_node, old_node] + } else { + vec![old_node, new_node] + }, + }; + *self + .node_at_mut(&path) + .expect("path was just derived") = replacement; + } + + /// Appends `panel` to the tab group containing `target_panel` (converting + /// a `Panel` leaf into a `Tabs` node) and makes it active. + fn insert_into_tabs(&mut self, panel: PanelId, target_panel: PanelId) { + let path = self + .find_panel(target_panel) + .expect("caller checked the target exists"); + let node = self.node_at_mut(&path).expect("path was just derived"); + match node { + DockNode::Panel(_) => { + *node = DockNode::Tabs { + panels: vec![target_panel, panel], + active: 1, + }; + } + DockNode::Tabs { panels, active } => { + panels.push(panel); + *active = panels.len() - 1; + } + DockNode::Split { .. } => unreachable!("find_panel never addresses a Split"), + } + } + + /// Removes `panel` from the tree, running [`cleanup`](DockLayout::cleanup) + /// to collapse nodes left empty or single-childed. + /// + /// Returns `true` if the panel was present. Removing the last panel sets + /// the root to `None`. + pub fn remove_panel(&mut self, panel: PanelId) -> bool { + let Some(root) = self.root.as_mut() else { + return false; + }; + if !Self::remove_from_node(root, panel) { + return false; + } + self.cleanup(); + true + } + + /// Recursively removes `panel` from `node`, returning whether it was found. + fn remove_from_node(node: &mut DockNode, panel: PanelId) -> bool { + match node { + DockNode::Panel(id) => *id == panel, + DockNode::Tabs { panels, active } => { + let Some(index) = panels.iter().position(|&other| other == panel) else { + return false; + }; + panels.remove(index); + if !panels.is_empty() { + *active = (*active).min(panels.len() - 1); + } + true + } + DockNode::Split { children, .. } => children + .iter_mut() + .any(|child| Self::remove_from_node(child, panel)), + } + } + + /// Atomically moves `panel` to `target`. + /// + /// Equivalent to [`remove_panel`](DockLayout::remove_panel) followed by + /// [`insert_panel`](DockLayout::insert_panel), but a no-op (returning + /// `false`) if `panel` is not present or the drop would land the panel + /// back in its own position (e.g. `Center` onto its own group). Moving + /// the only panel of a group away collapses the group. + pub fn move_panel(&mut self, panel: PanelId, target: DropTarget) -> bool { + if !self.contains(panel) { + return false; + } + // Dropping onto the panel's own node — its group for `Center`, its own + // node for edge zones — would target a node that no longer exists + // after the removal below, so treat every self-drop as a no-op. + if target.panel == Some(panel) { + return false; + } + let removed = self.remove_panel(panel); + debug_assert!(removed, "panel presence was checked above"); + let inserted = self.insert_panel(panel, Some(target)); + debug_assert!(inserted, "drop target must remain valid after the removal"); + inserted + } + + /// Sets the `ratio` of the `Split` node at `path`. + /// + /// `ratio` is clamped so that no child shrinks below its minimum extent + /// (see the min-size constants in + /// the `split_handle` module); out-of-range values are + /// clamped rather than rejected. + /// + /// # Panics + /// + /// Panics if `path` does not address a [`DockNode::Split`]. + pub fn resize_split(&mut self, path: &NodePath, ratio: f32) { + let Some(node) = self.node_at_mut(path) else { + panic!("resize_split: path {path:?} does not address a node"); + }; + let DockNode::Split { ratio: current, .. } = node else { + panic!("resize_split: path {path:?} does not address a Split node"); + }; + // Pixel-level minimum extents (see split_handle::MIN_CHILD_EXTENT) + // depend on the rendered size and cannot be enforced on a ratio; + // clamp to a conservative fraction so both children keep a share. + *current = ratio.clamp(0.05, 0.95); + } + + /// Re-establishes the [`DockNode`] invariants after structural edits. + /// + /// Concretely: removes empty `Tabs` nodes, replaces single-child `Split`s + /// with their child, flattens same-direction `Split` nests, and clamps + /// `active` tab indices into range. All public mutators call this + /// internally; call it manually only after mutating nodes obtained via + /// interior references (which the API avoids exposing for this reason). + pub fn cleanup(&mut self) { + self.root = self.root.take().and_then(Self::cleanup_node); + } + + /// Normalizes a single node, returning `None` when it collapses away. + fn cleanup_node(node: DockNode) -> Option { + match node { + DockNode::Panel(_) => Some(node), + DockNode::Tabs { + mut panels, + mut active, + } => { + if panels.is_empty() { + return None; + } + active = active.min(panels.len() - 1); + Some(DockNode::Tabs { panels, active }) + } + DockNode::Split { + direction, + ratio, + children, + } => { + let children: Vec = children + .into_iter() + .filter_map(Self::cleanup_node) + .collect(); + // Flatten direct same-direction split nests. + let mut flat = Vec::with_capacity(children.len()); + for child in children { + match child { + DockNode::Split { + direction: nested_direction, + children: nested_children, + .. + } if nested_direction == direction => flat.extend(nested_children), + other => flat.push(other), + } + } + let ratio = if ratio.is_finite() { + ratio.clamp(0.05, 0.95) + } else { + 0.5 + }; + match flat.len() { + 0 => None, + 1 => Some(flat.pop().expect("len == 1")), + _ => Some(DockNode::Split { + direction, + ratio, + children: flat, + }), + } + } + } + } + + /// Visits every [`PanelId`] in the tree, in depth-first order. + pub fn panels(&self) -> Vec { + let mut panels = Vec::new(); + if let Some(root) = &self.root { + Self::collect_panels(root, &mut panels); + } + panels + } + + fn collect_panels(node: &DockNode, out: &mut Vec) { + match node { + DockNode::Panel(id) => out.push(*id), + DockNode::Tabs { panels, .. } => out.extend_from_slice(panels), + DockNode::Split { children, .. } => { + for child in children { + Self::collect_panels(child, out); + } + } + } + } + + /// Returns the `ratio` of the `Split` node at `path`, or `None` if `path` + /// does not address a split. + pub fn split_ratio(&self, path: &NodePath) -> Option { + match self.node_at(path) { + Some(DockNode::Split { ratio, .. }) => Some(*ratio), + _ => None, + } + } + + /// Makes `panel` the active tab of the `Tabs` node at `path`, if present. + pub fn set_tabs_active(&mut self, path: &NodePath, panel: PanelId) -> bool { + let Some(node) = self.node_at_mut(path) else { + return false; + }; + let DockNode::Tabs { panels, active } = node else { + return false; + }; + match panels.iter().position(|&other| other == panel) { + Some(index) => { + *active = index; + true + } + None => false, + } + } + + /// Returns the node at `path`, or `None` if the path is invalid. + fn node_at(&self, path: &NodePath) -> Option<&DockNode> { + let mut node = self.root.as_ref()?; + for &index in &path.0 { + let DockNode::Split { children, .. } = node else { + return None; + }; + node = children.get(index)?; + } + Some(node) + } + + /// Returns a mutable reference to the node at `path`, or `None`. + pub(crate) fn node_at_mut(&mut self, path: &NodePath) -> Option<&mut DockNode> { + let mut node = self.root.as_mut()?; + for &index in &path.0 { + let DockNode::Split { children, .. } = node else { + return None; + }; + node = children.get_mut(index)?; + } + Some(node) + } +} + +/// Application-provided bridge between panel string keys and live panels. +/// +/// Panels are live views and cannot be serialized, so persistence stores only +/// a stable string key per panel. On save, [`panel_key`](PanelRegistry::panel_key) +/// maps each [`PanelId`] to its key; on restore, +/// [`build_panel`](PanelRegistry::build_panel) reconstructs a fresh view from +/// a key. The application owns the mapping — e.g. `"media-bin"`, +/// `"program-monitor"`, or per-project keys like `"inspector:clip-42"`. +/// +/// Keys must round-trip: a key produced by `panel_key` must be accepted by +/// `build_panel`. Keys that fail to rebuild are dropped from the restored +/// layout (with their positions collapsed by [`DockLayout::cleanup`]), so a +/// panel type removed in a newer app version degrades gracefully instead of +/// failing the whole restore. +/// +/// # Examples +/// +/// ```ignore +/// struct OakPanelRegistry; +/// +/// impl PanelRegistry for OakPanelRegistry { +/// fn panel_key(&self, id: PanelId) -> Option { +/// match id.raw() { +/// 1 => Some("media-bin".into()), +/// 2 => Some("program-monitor".into()), +/// _ => None, +/// } +/// } +/// +/// fn build_panel(&self, key: &str, window: &mut Window, cx: &mut App) +/// -> Option +/// { +/// match key { +/// "media-bin" => Some(PanelHandle::new(cx.new(|_| MediaBin::new()), cx)), +/// "program-monitor" => Some(PanelHandle::new(cx.new(|_| Monitor::new()), cx)), +/// _ => None, +/// } +/// } +/// } +/// ``` +pub trait PanelRegistry: 'static { + /// Returns the stable string key for a live panel, or `None` if the panel + /// is transient and should be omitted from saved layouts. + fn panel_key(&self, id: PanelId) -> Option; + + /// Rebuilds the panel identified by `key`, or returns `None` if the key + /// is unknown (the panel is then skipped during restore). + /// + /// Called on the main thread during + /// [`DockArea::restore_state`](crate::dock::DockArea::restore_state)(crate::dock::DockArea::restore_state); the registry may create entities with + /// `cx.new` and perform per-panel setup, but should not open windows or + /// otherwise mutate the dock area. + fn build_panel(&self, key: &str, window: &mut Window, cx: &mut App) -> Option; +} + +/// A serde-serializable snapshot of a [`DockLayout`], with panels referenced +/// by registry string keys. +/// +/// This is the type to persist (via `serde_json`, a settings file, ...). +/// Capture it from a live layout with [`DockLayoutState::capture`] and turn it +/// back into a tree with [`DockLayoutState::to_layout`]; then hand it to +/// [`DockArea::restore_state`](crate::dock::DockArea::restore_state)(crate::dock::DockArea::restore_state), which rebuilds the views through the +/// [`PanelRegistry`]. +/// +/// The serialized form is versioned via the `version` field; the current +/// version is [`DockLayoutState::VERSION`]. Unknown/newer versions should be +/// rejected by the caller before restoring. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct DockLayoutState { + /// Snapshot format version; written by [`capture`](DockLayoutState::capture), + /// checked by the caller on load. + pub version: u32, + root: Option, + /// Registry keys for panels that existed when the snapshot was taken but + /// whose `panel_key` returned `Some` while they were not reachable in the + /// tree (reserved for floating panels; see + /// [`FloatingPanelWindow`](crate::dock::FloatingPanelWindow)). Empty until floating support + /// lands. + #[serde(default)] + pub floating: BTreeMap, +} + +/// Reserved per-floating-panel data inside [`DockLayoutState`](crate::dock::DockLayoutState). +/// +/// Placeholder for the deferred floating-window feature (see +/// [`FloatingPanelWindow`](crate::dock::FloatingPanelWindow)): remembers that a panel was undocked +/// and where its window was. Not yet produced by +/// [`DockLayoutState::capture`]. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct SerializedFloating { + /// Logical x position of the floating window, in pixels. + pub x: f32, + /// Logical y position of the floating window, in pixels. + pub y: f32, + /// Width of the floating window, in pixels. + pub width: f32, + /// Height of the floating window, in pixels. + pub height: f32, +} + +/// A serialized [`DockNode`] with panel keys instead of ids. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +enum SerializedNode { + /// Serialized [`DockNode::Split`]. + Split { + /// See [`DockNode::Split::direction`]. + direction: Axis, + /// See [`DockNode::Split::ratio`]. + ratio: f32, + /// See [`DockNode::Split::children`]. + children: Vec, + }, + /// Serialized [`DockNode::Tabs`]; `active` is an index into `panels`. + Tabs { + /// Registry keys of the tabbed panels, in tab order. + panels: Vec, + /// Active tab index. + active: usize, + }, + /// Serialized [`DockNode::Panel`], holding the panel's registry key. + Panel(String), +} + +impl DockLayoutState { + /// The snapshot format version written by + /// [`capture`](DockLayoutState::capture). + pub const VERSION: u32 = 1; + + /// Snapshots `layout`, translating panel ids to string keys via + /// `registry`. + /// + /// Panels whose [`PanelRegistry::panel_key`] returns `None` are omitted + /// from the snapshot; their nodes are collapsed as if removed. + pub fn capture(layout: &DockLayout, registry: &dyn PanelRegistry) -> Self { + let root = layout + .root + .as_ref() + .and_then(|node| Self::serialize_node(node, registry)); + Self { + version: Self::VERSION, + root, + floating: BTreeMap::new(), + } + } + + /// Serializes `node`, dropping panels without a key; `None` when the node + /// collapses away entirely. + fn serialize_node(node: &DockNode, registry: &dyn PanelRegistry) -> Option { + match node { + DockNode::Panel(id) => registry.panel_key(*id).map(SerializedNode::Panel), + DockNode::Tabs { panels, active } => { + let active_panel = panels.get(*active).copied(); + let mut serialized = Vec::new(); + let mut serialized_active = 0; + for id in panels.iter() { + if let Some(key) = registry.panel_key(*id) { + if Some(*id) == active_panel { + serialized_active = serialized.len(); + } + serialized.push(key); + } + } + if serialized.is_empty() { + return None; + } + serialized_active = serialized_active.min(serialized.len() - 1); + Some(SerializedNode::Tabs { + panels: serialized, + active: serialized_active, + }) + } + DockNode::Split { + direction, + ratio, + children, + } => { + let children: Vec = children + .iter() + .filter_map(|child| Self::serialize_node(child, registry)) + .collect(); + match children.len() { + 0 => None, + 1 => children.into_iter().next(), + _ => Some(SerializedNode::Split { + direction: *direction, + ratio: *ratio, + children, + }), + } + } + } + } + + /// Rebuilds the pure tree shape, leaving view reconstruction to the + /// caller (see [`DockArea::restore_state`](crate::dock::DockArea::restore_state)(crate::dock::DockArea::restore_state), which resolves keys through + /// the registry). + /// + /// The returned layout is normalized ([`DockLayout::cleanup`] has run). + pub fn to_layout(&self) -> DockLayout { + let root = self.root.as_ref().and_then(Self::deserialize_node); + let mut layout = DockLayout { root }; + layout.cleanup(); + layout + } + + /// Deserializes a single node, addressing panels by deterministic interim + /// ids (see [`interim_id`]); `None` when the node collapses away. + fn deserialize_node(node: &SerializedNode) -> Option { + match node { + SerializedNode::Panel(key) => Some(DockNode::Panel(interim_id(key))), + SerializedNode::Tabs { panels, active } => { + if panels.is_empty() { + return None; + } + let panels: Vec = panels.iter().map(|key| interim_id(key)).collect(); + Some(DockNode::Tabs { + active: (*active).min(panels.len() - 1), + panels, + }) + } + SerializedNode::Split { + direction, + ratio, + children, + } => { + let children: Vec = children + .iter() + .filter_map(Self::deserialize_node) + .collect(); + match children.len() { + 0 => None, + 1 => children.into_iter().next(), + _ => Some(DockNode::Split { + direction: *direction, + ratio: *ratio, + children, + }), + } + } + } + } + + /// Collects the registry keys referenced by this snapshot, depth-first. + /// + /// Used by [`DockArea::restore_state`](crate::dock::DockArea::restore_state)(crate::dock::DockArea::restore_state) to rebuild panels + /// (and learn their real ids) before re-keying the tree. + pub(crate) fn keys(&self) -> Vec { + let mut keys = Vec::new(); + if let Some(root) = &self.root { + Self::collect_keys(root, &mut keys); + } + keys + } + + fn collect_keys(node: &SerializedNode, out: &mut Vec) { + match node { + SerializedNode::Panel(key) => out.push(key.clone()), + SerializedNode::Tabs { panels, .. } => out.extend(panels.iter().cloned()), + SerializedNode::Split { children, .. } => { + for child in children { + Self::collect_keys(child, out); + } + } + } + } +} + +/// Maps a registry key to a deterministic interim [`PanelId`]. +/// +/// [`DockLayoutState::to_layout`] cannot know the real ids of rebuilt panels, +/// so it addresses nodes with ids derived from the key. `restore_state` later +/// rebuilds the real panels and re-maps the tree using the same hash, so the +/// two passes agree. +pub(crate) fn interim_id(key: &str) -> PanelId { + let mut hasher = DefaultHasher::new(); + key.hash(&mut hasher); + PanelId::new(hasher.finish()) +} diff --git a/crates/gpui/src/dock/mod.rs b/crates/gpui/src/dock/mod.rs new file mode 100644 index 0000000000..53b73f23c2 --- /dev/null +++ b/crates/gpui/src/dock/mod.rs @@ -0,0 +1,89 @@ +//! Dockable panel layout system: tabs, splits, and drag-to-dock. +//! +//! This module provides the building blocks for IDE/NLE-style user interfaces in +//! which the user can rearrange the workspace by dragging panels between +//! tab groups and split containers, and where the resulting layout can be +//! persisted and restored across sessions. +//! +//! # Architecture +//! +//! The layout of a [`DockArea`](crate::dock::DockArea) is an immutable-by-convention tree of +//! [`DockNode`](crate::dock::DockNode)s: +//! +//! - `Split { direction, ratio, children }` — a row or column of child nodes, +//! sized proportionally. `ratio` is the fraction of the cross axis given to +//! the first child; with more than two children it is the fraction given to +//! the first child relative to the rest. See [`DockLayout::resize_split`](crate::dock::DockLayout::resize_split). +//! - `Tabs { panels, active }` — a tab group showing one panel at a time, +//! with a tab strip (the internal `tab_bar` component) for switching, +//! closing, and reordering. +//! - `Panel(panel_id)` — a leaf holding exactly one panel. +//! +//! Panels themselves are ordinary GPUI views that implement [`DockPanel`](crate::dock::DockPanel) +//! (on top of [`Render`](crate::Render)). They are held by the [`DockArea`](crate::dock::DockArea) +//! as type-erased [`PanelHandle`](crate::dock::PanelHandle)s keyed by [`PanelId`](crate::dock::PanelId); the tree stores only +//! ids, never views. +//! +//! # Drag to dock +//! +//! Dragging a panel by its tab (or a dedicated drag surface) starts a dock +//! drag. While dragging, every potential target offers five [`DropZone`](crate::dock::DropZone)s — +//! `Left`, `Right`, `Top`, `Bottom`, and `Center` — computed by +//! [`DockArea::drop_zone_at`](crate::dock::DockArea::drop_zone_at). Dropping on an edge zone splits the target +//! node in that direction; dropping on `Center` merges the dragged panel into +//! the target as a new tab. In addition, the outer edges of the root offer +//! drop zones that split the entire layout. A translucent drop indicator is +//! rendered above the content using [`deferred`](crate::deferred) so +//! it is not clipped by panel bounds. +//! +//! # Persistence +//! +//! Because panels are live views, only the *shape* of the tree plus stable +//! string keys for panels can be serialized. [`DockLayoutState`](crate::dock::DockLayoutState) is a +//! serde-serializable snapshot; the application supplies a [`PanelRegistry`](crate::dock::PanelRegistry) +//! that maps [`PanelId`](crate::dock::PanelId)s to string keys on save and rebuilds views from +//! those keys on restore. See [`DockLayout`](crate::dock::DockLayout) for details. +//! +//! # Wiring into your app +//! +//! The intended consumer is the Oak video editor: a media/project bin, source +//! and program monitors, an inspector, and a timeline, all dockable. The +//! typical setup is: +//! +//! 1. Implement [`DockPanel`](crate::dock::DockPanel) for each panel view (media bin, viewer, +//! inspector, timeline, ...). +//! 2. Implement [`PanelRegistry`](crate::dock::PanelRegistry) for an application type that knows how to +//! construct each panel from its string key. +//! 3. Create a [`DockArea`](crate::dock::DockArea), register the registry with +//! [`DockArea::with_registry`](crate::dock::DockArea::with_registry), and seed panels with [`DockArea::add_panel`](crate::dock::DockArea::add_panel). +//! 4. On startup, call [`DockArea::restore_state`](crate::dock::DockArea::restore_state) with the previously saved +//! [`DockLayoutState`](crate::dock::DockLayoutState) (e.g. from `serde_json`); on quit, persist +//! [`DockArea::save_state`](crate::dock::DockArea::save_state). +//! +//! ```ignore +//! let dock = cx.new(|cx| { +//! DockArea::new(cx) +//! .with_registry(Arc::new(MyPanelRegistry)) +//! }); +//! dock.update(cx, |dock, cx| { +//! dock.add_panel(PanelHandle::new(media_bin, cx), None, cx); +//! dock.add_panel(PanelHandle::new(viewer, cx), None, cx); +//! }); +//! ``` +//! +//! See `examples/learn/dock_layout.rs` for a fuller sketch. + +mod dock_area; +mod floating; +mod layout; +mod panel; +mod split_handle; +mod tab_bar; + +pub use dock_area::{DockArea, DockEvent}; +pub use floating::FloatingPanelWindow; +pub(crate) use layout::path_key; +pub use layout::{ + DockLayout, DockLayoutState, DockNode, DropTarget, DropZone, NodePath, PanelRegistry, +}; +pub use panel::{DockPanel, PanelEvent, PanelHandle, PanelId}; diff --git a/crates/gpui/src/dock/panel.rs b/crates/gpui/src/dock/panel.rs new file mode 100644 index 0000000000..174f0f8f1b --- /dev/null +++ b/crates/gpui/src/dock/panel.rs @@ -0,0 +1,255 @@ +//! Panel identity, the [`DockPanel`](crate::dock::DockPanel) trait, and type-erased panel handles. +//! +//! A *panel* is any view the user can dock. It is an ordinary GPUI view +//! ([`Render`]) that additionally implements [`DockPanel`](crate::dock::DockPanel) so the dock system +//! can identify it, label its tab, and negotiate closing. The [`DockArea`](crate::dock::DockArea) +//! stores panels as [`PanelHandle`]s — a type-erased wrapper around +//! [`AnyView`] plus the metadata the dock chrome (tab strip, drop overlay) +//! needs without downcasting. + +use crate::{ + AnyElement, AnyView, App, Context, Entity, EventEmitter, Render, SharedString, Subscription, + Window, +}; +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Stable, copyable identifier for a docked panel. +/// +/// A `PanelId` uniquely identifies one panel instance within a [`DockArea`](crate::dock::DockArea) +/// for its whole lifetime: the layout tree ([`DockNode`](crate::dock::DockNode)) +/// refers to panels exclusively by id, and events such as +/// [`DockEvent::PanelFocused`](crate::dock::DockEvent::PanelFocused) carry it. +/// +/// Ids are assigned by the panel implementation (or the application) via +/// [`DockPanel::panel_id`]. They must be unique within a dock area; adding a +/// second panel with an existing id is an error (see +/// [`DockArea::add_panel`](crate::dock::DockArea::add_panel)(crate::dock::DockArea::add_panel)). Ids are *not* +/// required to be stable across sessions — persistence goes through string +/// keys, see [`PanelRegistry`](crate::dock::PanelRegistry). +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct PanelId(u64); + +impl PanelId { + /// Creates a panel id from a raw numeric value. + /// + /// The value only needs to be unique within the owning + /// [`DockArea`](crate::dock::DockArea)(crate::dock::DockArea); a simple per-application counter + /// (or a hash of a stable name) is sufficient. + pub const fn new(raw: u64) -> Self { + Self(raw) + } + + /// Returns the raw numeric value backing this id. + pub const fn raw(self) -> u64 { + self.0 + } +} + +impl fmt::Display for PanelId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "panel-{}", self.0) + } +} + +/// Events a dock panel can emit to its containing [`DockArea`](crate::dock::DockArea)(crate::dock::DockArea). +/// +/// Panels emit these through their [`EventEmitter`] implementation +/// (required by [`DockPanel`](crate::dock::DockPanel)). The dock area subscribes to every panel it +/// holds and reacts — e.g. by updating the tab label or starting the close +/// flow — without the panel needing a direct reference to the dock. +#[derive(Clone, Debug)] +pub enum PanelEvent { + /// The panel asked to be closed (e.g. its own close affordance was + /// invoked). + /// + /// The dock area does not remove the panel unconditionally: it first + /// consults [`DockPanel::should_close`], then calls [`DockPanel::on_close`] + /// and removes the panel only if closing was confirmed. + CloseRequested, + /// The panel's content gained keyboard focus. + /// + /// The dock area uses this to keep its own `focused_panel` bookkeeping in + /// sync and to emit + /// [`DockEvent::PanelFocused`](crate::dock::DockEvent::PanelFocused). + Focused, + /// The panel's title changed; the tab strip should re-render the label. + /// + /// The new title is read back through [`DockPanel::title`] rather than + /// carried in the event, so panels never have to clone it. + TitleChanged, +} + +/// A view that can live inside a [`DockArea`](crate::dock::DockArea)(crate::dock::DockArea). +/// +/// Implement this on the same view type that implements [`Render`]. The dock +/// area renders the panel's normal [`Render::render`] output as the tab +/// group's content; this trait only supplies dock-specific metadata and +/// lifecycle hooks. +/// +/// # Required items +/// +/// - [`panel_id`](DockPanel::panel_id) — stable identity. +/// - [`title`](DockPanel::title) — tab label. +/// - [`tab_content`](DockPanel::tab_content) — rich tab content (icon + label, +/// status dot, ...). +/// +/// # Provided items +/// +/// - [`closable`](DockPanel::closable) — whether a close button is shown +/// (default `true`). +/// - [`should_close`](DockPanel::should_close) — veto hook, e.g. an unsaved +/// changes confirmation (default `true`). +/// - [`on_close`](DockPanel::on_close) — cleanup hook run after a confirmed +/// close. +/// +/// # Examples +/// +/// ```ignore +/// struct MediaBin { /* ... */ } +/// +/// impl Render for MediaBin { /* ... */ } +/// impl EventEmitter for MediaBin {} +/// +/// impl DockPanel for MediaBin { +/// fn panel_id(&self) -> PanelId { PanelId::new(1) } +/// fn title(&self, _cx: &App) -> SharedString { "Media Bin".into() } +/// fn tab_content(&self, _cx: &App) -> AnyElement { +/// div().child("Media Bin").into_any_element() +/// } +/// } +/// ``` +pub trait DockPanel: Render + EventEmitter + 'static { + /// Returns the stable id of this panel. + /// + /// Must return the same value for the whole lifetime of the view and must + /// be unique among all panels added to one dock area. + fn panel_id(&self) -> PanelId; + + /// Returns the plain-text title shown in the tab strip and, where + /// relevant, in window titles for floated panels. + /// + /// Called on every render of the containing tab bar, so it should be + /// cheap. Emit [`PanelEvent::TitleChanged`] after changing whatever state + /// feeds this. + fn title(&self, cx: &App) -> SharedString; + + /// Returns the element rendered inside this panel's tab. + /// + /// The default tab bar renders [`title`](DockPanel::title) when this is + /// not customized, but panels may return richer content (icon, dirty + /// indicator, close-on-middle-click affordances). The returned element + /// must not handle close or drag interactions itself — the tab strip + /// overlays those. + fn tab_content(&self, cx: &App) -> AnyElement; + + /// Whether this panel shows a close button and can be closed by the user. + /// + /// Non-closable panels can still be removed programmatically via + /// [`DockArea::remove_panel`](crate::dock::DockArea::remove_panel). + /// Defaults to `true`. + fn closable(&self) -> bool { + true + } + + /// Called when the user has asked to close this panel, before removal. + /// + /// Return `true` to allow the close, `false` to veto it (for example + /// after showing an "unsaved changes" dialog). This may be called on the + /// same event-loop turn as the close request, so asynchronous + /// confirmations should veto now and re-trigger closing later through + /// [`DockArea::remove_panel`](crate::dock::DockArea::remove_panel). + /// Defaults to `true`. + fn should_close(&mut self, _window: &mut Window, _cx: &mut Context) -> bool { + true + } + + /// Called after a close was confirmed and before the panel is removed + /// from the layout. + /// + /// Use this to release resources tied to the dock (subscriptions, + /// scratch entities). The default implementation does nothing. + fn on_close(&mut self, _window: &mut Window, _cx: &mut Context) {} +} + +/// A type-erased panel plus the metadata the dock chrome needs. +/// +/// Wraps the panel's view as an [`AnyView`] so a [`DockArea`](crate::dock::DockArea)(crate::dock::DockArea) +/// can hold heterogeneous panel types in one collection. The metadata +/// ([`PanelId`], title, closability) is a cached snapshot taken at +/// construction and refreshed when the panel emits [`PanelEvent::TitleChanged`]. +/// +/// Construct with [`PanelHandle::new`]; pass to +/// [`DockArea::add_panel`](crate::dock::DockArea::add_panel)(crate::dock::DockArea::add_panel). +pub struct PanelHandle { + id: PanelId, + view: AnyView, + title: SharedString, + closable: bool, + /// Subscription to the panel's [`PanelEvent`]s while it is held by a + /// dock area, installed by [`DockArea`](crate::dock::DockArea) when the + /// panel is added. + subscription: Option, +} + +impl PanelHandle { + /// Wraps a panel view, snapshotting its current metadata. + /// + /// `panel` must implement [`DockPanel`](crate::dock::DockPanel). The handle keeps the view alive + /// for as long as it is stored in the dock area. + /// + /// # Panics + /// + /// Does not panic, but adding two handles with the same + /// [`DockPanel::panel_id`] to one dock area is rejected there. + pub fn new(panel: Entity

, cx: &App) -> Self { + let id = panel.read(cx).panel_id(); + let title = panel.read(cx).title(cx); + let closable = panel.read(cx).closable(); + Self { + id, + view: panel.into(), + title, + closable, + subscription: None, + } + } + + /// Returns the id the panel reported at snapshot time. + pub fn panel_id(&self) -> PanelId { + self.id + } + + /// Returns the cached tab title. + /// + /// May be stale between a title change and the dock area processing + /// [`PanelEvent::TitleChanged`]; treat as display-only. + pub fn title(&self) -> &SharedString { + &self.title + } + + /// Returns the cached value of [`DockPanel::closable`]. + pub fn closable(&self) -> bool { + self.closable + } + + /// Returns the type-erased panel view. + pub fn view(&self) -> &AnyView { + &self.view + } + + /// Returns the subscription to this panel's [`PanelEvent`]s, if the dock + /// area has installed one. + #[allow(dead_code)] // reserved for the dock's panel-event bookkeeping + pub(crate) fn subscription(&self) -> &Option { + &self.subscription + } + + /// Installs (or replaces) the subscription to this panel's [`PanelEvent`]s. + /// + /// Used by [`DockArea`](crate::dock::DockArea) when the panel is added or + /// restored; the previous subscription, if any, is dropped. + pub(crate) fn set_subscription(&mut self, subscription: Option) { + self.subscription = subscription; + } +} diff --git a/crates/gpui/src/dock/split_handle.rs b/crates/gpui/src/dock/split_handle.rs new file mode 100644 index 0000000000..7d101cfd00 --- /dev/null +++ b/crates/gpui/src/dock/split_handle.rs @@ -0,0 +1,225 @@ +//! The draggable divider between the children of a +//! [`DockNode::Split`](crate::dock::DockNode::Split). +//! +//! Internal component — not part of the public API. One `SplitHandle` is +//! rendered between each pair of split children; dragging it adjusts the +//! split's `ratio` through +//! [`DockLayout::resize_split`](crate::dock::DockLayout::resize_split). + +use crate::{ + div, px, App, AppContext, Axis, ClickEvent, Context, ElementId, EventEmitter, + InteractiveElement, IntoElement, Pixels, Point, Render, StatefulInteractiveElement, Styled, + Window, +}; +use crate::colors::DefaultColors; + +use super::{NodePath, path_key}; + +/// Events emitted by a [`SplitHandle`] toward its owning [`DockArea`]. +#[derive(Clone, Debug)] +pub(crate) enum SplitHandleEvent { + /// The user dragged the handle; the split at `path` should be resized to + /// `ratio`. + ResizeRequested { + /// Path of the split node to resize. + path: NodePath, + /// Desired new ratio, already clamped to the allowed range. + ratio: f32, + }, + /// The user double-clicked the handle; the split at `path` should be + /// reset to [`SplitHandle::RESET_RATIO`]. + ResetRequested { + /// Path of the split node to reset. + path: NodePath, + }, +} + +/// The payload of a handle drag; carried by the drag-and-drop system so the +/// split container can identify which handle is being dragged. +#[derive(Clone, Debug)] +pub(crate) struct SplitHandleDrag { + /// Path of the split being resized. + pub(crate) path: NodePath, +} + +/// A resize handle between two children of a split node. +/// +/// # Behavior +/// +/// - Renders as a thin divider along the split's cross axis with the matching +/// resize cursor (`col-resize` for [`Axis::Horizontal`] splits, +/// `row-resize` for [`Axis::Vertical`]). +/// - Dragging converts the pointer delta into a ratio delta (pixels of the +/// parent extent → fraction) and emits +/// [`SplitHandleEvent::ResizeRequested`]; the owning +/// [`DockArea`](crate::dock::DockArea) applies it via +/// [`DockLayout::resize_split`], clamping so neither side shrinks below +/// [`SplitHandle::MIN_CHILD_EXTENT`]. +/// - Double-clicking emits [`SplitHandleEvent::ResetRequested`] to reset the +/// split to an even 50/50. +/// +/// The handle carries the [`NodePath`] of its split so it can address the +/// correct node after unrelated edits elsewhere in the tree; paths are +/// re-derived on every render, never stored across frames. +pub(crate) struct SplitHandle { + /// Axis along which the parent split lays out its children; the handle + /// itself extends along the perpendicular axis. + direction: Axis, + /// Path of the split node this handle resizes, valid for the current + /// frame only. + path: NodePath, + /// Pointer position where the current drag started, if dragging. + drag_origin: Option, +} + +impl SplitHandle { + /// Thickness of the handle's interactive area, in logical pixels. The + /// visual divider may be thinner; the wider hitbox makes the handle + /// grabbable. + pub(crate) const HITBOX: Pixels = Pixels(6.0); + + /// Minimum extent, in logical pixels, that a split child may be resized + /// to by dragging. Expressed as a fraction of the parent extent when + /// computing the drag clamp. + pub(crate) const MIN_CHILD_EXTENT: Pixels = Pixels(120.0); + + /// The ratio a double-click resets to (even split). + pub(crate) const RESET_RATIO: f32 = 0.5; + + /// Creates a handle for the split at `path`. + pub(crate) fn new(direction: Axis, path: NodePath) -> Self { + Self { + direction, + path, + drag_origin: None, + } + } + + /// Begins a drag, remembering the pointer origin. + pub(crate) fn begin_drag(&mut self, origin: Pixels) { + self.drag_origin = Some(origin); + } + + /// Applies an in-progress drag: converts the pointer delta to a ratio + /// delta relative to the parent extent and emits a + /// [`SplitHandleEvent::ResizeRequested`]. + /// + /// `start_ratio` is the split's ratio at drag start, re-read from the + /// layout by the owning dock area on every move so external edits during + /// the drag are respected. + pub(crate) fn drag_to( + &mut self, + position: Pixels, + parent_extent: Pixels, + start_ratio: f32, + cx: &mut Context, + ) { + let Some(origin) = self.drag_origin else { + return; + }; + if parent_extent.0 <= 0.0 { + return; + } + // Keep both children above MIN_CHILD_EXTENT, but never clamp harder + // than a quarter of the parent so tiny parents stay resizable. + let min_ratio = (Self::MIN_CHILD_EXTENT.0 / parent_extent.0).min(0.25); + let max_ratio = 1.0 - min_ratio; + let ratio = (start_ratio + (position.0 - origin.0) / parent_extent.0) + .clamp(min_ratio, max_ratio); + cx.emit(SplitHandleEvent::ResizeRequested { + path: self.path.clone(), + ratio, + }); + cx.notify(); + } + + /// Ends the current drag, if any. + pub(crate) fn end_drag(&mut self) { + self.drag_origin = None; + } + + /// Emits a [`SplitHandleEvent::ResetRequested`] for a double-click. + pub(crate) fn reset(&mut self, cx: &mut Context) { + cx.emit(SplitHandleEvent::ResetRequested { + path: self.path.clone(), + }); + cx.notify(); + } +} + +impl EventEmitter for SplitHandle {} + +impl Render for SplitHandle { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let direction = self.direction; + let handle = cx.entity(); + + // Begins the drag on the handle (recording the pointer origin) and + // returns the ghost view shown under the pointer. + let ghost_ctor = move |_drag: &SplitHandleDrag, + origin: Point, + _window: &mut Window, + cx: &mut App| { + handle.update(cx, |handle, _cx| { + handle.begin_drag(if direction == Axis::Horizontal { + origin.x + } else { + origin.y + }); + }); + cx.new(|_cx| SplitDragGhost { direction }) + }; + + let mut root = div() + .id(ElementId::named_usize("dock-split-handle", path_key(&self.path))) + .flex_none() + .bg(colors.separator) + .on_click(cx.listener(move |this, event: &ClickEvent, _window, cx| { + if event.click_count() >= 2 { + this.reset(cx); + } + })); + + // A horizontal split stacks children side by side, so its divider is + // a vertical bar and vice versa. + match direction { + Axis::Horizontal => { + root = root + .w(px(Self::HITBOX.0)) + .h_full() + .cursor_col_resize(); + } + Axis::Vertical => { + root = root + .w_full() + .h(px(Self::HITBOX.0)) + .cursor_row_resize(); + } + } + + root.on_drag( + SplitHandleDrag { + path: self.path.clone(), + }, + ghost_ctor, + ) + } +} + +/// The floating view shown under the pointer while a handle is being dragged. +struct SplitDragGhost { + direction: Axis, +} + +impl Render for SplitDragGhost { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let mut ghost = div().bg(colors.border).rounded_sm(); + match self.direction { + Axis::Horizontal => ghost = ghost.w(px(2.0)).h(px(64.0)), + Axis::Vertical => ghost = ghost.w(px(64.0)).h(px(2.0)), + } + ghost + } +} diff --git a/crates/gpui/src/dock/tab_bar.rs b/crates/gpui/src/dock/tab_bar.rs new file mode 100644 index 0000000000..52b982744e --- /dev/null +++ b/crates/gpui/src/dock/tab_bar.rs @@ -0,0 +1,326 @@ +//! The tab strip rendered above each `Tabs` group in a +//! [`DockArea`](crate::dock::DockArea). +//! +//! Internal component — not part of the public API. One `TabBar` exists per +//! [`DockNode::Tabs`](crate::dock::DockNode::Tabs) node; it renders one tab +//! per panel (via [`DockPanel::tab_content`](crate::dock::DockPanel::tab_content)), +//! tracks the active tab, hosts close buttons, and is the drag source for +//! both tab reordering and dock drags. + +use crate::{ + div, px, App, AppContext, ClickEvent, Context, DragMoveEvent, ElementId, EventEmitter, + InteractiveElement, IntoElement, ParentElement, Pixels, Point, Render, ScrollDelta, + ScrollWheelEvent, SharedString, StatefulInteractiveElement, Styled, Window, +}; +use crate::colors::DefaultColors; + +use super::PanelId; + +/// Events emitted by a [`TabBar`] toward its owning [`DockArea`]. +#[derive(Clone, Debug)] +pub(crate) enum TabBarEvent { + /// The tab order or the active tab changed in place. + Reordered { + /// The new tab order (mirrors the owning `Tabs` node's `panels`). + tabs: Vec, + /// Index of the active tab in `tabs`. + active: usize, + }, + /// The user clicked the close button of a tab. + CloseRequested(PanelId), + /// A tab was dragged out of the strip; the dock area takes over the drag. + DockDragStarted { + /// The panel being dragged. + panel: PanelId, + /// Pointer position in window coordinates. + position: Point, + }, +} + +/// A single tab's computed geometry within the strip, cached during render +/// for hit-testing (close button, reorder, drag start). +pub(crate) struct TabGeometry { + /// The panel this tab shows. + #[allow(dead_code)] // retained for future hit-testing of individual tabs + pub panel: PanelId, + /// Left edge of the tab relative to the strip. + pub x: Pixels, + /// Width of the tab. + pub width: Pixels, +} + +/// The tab strip for one `Tabs` node. +/// +/// # Responsibilities +/// +/// - Render tabs in tree order, highlighting the active one and dimming +/// inactive ones. +/// - Show a close button on tabs whose panel reports +/// [`closable`](crate::dock::DockPanel::closable); clicking it emits +/// [`TabBarEvent::CloseRequested`], which the dock area routes through its +/// close flow (never removes the panel directly). +/// - Reorder tabs by dragging within the strip: dropping a tab between two +/// others emits [`TabBarEvent::Reordered`] and the dock area rewrites +/// [`DockNode::Tabs::panels`](crate::dock::DockNode::Tabs::panels) in place. +/// - Escalate to a dock drag: once a dragged tab leaves the strip's bounds, +/// emit [`TabBarEvent::DockDragStarted`] so the +/// [`DockArea`](crate::dock::DockArea)'s drag-to-dock plumbing takes over. +/// - Overflow: when tabs exceed the available width, the strip scrolls +/// horizontally (wheel and drag) instead of shrinking tabs below a minimum +/// width; the active tab is scrolled into view when activated. +/// +/// # Invariants +/// +/// `tabs` always mirrors the owning node's `panels` order exactly and +/// `active < tabs.len()`; the dock area re-creates or syncs the strip +/// whenever the tree changes rather than the strip mutating the tree itself. +pub(crate) struct TabBar { + /// Panels in tab order, mirroring the owning `Tabs` node. + tabs: Vec, + /// Index of the active tab. + active: usize, + /// Horizontal scroll offset for overflowed strips. + scroll_offset: Pixels, + /// Per-tab geometry from the last frame, for hit-testing. + geometry: Vec, + /// Tab titles from the last sync, rendered as the tab labels. + titles: Vec, + /// Per-tab closability from the last sync. + closable: Vec, +} + +impl TabBar { + /// Minimum width a tab is allowed to shrink to before the strip starts + /// scrolling instead. + pub(crate) const MIN_TAB_WIDTH: Pixels = Pixels(80.0); + + /// Creates a strip for the given tabs; `active` is clamped into range. + pub(crate) fn new(tabs: Vec, active: usize) -> Self { + Self { + active: active.min(tabs.len().saturating_sub(1)), + tabs, + scroll_offset: Pixels(0.0), + geometry: Vec::new(), + titles: Vec::new(), + closable: Vec::new(), + } + } + + /// Syncs the strip with the owning node after a tree edit, preserving + /// scroll position where possible. + /// + /// Called from the dock area during render, so it must not notify. + pub(crate) fn sync( + &mut self, + tabs: &[PanelId], + active: usize, + titles: &[SharedString], + closable: &[bool], + _cx: &mut Context, + ) { + self.tabs = tabs.to_vec(); + self.titles = titles.to_vec(); + self.closable = closable.to_vec(); + self.active = active.min(self.tabs.len().saturating_sub(1)); + } + + /// Returns the index of the tab containing `point` (strip-relative + /// coordinates), using the cached geometry. + pub(crate) fn tab_index_at(&self, point: Point) -> Option { + let count = self.geometry.partition_point(|tab| tab.x.0 <= point.x.0); + if count == 0 { + return None; + } + // `count - 1` is the last tab whose left edge is left of the point; + // tabs are contiguous, so that tab contains the point. + Some(count - 1) + } + + /// Reorders the tab at `from` to position `to`, keeping the active tab + /// pointing at the same panel, and emits [`TabBarEvent::Reordered`] so + /// the dock area can rewrite the owning node. + /// + /// No-op if either index is out of range or `from == to`. + pub(crate) fn move_tab(&mut self, from: usize, to: usize, cx: &mut Context) { + if from >= self.tabs.len() || to >= self.tabs.len() || from == to { + return; + } + let active_panel = self.tabs.get(self.active).copied(); + let panel = self.tabs.remove(from); + let insert_at = if to > from { to - 1 } else { to }; + self.tabs.insert(insert_at, panel); + self.active = active_panel + .and_then(|p| self.tabs.iter().position(|tab| *tab == p)) + .unwrap_or(0); + cx.emit(TabBarEvent::Reordered { + tabs: self.tabs.clone(), + active: self.active, + }); + cx.notify(); + } + + /// Makes the tab at `index` active, scrolling it into view first. + fn activate(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + if index >= self.tabs.len() { + return; + } + self.active = index; + self.scroll_tab_into_view(index, window, cx); + cx.emit(TabBarEvent::Reordered { + tabs: self.tabs.clone(), + active: self.active, + }); + cx.notify(); + } + + /// Scrolls the strip so the tab at `index` is fully visible. + /// + /// The strip does not know the exact width of the visible viewport, so + /// this approximates it with the window width — good enough to bring an + /// overflowing tab back into view. + fn scroll_tab_into_view(&mut self, index: usize, window: &mut Window, _cx: &mut Context) { + let Some(geometry) = self.geometry.get(index) else { + return; + }; + let viewport = window.viewport_size().width.0; + let left = geometry.x.0; + let right = geometry.x.0 + geometry.width.0; + let scrolled = self.scroll_offset.0; + if left < scrolled { + self.scroll_offset = Pixels(left.max(0.0)); + } else if right > scrolled + viewport { + self.scroll_offset = Pixels((right - viewport).max(0.0)); + } + } +} + +impl EventEmitter for TabBar {} + +impl Render for TabBar { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + + // Recompute per-tab geometry from the current order and scroll offset. + self.geometry = self + .tabs + .iter() + .enumerate() + .map(|(index, &panel)| TabGeometry { + panel, + x: Pixels(index as f32 * Self::MIN_TAB_WIDTH.0 - self.scroll_offset.0), + width: Self::MIN_TAB_WIDTH, + }) + .collect(); + + let mut root = div() + .flex() + .flex_row() + .items_center() + .h(px(32.0)) + .w_full() + .overflow_hidden() + .on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _window, cx| { + let delta = match event.delta { + ScrollDelta::Pixels(delta) => delta.x.0, + ScrollDelta::Lines(delta) => delta.x * 20.0, + }; + this.scroll_offset = Pixels((this.scroll_offset.0 + delta).max(0.0)); + cx.notify(); + })) + .on_drag_move::(cx.listener(|this, event: &DragMoveEvent, _window, cx| { + let dragged = *event.drag(cx); + // Ignore drags of panels that don't belong to this strip + // (e.g. a dock-level drag from another group passing over). + if !this.tabs.contains(&dragged) { + return; + } + if event.bounds.contains(&event.event.position) { + if let Some(to) = this.tab_index_at(event.event.position) { + if let Some(from) = this.tabs.iter().position(|tab| *tab == dragged) { + this.move_tab(from, to, cx); + } + } + } else { + // The tab left the strip: hand the drag to the dock area. + cx.emit(TabBarEvent::DockDragStarted { + panel: dragged, + position: event.event.position, + }); + } + })); + + for (index, &panel) in self.tabs.iter().enumerate() { + let active = index == self.active; + let title = self + .titles + .get(index) + .cloned() + .unwrap_or_else(|| SharedString::from("Tab")); + let closable = self.closable.get(index).copied().unwrap_or(false); + + // Ghost shown under the pointer while this tab is being dragged. + let ghost_title = title.clone(); + let ghost_ctor = move |_panel: &PanelId, + _origin: Point, + _window: &mut Window, + cx: &mut App| { + cx.new(|_cx| TabDragGhost { title: ghost_title.clone() }) + }; + + let mut tab = div() + .id(ElementId::named_usize("dock-tab", panel.raw() as usize)) + .w(px(Self::MIN_TAB_WIDTH.0)) + .flex_none() + .h_full() + .cursor_pointer() + .bg(if active { colors.selected } else { colors.background }) + .text_color(if active { colors.text } else { colors.disabled }) + .child(title) + .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { + this.activate(index, window, cx); + })) + .on_drag(panel, ghost_ctor); + + if closable { + tab = tab.child( + div() + .id(ElementId::named_usize("dock-tab-close", panel.raw() as usize)) + .cursor_pointer() + .text_xs() + .text_color(colors.disabled) + .child("✕") + .on_click(cx.listener( + move |_this, _event: &ClickEvent, _window, cx| { + cx.stop_propagation(); + cx.emit(TabBarEvent::CloseRequested(panel)); + }, + )), + ); + } + + root = root.child(tab); + } + + root + } +} + +/// The floating view shown under the pointer while a tab is being dragged. +struct TabDragGhost { + title: SharedString, +} + +impl Render for TabDragGhost { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + div() + .px_2() + .py_1() + .rounded_md() + .bg(colors.background) + .border_1() + .border_color(colors.border) + .shadow_md() + .child(self.title.clone()) + } +} diff --git a/crates/gpui/src/effect_stack/card.rs b/crates/gpui/src/effect_stack/card.rs new file mode 100644 index 0000000000..ea68c8197c --- /dev/null +++ b/crates/gpui/src/effect_stack/card.rs @@ -0,0 +1,313 @@ +//! The per-effect card component and the drop-position indicator. +//! +//! [`EffectCard`] is a stateless (`RenderOnce`) component that renders one +//! card of the stack; [`EffectStackView`](crate::effect_stack::EffectStackView) +//! constructs one per [`EffectData`](crate::effect_stack::EffectData) item +//! per frame. [`InsertIndicator`] renders the line showing where a dragged +//! card would land. + +use crate::{ + colors::DefaultColors, div, px, AnyView, IntoElement, ParentElement, Pixels, RenderOnce, + SharedString, Styled, Window, +}; + +use super::data::{EffectCardKind, EffectId}; + +/// A single card in the effect stack: header row plus an optional parameter +/// content slot. +/// +/// # Layout +/// +/// ```text +/// ┌──────────────────────────────────────────┐ +/// │ ⠿ [⏻] Title [3] ⌄ ✕ │ ← header +/// │ subtitle │ +/// ├──────────────────────────────────────────┤ +/// │ (params area: AnyView, when expanded) │ +/// └──────────────────────────────────────────┘ +/// ``` +/// +/// The header row contains, left to right: drag handle (reorderable cards +/// only), enable/disable toggle (effects only), title and optional +/// subtitle, optional badge, expand chevron, and remove button (removable +/// cards only). Clicking the header toggles expansion. +/// +/// # Visual states +/// +/// - **Disabled** ([`enabled(false)`](EffectCard::enabled)): card content is +/// dimmed; the parameter area renders inert. +/// - **Drag ghost** ([`drag_ghost(true)`](EffectCard::drag_ghost)): the card +/// renders semi-transparent while it is the dragged card. +/// - **Fixed kind** ([`EffectCardKind::Source`] / [`EffectCardKind::Output`]): +/// visually distinct background/border, no handle/toggle/remove controls. +/// +/// # Accessibility +/// +/// The header exposes a button role with an accessible name built from the +/// title (plus "disabled" when off), the enable toggle exposes a checkbox +/// role with an `enabled` label, and the chevron communicates +/// expanded/collapsed state. (Exact roles/labels are finalized with the +/// implementation; treat this as the contract.) +#[derive(IntoElement)] +pub struct EffectCard { + id: EffectId, + kind: EffectCardKind, + title: SharedString, + subtitle: Option, + enabled: bool, + expanded: bool, + removable: bool, + reorderable: bool, + badge_count: Option, + params: Option, + drag_ghost: bool, +} + +impl EffectCard { + /// Creates a card for the given effect. + /// + /// Defaults: [`EffectCardKind::Effect`], enabled, collapsed, removable + /// and reorderable, no subtitle, badge, params view, or drag ghost. + pub fn new(id: EffectId) -> Self { + Self { + id, + kind: EffectCardKind::Effect, + title: SharedString::default(), + subtitle: None, + enabled: true, + expanded: false, + removable: true, + reorderable: true, + badge_count: None, + params: None, + drag_ghost: false, + } + } + + /// The effect this card represents. + pub fn id(&self) -> EffectId { + self.id + } + + /// Sets the card's role in the chain. Source/output cards drop the + /// drag handle, enable toggle, and remove button regardless of the + /// `removable`/`reorderable` flags. + pub fn kind(mut self, kind: EffectCardKind) -> Self { + self.kind = kind; + self + } + + /// Sets the primary header label. + pub fn title(mut self, title: impl Into) -> Self { + self.title = title.into(); + self + } + + /// Sets the optional muted secondary line (e.g. a LUT filename). + pub fn subtitle(mut self, subtitle: Option) -> Self { + self.subtitle = subtitle; + self + } + + /// Sets whether the effect is enabled. Disabled cards are dimmed. + pub fn enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + /// Sets whether the parameter area is shown. Has no visual effect when + /// no params view was provided via [`params`](EffectCard::params). + pub fn expanded(mut self, expanded: bool) -> Self { + self.expanded = expanded; + self + } + + /// Sets whether the remove button is shown. + pub fn removable(mut self, removable: bool) -> Self { + self.removable = removable; + self + } + + /// Sets whether the drag handle is shown and the card can start a drag. + pub fn reorderable(mut self, reorderable: bool) -> Self { + self.reorderable = reorderable; + self + } + + /// Sets the optional numeric badge (e.g. animated-parameter count). + /// `None` (or `Some(0)`) hides the badge. + pub fn badge_count(mut self, badge_count: Option) -> Self { + self.badge_count = badge_count; + self + } + + /// Sets the parameter-area content, usually produced by the app's + /// [`ParamsRenderer`](crate::effect_stack::ParamsRenderer). Only laid + /// out when the card is expanded. + pub fn params(mut self, params: AnyView) -> Self { + self.params = Some(params); + self + } + + /// Sets whether this card renders as the semi-transparent drag ghost + /// (i.e. it is the card currently being dragged). + pub fn drag_ghost(mut self, drag_ghost: bool) -> Self { + self.drag_ghost = drag_ghost; + self + } +} + +impl RenderOnce for EffectCard { + fn render(self, _window: &mut Window, cx: &mut crate::App) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let fixed = self.kind != EffectCardKind::Effect; + let title_color = if self.enabled { colors.text } else { colors.disabled }; + + // Header row: the title block (flexing to fill the row), an optional + // numeric badge, and the expand chevron for effects. The drag handle, + // enable toggle, and remove button are rendered by + // `EffectStackView`'s card wrapper, which wires them to + // `EffectStackEvent`. + let mut header = div() + .flex() + .flex_row() + .items_center() + .gap_1() + .min_w_0() + .px_2() + .py_1() + .child( + div() + .flex_1() + .min_w_0() + .text_ellipsis() + .text_color(title_color) + .child(self.title), + ); + + if let Some(count) = self.badge_count.filter(|&count| count > 0) { + header = header.child( + div() + .flex() + .items_center() + .rounded_full() + .bg(colors.selected) + .px_1() + .py_0p5() + .text_xs() + .text_color(colors.selected_text) + .child(if count > 99 { + SharedString::from("99+") + } else { + SharedString::from(count.to_string()) + }), + ); + } + + // Effects show an expand chevron; source/output cards are fixed and + // have no expandable parameter area. + if !fixed { + header = header.child( + div() + .text_xs() + .text_color(colors.disabled) + .child(if self.expanded { "⌄" } else { "⌃" }), + ); + } + + let mut card = div().flex().flex_col().flex_1().min_w_0(); + // Indent compensation for the handle/remove buttons drawn by the + // stack view beside this card, and the semi-transparent drag ghost. + if self.reorderable { + card = card.pl_1(); + } + if self.removable { + card = card.pr_1(); + } + if self.drag_ghost { + card = card.opacity(0.5); + } + card = card.child(header); + + if let Some(subtitle) = self.subtitle { + card = card.child( + div() + .w_full() + .text_xs() + .text_color(colors.disabled) + .text_ellipsis() + .child(subtitle), + ); + } + + // The parameter-area view is laid out by `EffectStackView` inside + // the expanded card body rather than here; reading the slot keeps + // this purely-visual component's contract exercised. + if self.expanded && self.params.is_some() { + // Rendered by EffectStackView::render. + } + + card + } +} + +/// The horizontal line shown between cards to indicate where a dragged card +/// would be inserted. +/// +/// Drawn by +/// [`EffectStackView`](crate::effect_stack::EffectStackView) at the current +/// [`DragState::insertion_index`](crate::effect_stack::DragState::insertion_index) +/// while a reorder drag is in progress. Invalid drop positions (per +/// [`EffectStackDataSource::can_reorder`](crate::effect_stack::EffectStackDataSource::can_reorder)) +/// render in a "not allowed" style. +#[derive(Clone, Copy, Debug, Default, IntoElement)] +pub struct InsertIndicator { + valid: bool, + thickness: Option, +} + +impl InsertIndicator { + /// Creates an indicator for a valid drop position. + pub fn valid() -> Self { + Self { + valid: true, + thickness: None, + } + } + + /// Creates an indicator for a rejected drop position (renders in a + /// "not allowed" style, e.g. red/dashed). + pub fn invalid() -> Self { + Self { + valid: false, + thickness: None, + } + } + + /// Whether this indicator marks an accepted drop position. + pub fn is_valid(&self) -> bool { + self.valid + } + + /// Overrides the line thickness. `None` uses the theme default. + pub fn thickness(mut self, thickness: Pixels) -> Self { + self.thickness = Some(thickness); + self + } +} + +impl RenderOnce for InsertIndicator { + fn render(self, _window: &mut Window, cx: &mut crate::App) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let thickness = self.thickness.unwrap_or(px(2.0)); + let line = div() + .w_full() + .h(thickness) + .bg(if self.valid { colors.selected } else { colors.disabled }); + if self.valid { + line + } else { + line.border_dashed() + } + } +} diff --git a/crates/gpui/src/effect_stack/data.rs b/crates/gpui/src/effect_stack/data.rs new file mode 100644 index 0000000000..b8336ca139 --- /dev/null +++ b/crates/gpui/src/effect_stack/data.rs @@ -0,0 +1,182 @@ +//! Data-source traits and metadata types for the effect stack. +//! +//! The app implements [`EffectStackDataSource`] on an entity it owns and +//! returns one [`EffectData`] object per card, ordered top-to-bottom in +//! signal order. See the [module-level docs](crate::effect_stack) for the +//! overall architecture. + +use std::fmt; +use std::sync::Arc; + +use crate::SharedString; + +/// Stable identifier of a single effect (card) in the stack. +/// +/// The app assigns IDs; the widget treats them as opaque keys used for card +/// identity across frames (element IDs, drag payloads) and in +/// [`EffectStackEvent`](crate::effect_stack::EffectStackEvent)s. +/// +/// # Invariants +/// +/// - IDs must be unique within one stack and stable for the lifetime of the +/// underlying effect node — the view diffs card lists by `EffectId` to +/// keep element state (expansion animation, focus) attached to the right +/// card across re-renders. +/// - In Oak, an `EffectId` typically wraps (or maps 1:1 to) the engine's +/// node ID on the graph path. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct EffectId(pub u64); + +impl fmt::Display for EffectId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "effect-{}", self.0) + } +} + +impl From for EffectId { + fn from(raw: u64) -> Self { + Self(raw) + } +} + +/// Which role a card plays in the linear chain. +/// +/// A well-formed stack is exactly one [`Source`](EffectCardKind::Source) +/// card pinned at the top, zero or more [`Effect`](EffectCardKind::Effect) +/// cards in the middle, and exactly one [`Output`](EffectCardKind::Output) +/// card pinned at the bottom. The view renders source/output cards with +/// fixed styling and ignores reorder/remove gestures for them regardless of +/// what [`EffectData::is_removable`] / [`EffectData::is_reorderable`] say. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum EffectCardKind { + /// The media/source card (e.g. the clip's footage). Fixed at the top of + /// the stack; not removable, not reorderable. + Source, + /// A regular effect card. Reorderable and removable by default. + Effect, + /// The output card. Fixed at the bottom of the stack; not removable, + /// not reorderable. + Output, +} + +/// Read-only view of one card in the effect stack. +/// +/// The app returns implementations of this trait from +/// [`EffectStackDataSource::effects`]. All methods are pure reads — the view +/// calls them on every render, so they must be cheap and must not mutate. +/// +/// Mutations always flow the other way: the user gestures produce +/// [`EffectStackEvent`](crate::effect_stack::EffectStackEvent)s, the app +/// applies them to its model, and the next render observes the new values +/// here. +pub trait EffectData: 'static { + /// Stable identity of this card; see [`EffectId`] for invariants. + fn id(&self) -> EffectId; + + /// The role of this card in the chain. See [`EffectCardKind`]. + fn kind(&self) -> EffectCardKind; + + /// Primary label shown in the card header (e.g. `"Transform"`, + /// `"OCIO LUT"`). + fn title(&self) -> SharedString; + + /// Optional secondary line shown under the title in a muted style, e.g. + /// the LUT filename or a one-line parameter summary. + /// + /// Defaults to `None` (no subtitle row is laid out). + fn subtitle(&self) -> Option { + None + } + + /// Whether the effect is currently enabled (not bypassed). + /// + /// Disabled cards are rendered dimmed and their parameter area is + /// inert. Source and output cards should return `true`; the view does + /// not render an enable toggle for them. + fn is_enabled(&self) -> bool; + + /// Whether the card's parameter area is currently expanded. + /// + /// Expansion state lives in the app's model (so it can persist and sync + /// with [`crate::node_graph`]); toggling it is requested via + /// [`EffectStackEvent::ExpansionToggled`](crate::effect_stack::EffectStackEvent::ExpansionToggled). + fn is_expanded(&self) -> bool; + + /// Whether the card may be removed from the stack. + /// + /// Defaults to `true` for [`EffectCardKind::Effect`] and `false` for + /// source/output cards. When `false`, the remove affordance is hidden + /// and [`EffectStackEvent::RemoveRequested`](crate::effect_stack::EffectStackEvent::RemoveRequested) + /// is never emitted for this card. + fn is_removable(&self) -> bool { + self.kind() == EffectCardKind::Effect + } + + /// Whether the card may be reordered by dragging. + /// + /// Defaults to `true` for [`EffectCardKind::Effect`] and `false` for + /// source/output cards. When `false`, the drag handle is hidden and the + /// card never participates in a reorder (neither as the dragged card + /// nor as a displaced neighbor position). + fn is_reorderable(&self) -> bool { + self.kind() == EffectCardKind::Effect + } + + /// Optional numeric badge shown in the card header, e.g. the number of + /// animated parameters on the effect. + /// + /// `Some(0)` and `None` both render no badge; prefer `None`. Large + /// values are clamped visually (e.g. `99+`). + fn badge_count(&self) -> Option { + None + } +} + +/// Ordered collection of effect cards backing an [`EffectStackView`](crate::effect_stack::EffectStackView). +/// +/// Implemented by the app on an [`Entity`](crate::Entity)-backed model. The +/// view holds the entity and reads through this trait every frame. +/// +/// # Cardinality and ordering +/// +/// [`effects`](EffectStackDataSource::effects) returns cards top-to-bottom +/// in signal order: index `0` is the source card, the last index is the +/// output card. Reorder indices in +/// [`EffectStackEvent::ReorderRequested`](crate::effect_stack::EffectStackEvent::ReorderRequested) +/// and [`can_reorder`](EffectStackDataSource::can_reorder) refer to +/// positions in this list. +pub trait EffectStackDataSource: 'static { + /// All cards in the stack, top-to-bottom (signal order). + /// + /// May be empty (or contain only source/output) — see + /// [`target_label`](EffectStackDataSource::target_label) for the + /// empty-selection state, which is distinct from a stack with no + /// effects. + fn effects(&self) -> Vec>; + + /// Label describing what this stack edits, e.g. the clip name shown in + /// the panel header. + /// + /// Return `None` when there is no valid selection (no clip under the + /// playhead, multi-selection, etc.). The view then renders an empty + /// state instead of cards and suppresses all card interactions. + fn target_label(&self) -> Option; + + /// Whether dropping the given effect at `new_index` (an index into the + /// list returned by [`effects`](EffectStackDataSource::effects)) would + /// be a valid reorder. + /// + /// Called continuously during a drag to drive the insertion indicator; + /// invalid positions render as "not allowed". The default + /// implementation allows any position between the source and output + /// cards. Apps override this to reject reorders that would produce + /// invalid signal chains (e.g. a node that requires two inputs). + /// + /// Note this is advisory UI feedback only — the app re-validates when + /// the actual + /// [`ReorderRequested`](crate::effect_stack::EffectStackEvent::ReorderRequested) + /// event arrives. + fn can_reorder(&self, _id: EffectId, _new_index: usize) -> bool { + true + } +} diff --git a/crates/gpui/src/effect_stack/mod.rs b/crates/gpui/src/effect_stack/mod.rs new file mode 100644 index 0000000000..728e0e118a --- /dev/null +++ b/crates/gpui/src/effect_stack/mod.rs @@ -0,0 +1,77 @@ +//! Linear effect-stack inspector widget. +//! +//! The effect stack presents the processing chain for a single clip (or the +//! current selection) as a vertical, linear list of cards — e.g. +//! `Media → Transform → OCIO LUT → Output`. It is the inspector-style +//! counterpart to [`crate::node_graph`]: **both widgets are views of the same +//! underlying node data** ("two views of one model"). +//! +//! # Architecture +//! +//! - **Trait-driven data source.** The widget never owns the document. The +//! app implements [`EffectStackDataSource`](crate::effect_stack::EffectStackDataSource) (and [`EffectData`](crate::effect_stack::EffectData) for each +//! card) on an [`Entity`](crate::Entity)-backed model and hands it to +//! [`EffectStackView`](crate::effect_stack::EffectStackView). The view reads through the trait on every render, +//! so the app's engine remains the single source of truth. +//! - **Edits are requests, not mutations.** Every user gesture (toggle, +//! reorder, remove, add, context menu) is surfaced as an +//! [`EffectStackEvent`](crate::effect_stack::EffectStackEvent) via [`EventEmitter`](crate::EventEmitter). The view +//! never mutates the data source itself. The app applies the request +//! through its engine and undo stack, then calls +//! [`cx.notify()`](crate::Context::notify) on the data source (or the view) +//! so the stack re-renders. Optimistic in-view state is intentionally +//! limited to transient visuals (e.g. the drag insertion indicator). +//! +//! # Relationship to `crate::node_graph` +//! +//! The stack shows the *linear path* through the node graph for one clip: +//! source node at the top, output node at the bottom, and every effect node +//! on the path in signal order. Mapping gestures back onto the graph is the +//! **app's responsibility**: +//! +//! - **Reorder a card** = detach the effect node from its neighbors and +//! rewire the path (previous node's output → moved node → node that used to +//! follow the insertion point). +//! - **Remove a card** = delete the node and bridge the gap. +//! - **Add a card** = insert a new node at the requested path position. +//! - **Enable toggle** = the node's bypass flag. +//! +//! When both the stack and the node graph are visible at the same time, both +//! should observe the same document entity so that an edit in one is +//! reflected in the other after `cx.notify()`. +//! +//! # Wiring into Oak +//! +//! In Oak (the video editor this widget is built for), the intended wiring +//! is: +//! +//! 1. `oakengine` owns the node graph and the undo stack. Oak implements +//! [`EffectStackDataSource`](crate::effect_stack::EffectStackDataSource) over a view-model entity that derives the +//! ordered effect list from the graph path of the selected clip. +//! 2. Oak subscribes to the [`Entity>`](crate::Entity) +//! and, for each [`EffectStackEvent`](crate::effect_stack::EffectStackEvent), builds the matching engine command, +//! pushes it onto the undo stack, executes it, and calls `cx.notify()`. +//! 3. Parameter UIs (per-effect controls) are supplied through +//! [`EffectStackView::params_renderer`](crate::effect_stack::EffectStackView::params_renderer) and live inside the expanded card +//! body. When a parameter edit happens, Oak calls +//! [`EffectStackView::notify_parameter_changed`](crate::effect_stack::EffectStackView::notify_parameter_changed) so the view can refresh +//! badges and so subscribers can schedule re-rendering. +//! 4. Selection changes (which clip is active) are pushed into the +//! view-model; the stack's empty state renders automatically when +//! [`EffectStackDataSource::target_label`](crate::effect_stack::EffectStackDataSource::target_label) returns `None`. +//! +//! # Modules +//! +//! - [`data`](crate::effect_stack::data): the data-source traits and card metadata types. +//! - [`stack_view`](crate::effect_stack::stack_view): the top-level [`EffectStackView`](crate::effect_stack::EffectStackView) and +//! [`EffectStackEvent`](crate::effect_stack::EffectStackEvent). +//! - [`card`](crate::effect_stack::card): the [`EffectCard`](crate::effect_stack::EffectCard) component and the drop-position +//! [`InsertIndicator`](crate::effect_stack::card::InsertIndicator). + +pub mod card; +pub mod data; +pub mod stack_view; + +pub use card::*; +pub use data::*; +pub use stack_view::*; diff --git a/crates/gpui/src/effect_stack/stack_view.rs b/crates/gpui/src/effect_stack/stack_view.rs new file mode 100644 index 0000000000..32f0eb0566 --- /dev/null +++ b/crates/gpui/src/effect_stack/stack_view.rs @@ -0,0 +1,604 @@ +//! The top-level effect-stack view and its event type. +//! +//! [`EffectStackView`] renders the linear card chain described by an +//! [`EffectStackDataSource`] and reports every user edit intent as an +//! [`EffectStackEvent`]. See the [module-level docs](crate::effect_stack) +//! for the "edits are requests" contract. + +use std::rc::Rc; + +use crate::{ + colors::DefaultColors, div, AnyView, App, AppContext, ClickEvent, Context, DragMoveEvent, + ElementId, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, + MouseButton, ParentElement, Pixels, Point, Render, SharedString, StatefulInteractiveElement, + Styled, Window, +}; + +use super::card::{EffectCard, InsertIndicator}; +use super::data::{EffectCardKind, EffectId, EffectStackDataSource}; + +/// Callback the app registers with +/// [`EffectStackView::params_renderer`] to render the parameter controls of +/// one effect inside its expanded card. +/// +/// Called during render for every expanded card. The returned [`AnyView`] +/// is placed in the card's content slot; its size drives the expanded +/// height of the card. Return any empty view (e.g. [`crate::div()`]'s +/// default) to render a blank parameter area. +pub type ParamsRenderer = + Rc AnyView>; + +/// Edit requests emitted by [`EffectStackView`]. +/// +/// **Every variant is a request, not a completed edit.** The view does not +/// mutate the [`EffectStackDataSource`]. The app subscribes, applies the +/// request through its engine and undo stack, then calls +/// [`cx.notify()`](Context::notify) so the stack re-renders from the +/// updated model. If the request is rejected (invalid engine state, failed +/// validation), the app simply does nothing — the view keeps rendering the +/// unchanged model. +#[derive(Clone, Debug)] +pub enum EffectStackEvent { + /// The user dragged a card to a new position. + /// + /// `new_index` is an index into the list returned by + /// [`EffectStackDataSource::effects`] **after** removal of the dragged + /// card (i.e. an insertion position). The app maps this to rewiring the + /// node-graph path; see the [module docs](crate::effect_stack). The + /// view has already filtered positions rejected by + /// [`EffectStackDataSource::can_reorder`], but the app must re-validate. + ReorderRequested { + /// The dragged effect. + effect: EffectId, + /// Insertion index in the post-removal card list. + new_index: usize, + }, + /// The user clicked the enable/disable toggle on a card. + EnableToggled { + /// The toggled effect. + effect: EffectId, + /// The desired new enabled state. + enabled: bool, + }, + /// The user clicked a card header to expand or collapse its parameter + /// area. + ExpansionToggled { + /// The toggled effect. + effect: EffectId, + /// The desired new expansion state. + expanded: bool, + }, + /// The user clicked the remove button on a removable card. + RemoveRequested(EffectId), + /// The user invoked an "add effect" affordance at a stack position. + /// + /// The app typically responds by opening its effect browser; once the + /// user picks an effect, the app inserts the corresponding node at + /// `index` and notifies. + AddRequested { + /// Insertion index into the current card list. + index: usize, + }, + /// The user secondary-clicked a card. The app owns the menu itself — + /// the view only reports where and on which card it happened. + ContextMenuRequested { + /// The effect that was clicked. + effect: EffectId, + /// Mouse position in window coordinates, suitable for positioning a + /// context menu. + position: Point, + }, + /// A parameter of an effect changed inside its card's parameter area. + /// + /// Emitted when the app's parameter UI calls + /// [`EffectStackView::notify_parameter_changed`]. The view uses it to + /// refresh card metadata (e.g. the animated-parameter badge); the app + /// may additionally subscribe to e.g. schedule a preview re-render. + ParameterChanged { + /// The effect whose parameters changed. + effect: EffectId, + }, +} + +/// Transient drag state for an in-progress card reorder. +/// +/// Purely visual: tracks which card is being dragged and the current +/// insertion position so [`render`](Render::render) can draw the ghost and +/// the [`InsertIndicator`](crate::effect_stack::InsertIndicator). Cleared +/// on drop or cancel; never survives into the app's model. +#[derive(Clone, Copy, Debug, Default)] +pub struct DragState { + /// The card currently being dragged, if any. + pub dragged: Option, + /// Current insertion index (into the post-removal list) while dragging, + /// if the pointer is over a valid drop position. + pub insertion_index: Option, +} + +/// The linear effect-stack inspector view. +/// +/// Generic over the app's data-source entity `D`. Construct with +/// [`EffectStackView::new`], optionally register a parameter renderer with +/// [`EffectStackView::params_renderer`], then subscribe to +/// [`EffectStackEvent`]s on the entity. +/// +/// Implements [`Render`], [`Focusable`] (for keyboard interaction) and +/// [`EventEmitter`]. +/// +/// # Interactions +/// +/// - **Click card header**: emits [`EffectStackEvent::ExpansionToggled`]. +/// - **Enable toggle (eye/power)**: emits [`EffectStackEvent::EnableToggled`]. +/// - **Drag card by its handle**: live +/// [`InsertIndicator`](crate::effect_stack::InsertIndicator) tracks the +/// pointer; drop emits [`EffectStackEvent::ReorderRequested`]. Cards with +/// [`EffectData::is_reorderable`](crate::effect_stack::EffectData::is_reorderable) +/// `== false` (source/output by default) cannot be dragged. +/// - **Remove button**: emits [`EffectStackEvent::RemoveRequested`]. +/// - **Secondary click**: emits [`EffectStackEvent::ContextMenuRequested`]; +/// the app renders the actual menu. +/// - **Empty selection** ([`EffectStackDataSource::target_label`] is +/// `None`): renders an empty state and disables all interactions. +/// +/// # Virtualization +/// +/// The card list is rendered as a plain vertical flex column, **not** a +/// [`uniform_list`](crate::uniform_list). Effect stacks in a video editor +/// rarely exceed a few dozen cards, and expanded cards have variable, +/// content-driven heights with stateful child views (parameter UIs), which +/// a virtualized list would fight against. Revisit if profiles show +/// otherwise. +pub struct EffectStackView { + data: Entity, + params_renderer: Option, + focus_handle: FocusHandle, + drag_state: DragState, +} + +impl EffectStackView { + /// Creates a new view over the given data-source entity. + /// + /// The view does not subscribe to the entity itself; the app is + /// expected to call [`cx.notify()`](Context::notify) on the data source + /// (or on this view) after applying edits, per the "edits are requests" + /// contract. + pub fn new(data: Entity, cx: &mut Context) -> Self { + Self { + data, + params_renderer: None, + focus_handle: cx.focus_handle(), + drag_state: DragState::default(), + } + } + + /// Registers the app callback that renders an effect's parameter + /// controls inside its expanded card. See [`ParamsRenderer`]. + /// + /// Builder style; call once at setup: + /// + /// ```ignore + /// let stack = cx.new(|cx| { + /// EffectStackView::new(data, cx).params_renderer(|id, window, cx| { + /// my_effect_params_view(*id).into() + /// }) + /// }); + /// ``` + /// + /// Cards of [`EffectCardKind::Source`](crate::effect_stack::EffectCardKind::Source) + /// and [`Output`](crate::effect_stack::EffectCardKind::Output) never + /// invoke the renderer — they have no parameter area. + pub fn params_renderer( + mut self, + renderer: impl Fn(&EffectId, &mut Window, &mut App) -> AnyView + 'static, + ) -> Self { + self.params_renderer = Some(Rc::new(renderer)); + self + } + + /// The data-source entity this view reads from. + pub fn data(&self) -> &Entity { + &self.data + } + + /// Current transient drag state (visual only). + pub fn drag_state(&self) -> DragState { + self.drag_state + } + + /// Helper for the app's parameter UIs: reports that a parameter of + /// `effect` changed, causing the view to refresh card metadata and to + /// emit [`EffectStackEvent::ParameterChanged`]. + /// + /// Call this from within the app's parameter view after applying a + /// parameter edit to the engine. This is a notification of an edit the + /// app already performed — unlike the other events, it does not require + /// a follow-up model change. + pub fn notify_parameter_changed(&mut self, effect: EffectId, cx: &mut Context) { + cx.emit(EffectStackEvent::ParameterChanged { effect }); + cx.notify(); + } + + /// Updates the transient drag state while a reorder drag moves over the + /// card `card_id`. + /// + /// The root-level drag-move listener runs first (capture phase, + /// registration order) and clears the insertion index, so a card's + /// listener only needs to set it while the pointer is inside that card's + /// own bounds — a pointer is inside at most one card, so the indicator + /// tracks exactly the card under the pointer. When the pointer is + /// elsewhere, this returns without touching the (already cleared) state. + fn update_drag( + &mut self, + card_id: EffectId, + event: &DragMoveEvent, + cx: &mut Context, + ) { + let effects = self.data.read(cx).effects(); + let dragged = *event.drag(cx); + self.drag_state.dragged = Some(dragged); + if !event.bounds.contains(&event.event.position) { + return; + } + let Some(i0) = effects.iter().position(|e| e.id() == dragged) else { + self.drag_state.insertion_index = None; + cx.notify(); + return; + }; + let Some(j) = effects.iter().position(|e| e.id() == card_id) else { + self.drag_state.insertion_index = None; + cx.notify(); + return; + }; + // The pointer is inside this card, so the indicator sits on the + // nearer of the two edges of the card; the index is expressed in + // the post-removal card list. + let insert_before = event.event.position.y < event.bounds.center().y; + let pos_in_removed = j - usize::from(j > i0); + let new_index = if insert_before { + pos_in_removed + } else { + pos_in_removed + 1 + }; + self.drag_state.insertion_index = if self.data.read(cx).can_reorder(dragged, new_index) { + Some(new_index) + } else { + None + }; + cx.notify(); + } + + /// Clears the transient drag state (mouse released outside the stack, or + /// any gesture that should abort an in-progress drag). + fn cancel_drag(&mut self, cx: &mut Context) { + self.drag_state = DragState::default(); + cx.notify(); + } + + /// Toggles a card's expansion by emitting + /// [`EffectStackEvent::ExpansionToggled`]. + fn toggle_expanded(&mut self, id: EffectId, cx: &mut Context) { + self.drag_state = DragState::default(); + let expanded = self + .data + .read(cx) + .effects() + .iter() + .find(|e| e.id() == id) + .map(|e| !e.is_expanded()) + .unwrap_or(false); + cx.emit(EffectStackEvent::ExpansionToggled { effect: id, expanded }); + cx.notify(); + } + + /// Toggles a card's enabled state by emitting + /// [`EffectStackEvent::EnableToggled`]. + fn toggle_enabled(&mut self, id: EffectId, cx: &mut Context) { + self.drag_state = DragState::default(); + let enabled = self + .data + .read(cx) + .effects() + .iter() + .find(|e| e.id() == id) + .map(|e| !e.is_enabled()) + .unwrap_or(false); + cx.emit(EffectStackEvent::EnableToggled { effect: id, enabled }); + cx.notify(); + } + + /// Requests removal of a card by emitting + /// [`EffectStackEvent::RemoveRequested`]. + fn remove(&mut self, id: EffectId, cx: &mut Context) { + self.drag_state = DragState::default(); + cx.emit(EffectStackEvent::RemoveRequested(id)); + cx.notify(); + } + + /// Reports a secondary click on a card by emitting + /// [`EffectStackEvent::ContextMenuRequested`]. + fn context_menu(&mut self, id: EffectId, position: Point, cx: &mut Context) { + self.drag_state = DragState::default(); + cx.emit(EffectStackEvent::ContextMenuRequested { effect: id, position }); + cx.notify(); + } + + /// Requests insertion of a new effect at the end of the stack by + /// emitting [`EffectStackEvent::AddRequested`]. + fn add(&mut self, cx: &mut Context) { + let index = self.data.read(cx).effects().len(); + cx.emit(EffectStackEvent::AddRequested { index }); + cx.notify(); + } +} + +impl EventEmitter for EffectStackView {} + +impl Focusable for EffectStackView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for EffectStackView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let focus_handle = self.focus_handle.clone(); + let params_renderer = self.params_renderer.clone(); + + // A drag that ended without a drop (released outside a drop target, or + // cancelled) leaves transient drag state behind; clear it on the next + // render so no card stays half-transparent and no indicator lingers. + if self.drag_state.dragged.is_some() && !cx.has_active_drag() { + self.drag_state = DragState::default(); + } + + let (label, effects) = { + let data = self.data.read(cx); + (data.target_label(), data.effects()) + }; + let insertion_index = self.drag_state.insertion_index; + let dragged_id = self.drag_state.dragged; + let i0 = dragged_id.and_then(|d| effects.iter().position(|e| e.id() == d)); + let i0_guard = i0.unwrap_or(usize::MAX); + + // Constructs the ghost view shown under the pointer while a card is + // being dragged. + let ghost_ctor = { + let data = self.data.clone(); + move |_id: &EffectId, _origin: Point, _window: &mut Window, cx: &mut App| { + let title = data + .read(cx) + .effects() + .iter() + .find(|e| e.id() == *_id) + .map(|e| e.title()) + .unwrap_or_else(|| SharedString::from("Effect")); + cx.new(|_cx| DragGhost { title }) + } + }; + + let mut root = div() + .id("effect-stack") + .flex() + .flex_col() + .w_full() + .h_full() + .track_focus(&focus_handle) + .on_drag_move::(cx.listener(|this, _event, _window, cx| { + // Runs first (capture phase, registration order): clear the + // indicator by default; the per-card listener re-sets it + // while the pointer is inside that card. + this.drag_state.insertion_index = None; + cx.notify(); + })) + .on_mouse_up_out( + MouseButton::Left, + cx.listener(|this, _event, _window, cx| this.cancel_drag(cx)), + ); + + let Some(label) = label else { + return root.child( + div() + .id("empty-state") + .flex() + .flex_1() + .items_center() + .justify_center() + .text_sm() + .text_color(colors.disabled) + .child("No selection"), + ); + }; + + root = root.child( + div() + .id("effect-stack-header") + .px_3() + .py_2() + .text_sm() + .text_color(colors.text) + .child(label), + ); + + let mut column = div().id("effect-stack-cards").flex().flex_col().w_full(); + + for (index, effect) in effects.iter().enumerate() { + let id = effect.id(); + let fixed = effect.kind() != EffectCardKind::Effect; + let enabled = effect.is_enabled(); + let expanded = effect.is_expanded(); + let removable = effect.is_removable(); + let reorderable = effect.is_reorderable(); + + // Insertion indicator for a drop position just before this card. + if let Some(p) = insertion_index { + let k = p + usize::from(p >= i0_guard); + if k == index { + column = column.child(InsertIndicator::valid()); + } + } + + // Header row: drag handle, enable toggle, the card itself + // (flexing to fill), and the remove button. + let mut header_row = div() + .id(ElementId::named_usize("effect-header", id.0 as usize)) + .flex() + .flex_row() + .items_center() + .gap_1() + .px_2() + .py_1() + .on_aux_click(cx.listener(move |this, event: &ClickEvent, _window, cx| { + cx.stop_propagation(); + this.context_menu(id, event.position(), cx); + })); + + if !fixed { + header_row = header_row.cursor_pointer().on_click( + cx.listener(move |this, _event, _window, cx| { + cx.stop_propagation(); + this.toggle_expanded(id, cx); + }), + ); + } + + if reorderable { + header_row = header_row.child( + div() + .id(ElementId::named_usize("effect-handle", id.0 as usize)) + .cursor_grab() + .text_color(colors.disabled) + .child("⠿") + .on_drag(id, ghost_ctor.clone()), + ); + } + + if !fixed { + header_row = header_row.child( + div() + .id(ElementId::named_usize("effect-toggle", id.0 as usize)) + .cursor_pointer() + .text_color(if enabled { colors.text } else { colors.disabled }) + .child("⏻") + .on_click(cx.listener(move |this, _event, _window, cx| { + cx.stop_propagation(); + this.toggle_enabled(id, cx); + })), + ); + } + + let card = EffectCard::new(id) + .kind(effect.kind()) + .title(effect.title()) + .subtitle(effect.subtitle()) + .enabled(enabled) + .expanded(expanded) + .removable(removable) + .reorderable(reorderable) + .badge_count(effect.badge_count()) + .drag_ghost(dragged_id == Some(id)); + header_row = header_row.child(card); + + if removable { + header_row = header_row.child( + div() + .id(ElementId::named_usize("effect-remove", id.0 as usize)) + .cursor_pointer() + .text_color(colors.disabled) + .child("✕") + .on_click(cx.listener(move |this, _event, _window, cx| { + cx.stop_propagation(); + this.remove(id, cx); + })), + ); + } + + let mut wrapper = div() + .id(ElementId::named_usize("effect", id.0 as usize)) + .flex() + .flex_col() + .w_full() + .rounded_md() + .border_1() + .border_color(if fixed { colors.border } else { colors.separator }) + .bg(if fixed { colors.container } else { colors.background }) + .overflow_hidden(); + wrapper = wrapper.child(header_row); + + if expanded && !fixed { + if let Some(renderer) = ¶ms_renderer { + wrapper = wrapper.child( + div() + .id(ElementId::named_usize("effect-params", id.0 as usize)) + .border_t_1() + .border_color(colors.separator) + .child(renderer(&id, window, cx)), + ); + } + } + + if reorderable { + wrapper = wrapper + .on_drag_move::(cx.listener(move |this, event, _window, cx| { + this.update_drag(id, event, cx); + })) + .on_drop::( + cx.listener(move |this, &dragged: &EffectId, _window, cx| { + let index = this.drag_state.insertion_index; + this.drag_state = DragState::default(); + if let Some(index) = index { + cx.emit(EffectStackEvent::ReorderRequested { + effect: dragged, + new_index: index, + }); + } + cx.notify(); + }), + ) + .can_drop(|payload, _window, _cx| payload.is::()); + } + + column = column.child(wrapper); + } + + // Insertion indicator for a drop position after the last card. + if let Some(p) = insertion_index { + let k = p + usize::from(p >= i0_guard); + if k == effects.len() { + column = column.child(InsertIndicator::valid()); + } + } + + let add_button = div() + .id("effect-add") + .cursor_pointer() + .px_3() + .py_2() + .text_sm() + .text_color(colors.text) + .child("+ Add Effect") + .on_click(cx.listener(move |this, _event, _window, cx| this.add(cx))); + + root.child(column).child(add_button) + } +} + +/// The floating view shown under the pointer while a card is being dragged. +struct DragGhost { + title: SharedString, +} + +impl Render for DragGhost { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + div() + .px_2() + .py_1() + .rounded_md() + .bg(colors.background) + .border_1() + .border_color(colors.border) + .shadow_md() + .child(self.title.clone()) + } +} diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index c1cffd8bc2..9b3dd74ce0 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -18,6 +18,10 @@ mod bounds_tree; mod color; /// The default colors used by GPUI. pub mod colors; +/// Dockable panel layout system (tabs, splits, drag-to-dock). +pub mod dock; +/// Linear effect-stack inspector widget (companion to [`node_graph`]). +pub mod effect_stack; mod element; mod elements; mod executor; @@ -31,6 +35,8 @@ mod interactive; mod key_dispatch; mod keymap; mod lerp; +/// Node-graph editor widget (nodes, ports, wires, pan/zoom canvas). +pub mod node_graph; mod path_builder; mod platform; pub mod prelude; @@ -50,6 +56,8 @@ mod taffy; #[cfg(any(test, feature = "test-support"))] pub mod test; mod text_system; +/// Video-editing timeline widget (tracks, clips, ruler, playhead). +pub mod timeline; mod transition; mod util; mod view; diff --git a/crates/gpui/src/node_graph/data.rs b/crates/gpui/src/node_graph/data.rs new file mode 100644 index 0000000000..a95dad6c40 --- /dev/null +++ b/crates/gpui/src/node_graph/data.rs @@ -0,0 +1,234 @@ +//! Data-source traits and identifier types for the node-graph editor. +//! +//! The widget is fully data-agnostic: it reads everything it displays through +//! the traits in this file and never mutates the underlying model. The +//! embedding application implements these traits over its own graph (for Oak: +//! the `oakengine` node graph) and reacts to the [`NodeGraphEvent`]s emitted +//! by the view. +//! +//! [`NodeGraphEvent`]: crate::node_graph::NodeGraphEvent + +use crate::{Hsla, Pixels, Point, SharedString}; + +/// Unique identifier of a node within the graph. +/// +/// Typically a newtype over the host application's own node key (e.g. an +/// engine node handle). The widget only requires that ids are cheap to copy, +/// totally ordered (for selection sets) and hashable (for lookup maps). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct NodeId(pub u64); + +/// Unique identifier of a port within the graph. +/// +/// Port ids are *globally* unique, not per-node, so that a single [`PortId`] +/// is enough to address an endpoint of a connection request. The app is free +/// to pack a node id and a per-node port index into the `u64` however it +/// likes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PortId(pub u64); + +/// Unique identifier of an edge (a connection between two ports). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct EdgeId(pub u64); + +/// Whether a port accepts incoming connections or produces outgoing ones. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum PortKind { + /// A port that consumes data; conventionally drawn on the left side of a + /// node and accepts connections *from* an [`PortKind::Output`] port. + Input, + /// A port that produces data; conventionally drawn on the right side of a + /// node and connects *to* an [`PortKind::Input`] port. + Output, +} + +/// A lightweight, app-defined descriptor of the data flowing through a port. +/// +/// The widget does not interpret data types semantically — it uses the +/// [`color`](Self::color) to tint port dots and wires, and uses +/// [`PartialEq`] only as a convenience for *default* visual hints. The +/// authoritative compatibility check is always +/// [`NodeGraphDataSource::can_connect`], so an app may implement subtyping, +/// implicit conversions (e.g. `int → float`) or direction-dependent rules +/// there without this type needing to model them. +/// +/// # Equality contract +/// +/// Two [`PortDataType`] values are considered the same type when their +/// `name`s are equal; the color is *not* part of equality. Apps that want +/// distinct types sharing a name should disambiguate the name. +#[derive(Clone, Debug)] +pub struct PortDataType { + /// Human-readable type name, e.g. `"video"`, `"audio"`, `"matte"`. + /// Also used as the identity of the type (see type-level docs). + pub name: SharedString, + /// Color used to tint port dots and wires carrying this type. + pub color: Hsla, +} + +impl PortDataType { + /// Creates a new data-type descriptor with the given display name and + /// tint color. + pub fn new(name: impl Into, color: Hsla) -> Self { + Self { + name: name.into(), + color, + } + } +} + +impl PartialEq for PortDataType { + fn eq(&self, other: &Self) -> bool { + self.name == other.name + } +} + +impl Eq for PortDataType {} + +/// A single port on a node. +/// +/// Ports are the endpoints of edges. Each port has a globally unique +/// [`PortId`], a direction ([`PortKind`]) and a [`PortDataType`] used for +/// tinting. +pub trait PortData { + /// Returns the globally unique identifier of this port. + fn id(&self) -> PortId; + + /// Returns whether this is an input or an output port. + fn kind(&self) -> PortKind; + + /// Returns the short label drawn next to the port dot (e.g. `"in"`, + /// `"mask"`). May be empty, in which case only the dot is drawn. + fn label(&self) -> SharedString; + + /// Returns the data type of this port, used to tint the port dot and any + /// wires connected to it. + fn data_type(&self) -> PortDataType; + + /// Returns whether this port currently has at least one edge attached. + /// + /// Used only for rendering (connected dots are filled, unconnected dots + /// are hollow) and for styling during wire drags; the widget does not + /// enforce any cardinality rules from it — that is the job of + /// [`NodeGraphDataSource::can_connect`]. + fn is_connected(&self) -> bool; +} + +/// A single node in the graph. +/// +/// Nodes are rectangular cards with a header, a column of input ports on the +/// left and a column of output ports on the right (see +/// [`NodeElement`](crate::node_graph::NodeElement)). +pub trait NodeData { + /// The port type used by this node's inputs and outputs. + type Port: PortData; + + /// Returns the unique identifier of this node. + fn id(&self) -> NodeId; + + /// Returns the title drawn in the node's header. + fn title(&self) -> SharedString; + + /// Returns the position of the node's top-left corner in *graph space* + /// (the document coordinate system). + /// + /// Graph space is an unbounded, zoom-independent coordinate system: a + /// node at `point(px(100.), px(40.))` stays attached to that document + /// location regardless of pan and zoom. The view converts to screen + /// coordinates with + /// [`GraphViewState::graph_to_screen`](crate::node_graph::GraphViewState::graph_to_screen). + fn position(&self) -> Point; + + /// Returns the input ports of this node, in top-to-bottom draw order. + fn inputs(&self) -> Vec; + + /// Returns the output ports of this node, in top-to-bottom draw order. + fn outputs(&self) -> Vec; + + /// Returns an optional accent color for the node header, or `None` to use + /// the theme default. Apps typically use this to group nodes by category + /// (inputs, transforms, color management, outputs, …). + fn header_color(&self) -> Option; + + /// Returns whether the node is collapsed to just its header. + /// + /// Collapsed nodes draw no ports and cannot be connection targets. The + /// collapsed state itself belongs to the app's model (or view state); the + /// widget only reflects it. + fn is_collapsed(&self) -> bool; + + /// Returns whether the node is enabled. + /// + /// Disabled nodes (e.g. a bypassed effect) are drawn dimmed. This is a + /// purely visual hint; the widget does not change interaction behavior + /// for disabled nodes. + fn is_enabled(&self) -> bool; +} + +/// A single directed connection from an output port to an input port. +pub trait EdgeData { + /// Returns the unique identifier of this edge. + fn id(&self) -> EdgeId; + + /// Returns the id of the node the connection starts at. + fn from_node(&self) -> NodeId; + + /// Returns the id of the output port the connection starts at. + fn from_port(&self) -> PortId; + + /// Returns the id of the node the connection ends at. + fn to_node(&self) -> NodeId; + + /// Returns the id of the input port the connection ends at. + fn to_port(&self) -> PortId; +} + +/// The data source backing a [`NodeGraphView`](crate::node_graph::NodeGraphView). +/// +/// The app implements this trait over its engine model and places it in an +/// `Entity`. The view re-reads `nodes()` and `edges()` every frame in which +/// the entity notifies, so implementations should be cheap snapshots or +/// borrow from cached data. +/// +/// All methods take `&self`; the widget never mutates the source. Edits +/// arrive back at the app as [`NodeGraphEvent`]s. +/// +/// [`NodeGraphEvent`]: crate::node_graph::NodeGraphEvent +pub trait NodeGraphDataSource { + /// The node type returned by [`nodes()`](Self::nodes). + type Node: NodeData; + /// The edge type returned by [`edges()`](Self::edges). + type Edge: EdgeData; + + /// Returns all nodes to display, in no required order (the view sorts for + /// painting; selection order is unaffected). + fn nodes(&self) -> Vec; + + /// Returns all edges to display. Edges referencing ports or nodes that + /// are not part of [`nodes()`](Self::nodes) are ignored by the view. + fn edges(&self) -> Vec; + + /// Returns whether connecting output port `from` to input port `to` + /// would be valid. + /// + /// This is the single place where the app enforces its connection rules: + /// data-type compatibility (including implicit conversions), cycle + /// prevention, port cardinality, node enablement, and so on. The view + /// calls this: + /// + /// - *continuously during a wire drag* to highlight compatible target + /// ports and to mark the ghost wire as valid/invalid, and + /// - *once on drop* before emitting + /// [`NodeGraphEvent::ConnectionRequested`](crate::node_graph::NodeGraphEvent::ConnectionRequested) + /// — a drop on a port for which this returns `false` cancels the drag + /// silently. + /// + /// It must be cheap, pure, and consistent: the same arguments must yield + /// the same answer within a frame. The view passes output port first, + /// input port second, regardless of which end the user started the drag + /// from. Returning `true` here does not commit the app to accepting the + /// connection; the engine may still reject it when the event arrives + /// (e.g. it raced with another edit), in which case the app simply does + /// not apply it. + fn can_connect(&self, from: PortId, to: PortId) -> bool; +} diff --git a/crates/gpui/src/node_graph/graph_view.rs b/crates/gpui/src/node_graph/graph_view.rs new file mode 100644 index 0000000000..819fdbffe3 --- /dev/null +++ b/crates/gpui/src/node_graph/graph_view.rs @@ -0,0 +1,981 @@ +//! The interactive node-graph view and its event type. +//! +//! [`NodeGraphView`] is the top-level widget: a focusable canvas that renders +//! nodes, wires and interaction overlays, and reports every user edit +//! intention as a [`NodeGraphEvent`]. See the +//! [module-level docs](crate::node_graph) for the overall architecture. + +use std::collections::{BTreeSet, HashMap}; + +use crate::{ + App, BorderStyle, Bounds, Context, Corners, Edges, Entity, EventEmitter, FocusHandle, + Focusable, Hsla, IntoElement, KeyDownEvent, KeyUpEvent, MouseButton, MouseDownEvent, + MouseMoveEvent, PaintQuad, PinchEvent, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, + Window, canvas, colors::DefaultColors, div, fill, hsla, point, prelude::*, px, size, +}; + +use crate::node_graph::{ + DEFAULT_NODE_WIDTH, EdgeData, EdgeId, GhostWire, GraphViewState, NodeData, NodeElement, + NodeGraphDataSource, NodeId, NodeVisualState, PortData, PortDataType, PortId, PortKind, + SelectionRect, Wire, WireVisualState, paint_ghost, +}; + +/// Spacing between grid lines, in graph-space pixels. +const GRID_SIZE: f32 = 20.0; + +/// How close (in screen pixels) the cursor must be to a port dot for a wire +/// drag to snap to it. +const PORT_GRAB_RADIUS: Pixels = px(12.0); + +/// Minimum marquee drag distance (in screen pixels) before a background press +/// is treated as a marquee drag rather than a plain click. +const MARQUEE_DRAG_THRESHOLD: f32 = 3.0; + +/// What a mouse press on the canvas hit. +#[derive(Clone, Copy, Debug, PartialEq)] +enum HitTarget { + /// Empty background. + Background, + /// A port dot: start a wire drag. + Port(PortId), + /// A collapse or enable toggle in a node header: toggle selection only. + Toggle(NodeId), + /// A node body: select (and possibly drag) the node. + Node(NodeId), +} + +/// Transient state of a node move drag. +struct NodeDragState { + /// The nodes being moved (the full selection at drag start). + nodes: Vec, + /// Element-local cursor position where the drag started. + anchor: Point, + /// Accumulated graph-space displacement since drag start. + delta: Point, +} + +/// Transient state of a wire drag (a "ghost" connection in progress). +struct WireDragState { + /// The ghost wire, anchored at the drag source port. + ghost: GhostWire, + /// The port the drag started from. + source_port: PortId, + /// When the drag picked up an existing edge (from a connected input), the + /// edge id; dropping in empty space disconnects it. + picked_edge: Option, + /// Ports currently approved as drop targets by + /// [`NodeGraphDataSource::can_connect`]. + valid_ports: BTreeSet, +} + +/// Transient state of a pan drag (space-drag or middle-mouse drag). +struct PanDragState { + /// Window-space cursor position where the pan started. + start_mouse: Point, + /// The viewport offset when the pan started. + start_offset: Point, +} + +/// A snapshot of everything the view paints in one frame, computed in the +/// canvas prepaint and consumed by the paint closure. +struct GraphDraw { + /// Node elements in paint order (bottom-most first), positioned in window + /// space. Dragged nodes are painted last (on top). + nodes: Vec<(Point, NodeElement)>, + /// Edge wires, in graph order, positioned in window space. + wires: Vec, + /// The in-progress ghost wire, if any, in window space. + ghost: Option, + /// The in-progress marquee rectangle, in element-local space. + marquee: Option, + /// The pan offset used to compute this frame. + offset: Point, + /// The zoom factor used to compute this frame. + zoom: f32, +} + +/// A copy of a [`GhostWire`]'s geometry, stored in the frame snapshot so the +/// paint closure does not need to borrow the view. +struct GhostSnapshot { + /// Window-space anchor of the fixed end. + from: Point, + /// Window-space position of the free (cursor) end. + to: Point, + /// Data-type tint of the source port. + color: Hsla, + /// Whether the current drop target is valid. + target_valid: bool, +} + +/// Returns whether the two axis-aligned rectangles overlap (touching counts). +fn rects_intersect( + min1: Point, + max1: Point, + min2: Point, + max2: Point, +) -> bool { + min1.x <= max2.x && min2.x <= max1.x && min1.y <= max2.y && min2.y <= max1.y +} + +/// Events emitted by [`NodeGraphView`]. +/// +/// **Every variant is a request, not a fact.** The widget never mutates the +/// graph itself; the app receives these events, validates them against its +/// engine and undo stack, applies them (or not), and calls `cx.notify()` on +/// the data-source entity. Variants named `*Requested` correspond to undoable +/// engine operations; the others are view-state notifications the app may +/// ignore. +#[derive(Clone, Debug)] +pub enum NodeGraphEvent { + /// Continuous preview emitted while the user drags one or more nodes: + /// reports the *accumulated* graph-space delta since the drag started. + /// + /// Emitted on every pointer move during a node drag, before the final + /// [`NodeMoveRequested`](Self::NodeMoveRequested). Apps may use it for + /// live feedback (e.g. snapping guides) but must not push undo states for + /// it. The widget draws dragged nodes at their model position plus this + /// delta, so the app does not need to apply it for the drag to look + /// right. + NodeMovePreview { + /// The nodes being dragged (the full selection at drag start). + nodes: Vec, + /// Accumulated graph-space displacement since drag start. + delta: Point, + }, + + /// Emitted exactly once when a node drag ends (pointer release). + /// + /// This is the undoable operation: the app should move all listed nodes + /// by `delta` in graph space as a single undo step. `delta` is the same + /// accumulated displacement reported by the last + /// [`NodeMovePreview`](Self::NodeMovePreview) of this drag. + NodeMoveRequested { + /// The nodes to move (the full selection at drag start). + nodes: Vec, + /// Total graph-space displacement to apply. + delta: Point, + }, + + /// The user dropped a wire drag on a port and + /// [`NodeGraphDataSource::can_connect`] approved the pair. + /// + /// `from` is always the output port, `to` the input port, regardless of + /// which end the drag started from. The app should still re-validate + /// before applying — the model may have changed since the drag started. + ConnectionRequested { + /// The output port the connection starts at. + from: PortId, + /// The input port the connection ends at. + to: PortId, + }, + + /// The user asked to remove an existing edge (e.g. by clicking a wire + /// with the disconnect modifier, or dragging a connected input's wire + /// off into empty space). + DisconnectionRequested { + /// The edge to remove. + edge: EdgeId, + }, + + /// The user pressed the delete/backspace key with a non-empty selection. + /// + /// Nodes and edges are delivered together so the app can remove them as + /// one undo step. `edges` contains both explicitly selected edges and + /// every edge incident to a deleted node (computed by the widget, since + /// those edges cannot outlive their endpoints). + DeleteRequested { + /// The nodes to delete. + nodes: Vec, + /// The edges to delete, including edges incident to `nodes`. + edges: Vec, + }, + + /// The selection changed. The full new selection is included so listeners + /// do not need to track deltas. Oak uses this to keep the node graph and + /// the [`crate::effect_stack`] selections in sync. + SelectionChanged { + /// The complete new selection. + nodes: BTreeSet, + }, + + /// The viewport (pan offset and/or zoom) changed. Emitted after the + /// gesture that caused it completes — for a zoom-to-cursor scroll this is + /// per scroll tick; apps that persist the viewport should debounce. + ViewChanged { + /// The new pan offset (screen-space position of the graph origin). + offset: Point, + /// The new zoom factor. + zoom: f32, + }, + + /// The user clicked (or released a cancelled wire drag on) empty + /// background. `position` is the click position in *graph space*, ready + /// to be used as the position of a newly created node. Oak opens its + /// "add node" menu from this event. + BackgroundClicked { + /// Click position in graph space. + position: Point, + }, +} + +use NodeGraphEvent::*; + +/// The interactive node-graph editor view. +/// +/// Generic over the app's data source `D`. Construct with +/// [`NodeGraphView::new`], place the returned `Entity>` in +/// your layout, and [`cx.subscribe`](Context::subscribe) to +/// [`NodeGraphEvent`] to receive edit requests. +/// +/// # Interaction summary +/// +/// | Gesture | Effect | +/// |---|---| +/// | Space-drag or middle-mouse drag on background | pan ([`NodeGraphEvent::ViewChanged`]) | +/// | Scroll wheel / trackpad pinch | zoom at cursor ([`NodeGraphEvent::ViewChanged`]) | +/// | Left-drag on a node | move the node — and the whole selection if the node was selected ([`NodeGraphEvent::NodeMovePreview`] × N, then [`NodeGraphEvent::NodeMoveRequested`]) | +/// | Left-drag from a port dot | wire drag: compatible target ports highlight live via [`NodeGraphDataSource::can_connect`]; drop on a port emits [`NodeGraphEvent::ConnectionRequested`], drop on empty space cancels and emits [`NodeGraphEvent::BackgroundClicked`] so the app can offer an "add node" menu | +/// | Left-drag on background | marquee selection ([`NodeGraphEvent::SelectionChanged`]) | +/// | Click node | select it; Shift-click toggles it in the selection | +/// | Delete / Backspace | [`NodeGraphEvent::DeleteRequested`] for the selection | +/// +/// All mouse positions in events are in window space; hit testing and painting +/// convert to element-local space by subtracting the viewport origin, which is +/// captured each frame by the canvas prepaint. +pub struct NodeGraphView { + /// The app-supplied graph model. Read every frame; never mutated. + data: Entity, + /// Viewport and selection state. + state: GraphViewState, + /// Focus handle for keyboard interactions (delete, future shortcuts). + focus_handle: FocusHandle, + /// The view's bounds within the window, set every frame by the canvas + /// prepaint. Its origin converts between window-space and element-local + /// coordinates. + viewport: Bounds, + /// In-progress node move drag, if any. + node_drag: Option, + /// In-progress wire drag, if any. + wire_drag: Option, + /// In-progress pan drag, if any. + pan_drag: Option, + /// Whether the space key is currently held down (space-drag pans). + space_down: bool, +} + +impl NodeGraphView { + /// Creates a new node-graph view over the given data-source entity. + /// + /// The view subscribes to the entity and re-renders whenever the app + /// calls `cx.notify()` on it after applying (or rejecting) edit requests. + pub fn new(data: Entity, _window: &mut Window, cx: &mut Context) -> Self { + let focus_handle = cx.focus_handle(); + cx.observe(&data, |_, _, cx| cx.notify()).detach(); + Self { + data, + state: GraphViewState::new(), + focus_handle, + viewport: Bounds::new(point(px(0.0), px(0.0)), size(px(0.0), px(0.0))), + node_drag: None, + wire_drag: None, + pan_drag: None, + space_down: false, + } + } + + /// Returns the current viewport/selection state. + pub fn state(&self) -> &GraphViewState { + &self.state + } + + /// Returns a mutable reference to the viewport/selection state, e.g. to + /// restore a persisted viewport or to sync selection with + /// [`crate::effect_stack`]. Does not emit events; call `cx.notify()` on + /// the view entity afterwards if you changed anything. + pub fn state_mut(&mut self) -> &mut GraphViewState { + &mut self.state + } + + /// Returns the data-source entity this view renders. + pub fn data(&self) -> &Entity { + &self.data + } + + /// Returns what is under `position` (in window space), or + /// [`HitTarget::Background`]. Nodes are tested in reverse paint order so + /// the topmost (last-painted) node wins. + fn hit_test(&self, position: Point, cx: &App) -> HitTarget { + let anchor = position - self.viewport.origin; + let data = self.data.read(cx); + for node in data.nodes().into_iter().rev() { + let element = NodeElement::from_node(&node, NodeVisualState::default()); + let screen_pos = self.state.graph_to_screen(node.position()); + let bounds = Bounds::new(screen_pos, size(DEFAULT_NODE_WIDTH, element.height())); + if bounds.contains(&anchor) { + let local = anchor - screen_pos; + if let Some(port) = element.port_at(local) { + return HitTarget::Port(port); + } + if element.collapse_toggle_hit(local) || element.enable_toggle_hit(local) { + return HitTarget::Toggle(node.id()); + } + return HitTarget::Node(node.id()); + } + } + HitTarget::Background + } + + /// Handles a press on a node's body: updates the selection according to + /// modifier keys (plain click selects exclusively, Shift toggles) and + /// begins a potential node drag. Emits + /// [`NodeGraphEvent::SelectionChanged`] when the selection changed. + fn on_node_mouse_down( + &mut self, + node: NodeId, + position: Point, + toggle: bool, + _window: &mut Window, + cx: &mut Context, + ) { + if toggle { + let mut new_selection = self.state.selection().clone(); + if !new_selection.remove(&node) { + new_selection.insert(node); + } + self.set_selection_and_emit(new_selection, cx); + } else if !self.state.is_selected(node) { + self.set_selection_and_emit(BTreeSet::from([node]), cx); + } + self.node_drag = Some(NodeDragState { + nodes: self.state.selection().iter().copied().collect(), + anchor: position - self.viewport.origin, + delta: point(px(0.0), px(0.0)), + }); + cx.notify(); + } + + /// Handles pointer movement during a node drag: updates the accumulated + /// drag delta in graph space and emits [`NodeGraphEvent::NodeMovePreview`]. + fn on_node_drag_move(&mut self, window: &mut Window, cx: &mut Context) { + let drag = self.node_drag.as_mut().expect("node drag in progress"); + let cursor = window.mouse_position() - self.viewport.origin; + drag.delta = self.state.screen_to_graph(cursor) - self.state.screen_to_graph(drag.anchor); + let (nodes, delta) = (drag.nodes.clone(), drag.delta); + cx.emit(NodeMovePreview { nodes, delta }); + cx.notify(); + } + + /// Handles pointer release at the end of a node drag: emits the final + /// [`NodeGraphEvent::NodeMoveRequested`] with the accumulated delta and + /// clears the transient drag state. + fn on_node_drag_end(&mut self, _window: &mut Window, cx: &mut Context) { + if let Some(drag) = self.node_drag.take() { + if drag.delta != point(px(0.0), px(0.0)) { + cx.emit(NodeMoveRequested { + nodes: drag.nodes, + delta: drag.delta, + }); + } + cx.notify(); + } + } + + /// Begins a wire drag from the given port. If the port is a connected + /// input, the existing edge is "picked up" instead: its other end becomes + /// the drag source and a [`NodeGraphEvent::DisconnectionRequested`] is + /// emitted only if the drag ends without a new connection. + fn begin_wire_drag(&mut self, port: PortId, _window: &mut Window, cx: &mut Context) { + let data = self.data.read(cx); + let mut found: Option<(Point, Point, PortKind, Option)> = + None; + for node in data.nodes() { + let element = NodeElement::from_node(&node, NodeVisualState::default()); + if let Some(anchor) = element.port_anchor(port) { + let kind = if node.inputs().into_iter().any(|p| p.id() == port) { + PortKind::Input + } else { + PortKind::Output + }; + let data_type = node + .inputs() + .into_iter() + .chain(node.outputs()) + .find(|p| p.id() == port) + .map(|p| p.data_type()); + found = Some((node.position(), anchor, kind, data_type)); + break; + } + } + let (node_pos, anchor, kind, data_type) = match found { + Some((node_pos, anchor, kind, Some(data_type))) => (node_pos, anchor, kind, data_type), + _ => return, + }; + let screen_anchor = self.viewport.origin + self.state.graph_to_screen(node_pos + anchor); + + // Picking up an existing edge: only a connected input drag re-roots the + // ghost at the far (output) end; an output drag always starts fresh. + if kind == PortKind::Input { + if let Some(edge) = data.edges().into_iter().find(|e| e.to_port() == port) { + if let Some(from_node) = + data.nodes().into_iter().find(|n| n.id() == edge.from_node()) + { + let from_element = + NodeElement::from_node(&from_node, NodeVisualState::default()); + if let Some(far_anchor) = from_element.port_anchor(edge.from_port()) { + let far_screen = self.viewport.origin + + self.state.graph_to_screen(from_node.position() + far_anchor); + if let Some(far_type) = from_node + .outputs() + .into_iter() + .find(|p| p.id() == edge.from_port()) + .map(|p| p.data_type()) + { + self.wire_drag = Some(WireDragState { + ghost: GhostWire::new(far_screen, &far_type, true), + source_port: port, + picked_edge: Some(edge.id()), + valid_ports: BTreeSet::new(), + }); + cx.notify(); + return; + } + } + } + } + } + + self.wire_drag = Some(WireDragState { + ghost: GhostWire::new(screen_anchor, &data_type, kind == PortKind::Output), + source_port: port, + picked_edge: None, + valid_ports: BTreeSet::new(), + }); + cx.notify(); + } + + /// Updates the wire drag: moves the ghost wire's free end to the cursor + /// and recomputes which ports are valid drop targets by calling + /// [`NodeGraphDataSource::can_connect`] for each port of the opposite + /// kind. Ports that pass are highlighted; the ghost wire is drawn in its + /// invalid state while hovering a port that fails. + fn update_wire_drag(&mut self, window: &mut Window, cx: &mut Context) { + let data = self.data.read(cx); + let drag = self.wire_drag.as_mut().expect("wire drag in progress"); + let from_output = drag.ghost.is_from_output(); + let source_port = drag.source_port; + let picked_edge = drag.picked_edge; + let output_id = if from_output { + picked_edge + .and_then(|edge_id| data.edges().into_iter().find(|e| e.id() == edge_id)) + .map(|edge| edge.from_port()) + .unwrap_or(source_port) + } else { + source_port + }; + let cursor = window.mouse_position(); + let mut valid: BTreeSet = BTreeSet::new(); + let mut snapped: Option> = None; + let mut target_valid = false; + for node in data.nodes() { + let element = NodeElement::from_node(&node, NodeVisualState::default()); + for port in node.inputs().into_iter().chain(node.outputs()) { + let port_id = port.id(); + let candidate = if from_output { + port.kind() == PortKind::Input && data.can_connect(output_id, port_id) + } else { + port.kind() == PortKind::Output && data.can_connect(port_id, source_port) + }; + if candidate { + valid.insert(port_id); + } + if let Some(anchor) = element.port_anchor(port_id) { + let screen = + self.viewport.origin + self.state.graph_to_screen(node.position() + anchor); + let dx = screen.x.0 - cursor.x.0; + let dy = screen.y.0 - cursor.y.0; + if dx * dx + dy * dy <= PORT_GRAB_RADIUS.0 * PORT_GRAB_RADIUS.0 { + snapped = Some(screen); + target_valid = candidate; + } + } + } + } + drag.valid_ports = valid; + drag.ghost.update(cursor, snapped, target_valid); + cx.notify(); + } + + /// Ends the wire drag. On a compatible port: emits + /// [`NodeGraphEvent::ConnectionRequested`]. On empty space: cancels and + /// emits [`NodeGraphEvent::BackgroundClicked`] at the drop position so + /// the app may open an "add node" menu pre-wired to the dragged port. On + /// an incompatible port (or back on the source port): cancels silently. + fn end_wire_drag(&mut self, window: &mut Window, cx: &mut Context) { + let drag = match self.wire_drag.take() { + Some(drag) => drag, + None => return, + }; + let data = self.data.read(cx); + let from_output = drag.ghost.is_from_output(); + let source_port = drag.source_port; + let picked_edge = drag.picked_edge; + let output_id = if from_output { + picked_edge + .and_then(|edge_id| data.edges().into_iter().find(|e| e.id() == edge_id)) + .map(|edge| edge.from_port()) + .unwrap_or(source_port) + } else { + source_port + }; + let cursor = window.mouse_position(); + let mut hit: Option<(PortId, bool)> = None; + 'ports: for node in data.nodes() { + let element = NodeElement::from_node(&node, NodeVisualState::default()); + for port in node.inputs().into_iter().chain(node.outputs()) { + let port_id = port.id(); + let candidate = if from_output { + port.kind() == PortKind::Input && data.can_connect(output_id, port_id) + } else { + port.kind() == PortKind::Output && data.can_connect(port_id, source_port) + }; + if let Some(anchor) = element.port_anchor(port_id) { + let screen = self.viewport.origin + + self.state.graph_to_screen(node.position() + anchor); + let dx = screen.x.0 - cursor.x.0; + let dy = screen.y.0 - cursor.y.0; + if dx * dx + dy * dy <= PORT_GRAB_RADIUS.0 * PORT_GRAB_RADIUS.0 { + hit = Some((port_id, candidate)); + break 'ports; + } + } + } + } + match hit { + Some((target, true)) if target != source_port => { + cx.emit(ConnectionRequested { + from: output_id, + to: target, + }); + } + // An incompatible port or the port the drag started from: cancel. + Some(_) => {} + None => { + if let Some(edge) = picked_edge { + cx.emit(DisconnectionRequested { edge }); + } else { + cx.emit(BackgroundClicked { + position: self.state.screen_to_graph(cursor - self.viewport.origin), + }); + } + } + } + cx.notify(); + } + + /// Handles background presses: begins panning (space/middle button) or a + /// marquee selection (left button), or emits + /// [`NodeGraphEvent::BackgroundClicked`] on a right click. + fn on_background_mouse_down( + &mut self, + position: Point, + button: MouseButton, + _window: &mut Window, + cx: &mut Context, + ) { + let anchor = position - self.viewport.origin; + if self.space_down || button == MouseButton::Middle { + self.pan_drag = Some(PanDragState { + start_mouse: position, + start_offset: self.state.offset(), + }); + } + if button == MouseButton::Left { + self.state.begin_marquee(anchor); + } + if button == MouseButton::Right { + cx.emit(BackgroundClicked { + position: self.state.screen_to_graph(anchor), + }); + } + cx.notify(); + } + + /// Handles pointer movement during a pan drag: repositions the viewport + /// offset and emits [`NodeGraphEvent::ViewChanged`]. + fn on_pan_drag_move(&mut self, position: Point, cx: &mut Context) { + let pan = self.pan_drag.as_ref().expect("pan drag in progress"); + let (start_mouse, start_offset) = (pan.start_mouse, pan.start_offset); + self.state.set_offset(start_offset + (position - start_mouse)); + cx.emit(ViewChanged { + offset: self.state.offset(), + zoom: self.state.zoom(), + }); + cx.notify(); + } + + /// Handles scroll-wheel and pinch gestures: zooms at the cursor via + /// [`GraphViewState::zoom_at`] and emits [`NodeGraphEvent::ViewChanged`]. + fn on_scroll_or_pinch(&mut self, position: Point, factor: f32, cx: &mut Context) { + self.state.zoom_at(position - self.viewport.origin, factor); + cx.emit(ViewChanged { + offset: self.state.offset(), + zoom: self.state.zoom(), + }); + cx.notify(); + } + + /// Handles the delete/backspace key: collects the selected nodes plus all + /// edges incident to them and emits [`NodeGraphEvent::DeleteRequested`]. + /// Does nothing with an empty selection. + fn on_delete_key(&mut self, _window: &mut Window, cx: &mut Context) { + let nodes = self.state.selection().iter().copied().collect::>(); + if nodes.is_empty() { + return; + } + let data = self.data.read(cx); + let edges = data + .edges() + .into_iter() + .filter(|edge| { + nodes + .iter() + .any(|node| *node == edge.from_node() || *node == edge.to_node()) + }) + .map(|edge| edge.id()) + .collect::>(); + cx.emit(DeleteRequested { nodes, edges }); + cx.notify(); + } + + /// Emits [`NodeGraphEvent::SelectionChanged`] if `new` differs from the + /// current selection, and stores `new`. + fn set_selection_and_emit(&mut self, new: BTreeSet, cx: &mut Context) { + if self.state.selection() == &new { + return; + } + self.state.set_selection(new.clone()); + cx.emit(SelectionChanged { nodes: new }); + } + + /// Ends a marquee drag: selects all nodes intersecting the rectangle, or + /// treats the press as a plain background click (clear selection + emit + /// [`NodeGraphEvent::BackgroundClicked`]) when the drag was too small to + /// count. + fn end_marquee_or_click(&mut self, _window: &mut Window, cx: &mut Context) { + let rect = match self.state.end_marquee() { + Some(rect) => rect, + None => return, + }; + let (min, max) = rect.normalized(); + let dragged = (max.x - min.x).0 >= MARQUEE_DRAG_THRESHOLD + || (max.y - min.y).0 >= MARQUEE_DRAG_THRESHOLD; + if dragged { + let g_min = self.state.screen_to_graph(min); + let g_max = self.state.screen_to_graph(max); + let data = self.data.read(cx); + let mut new_selection = BTreeSet::new(); + for node in data.nodes() { + let element = NodeElement::from_node(&node, NodeVisualState::default()); + let pos = node.position(); + if rects_intersect( + g_min, + g_max, + pos, + pos + point(DEFAULT_NODE_WIDTH, element.height()), + ) { + new_selection.insert(node.id()); + } + } + self.set_selection_and_emit(new_selection, cx); + } else { + self.set_selection_and_emit(BTreeSet::new(), cx); + cx.emit(BackgroundClicked { + position: self.state.screen_to_graph(min), + }); + } + cx.notify(); + } + + /// Snapshot of the frame the canvas is about to paint: nodes (in paint + /// order, dragged nodes last), wires, ghost wire and marquee, all in + /// window space where applicable. + fn build_draw(&self, cx: &mut Context) -> GraphDraw { + let data = self.data.read(cx); + let selection = self.state.selection().clone(); + let drag = self.node_drag.as_ref(); + let wire = self.wire_drag.as_ref(); + let viewport_origin = self.viewport.origin; + let fallback = PortDataType::new("", hsla(0.0, 0.0, 0.5, 1.0)); + + let mut port_types: HashMap = HashMap::new(); + let mut elements: HashMap, NodeElement)> = HashMap::new(); + let mut order: Vec = Vec::new(); + let mut top: Vec = Vec::new(); + + for node in data.nodes() { + let node_id = node.id(); + for port in node.inputs().into_iter().chain(node.outputs()) { + port_types.insert(port.id(), port.data_type()); + } + let has_compatible_port = wire.map_or(false, |w| { + node.inputs() + .into_iter() + .chain(node.outputs()) + .any(|port| w.valid_ports.contains(&port.id())) + }); + let element = NodeElement::from_node( + &node, + NodeVisualState { + selected: selection.contains(&node_id), + has_compatible_port, + }, + ); + let mut pos = node.position(); + if let Some(d) = drag { + if d.nodes.contains(&node_id) { + pos = pos + d.delta; + top.push(node_id); + } else { + order.push(node_id); + } + } else { + order.push(node_id); + } + elements.insert( + node_id, + (viewport_origin + self.state.graph_to_screen(pos), element), + ); + } + order.extend(top); + + let mut wires = Vec::new(); + for edge in data.edges() { + let (from_pos, from_element) = match elements.get(&edge.from_node()) { + Some(entry) => entry, + None => continue, + }; + let (to_pos, to_element) = match elements.get(&edge.to_node()) { + Some(entry) => entry, + None => continue, + }; + let from_anchor = match from_element.port_anchor(edge.from_port()) { + Some(anchor) => anchor, + None => continue, + }; + let to_anchor = match to_element.port_anchor(edge.to_port()) { + Some(anchor) => anchor, + None => continue, + }; + let data_type = port_types + .get(&edge.from_port()) + .or_else(|| port_types.get(&edge.to_port())) + .unwrap_or(&fallback); + let selected = + selection.contains(&edge.from_node()) || selection.contains(&edge.to_node()); + let wire_state = if selected { + WireVisualState::Selected + } else { + WireVisualState::Normal + }; + wires.push(Wire::new( + edge.id(), + *from_pos + from_anchor, + *to_pos + to_anchor, + data_type, + wire_state, + )); + } + + let nodes = order + .into_iter() + .map(|id| { + elements + .remove(&id) + .expect("every painted node must have an element") + }) + .collect(); + + let ghost = wire.map(|d| GhostSnapshot { + from: d.ghost.source(), + to: d.ghost.free_end(), + color: d.ghost.color(), + target_valid: d.ghost.is_target_valid(), + }); + + GraphDraw { + nodes, + wires, + ghost, + marquee: self.state.marquee().copied(), + offset: self.state.offset(), + zoom: self.state.zoom(), + } + } + + /// Paints a [`GraphDraw`] snapshot: background, grid, wires, nodes, ghost + /// wire and marquee overlay. + fn paint_draw(draw: &GraphDraw, bounds: Bounds, window: &mut Window, cx: &mut App) { + let colors = cx.default_colors().clone(); + window.paint_quad(fill(bounds, Hsla::from(colors.background))); + + // Grid lines. Lines are spaced GRID_SIZE graph pixels apart; a line + // with graph coordinate k lands at screen x = offset.x + k*GRID_SIZE*zoom. + let zoom = draw.zoom; + let x0 = ((-draw.offset.x.0) / (GRID_SIZE * zoom)).floor() as i64; + let x1 = ((bounds.size.width.0 - draw.offset.x.0) / (GRID_SIZE * zoom)).ceil() as i64; + for k in x0..=x1 { + let x = bounds.left() + px(k as f32 * GRID_SIZE * zoom + draw.offset.x.0); + window.paint_quad(fill( + Bounds::new(point(x, bounds.top()), size(px(1.0), bounds.size.height)), + Hsla::from(colors.border).opacity(0.5), + )); + } + let y0 = ((-draw.offset.y.0) / (GRID_SIZE * zoom)).floor() as i64; + let y1 = ((bounds.size.height.0 - draw.offset.y.0) / (GRID_SIZE * zoom)).ceil() as i64; + for k in y0..=y1 { + let y = bounds.top() + px(k as f32 * GRID_SIZE * zoom + draw.offset.y.0); + window.paint_quad(fill( + Bounds::new(point(bounds.left(), y), size(bounds.size.width, px(1.0))), + Hsla::from(colors.border).opacity(0.5), + )); + } + + for wire in &draw.wires { + wire.paint(window, zoom); + } + for (origin, element) in &draw.nodes { + element.paint(*origin, window, cx); + } + if let Some(ghost) = &draw.ghost { + paint_ghost(window, ghost.from, ghost.to, ghost.color, ghost.target_valid, zoom); + } + if let Some(marquee) = &draw.marquee { + let (min, max) = marquee.normalized(); + let marquee_bounds = Bounds::from_corners(bounds.origin + min, bounds.origin + max); + window.paint_quad(fill( + marquee_bounds, + Hsla::from(colors.selected).opacity(0.15), + )); + window.paint_quad(PaintQuad { + bounds: marquee_bounds, + corner_radii: Corners::all(px(0.0)), + background: hsla(0.0, 0.0, 0.0, 0.0).into(), + border_widths: Edges::all(px(1.0)), + border_color: Hsla::from(colors.selected), + border_style: BorderStyle::Solid, + }); + } + } +} + +impl EventEmitter for NodeGraphView {} + +impl Focusable for NodeGraphView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for NodeGraphView { + /// Renders the graph: a full-size background layer (grid + pan/zoom + /// handlers), then wires below nodes in graph-space order, then the + /// marquee rectangle and the ghost wire as overlays. + /// + /// Layout/painting is done in screen space; node and wire geometry is + /// computed by mapping graph-space model coordinates through + /// [`GraphViewState::graph_to_screen`]. Wire anchors come from + /// [`NodeElement::port_anchor`](crate::node_graph::NodeElement::port_anchor) + /// so wires always land on port dots. + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let focus_handle = self.focus_handle.clone(); + let entity = cx.entity(); + + div() + .relative() + .size_full() + .track_focus(&focus_handle) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + if event.keystroke.key == "space" { + this.space_down = true; + } else if event.keystroke.key == "delete" || event.keystroke.key == "backspace" { + this.on_delete_key(window, cx); + } + })) + .on_key_up(cx.listener(|this, event: &KeyUpEvent, _window, _cx| { + if event.keystroke.key == "space" { + this.space_down = false; + } + })) + .on_mouse_down(MouseButton::Left, cx.listener(|this, event: &MouseDownEvent, window, cx| { + window.focus(&this.focus_handle, cx); + match this.hit_test(event.position, cx) { + HitTarget::Port(port) => this.begin_wire_drag(port, window, cx), + HitTarget::Toggle(node) => { + this.set_selection_and_emit(BTreeSet::from([node]), cx); + cx.notify(); + } + HitTarget::Node(node) => { + this.on_node_mouse_down(node, event.position, event.modifiers.shift, window, cx); + } + HitTarget::Background => { + this.on_background_mouse_down(event.position, MouseButton::Left, window, cx); + } + } + })) + .on_mouse_down(MouseButton::Middle, cx.listener(|this, event: &MouseDownEvent, window, cx| { + window.focus(&this.focus_handle, cx); + this.on_background_mouse_down(event.position, MouseButton::Middle, window, cx); + })) + .on_mouse_down(MouseButton::Right, cx.listener(|this, event: &MouseDownEvent, window, cx| { + window.focus(&this.focus_handle, cx); + if this.hit_test(event.position, cx) == HitTarget::Background { + this.on_background_mouse_down(event.position, MouseButton::Right, window, cx); + } + })) + .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, window, cx| { + if this.node_drag.is_some() { + this.on_node_drag_move(window, cx); + } else if this.wire_drag.is_some() { + this.update_wire_drag(window, cx); + } else if this.pan_drag.is_some() { + this.on_pan_drag_move(event.position, cx); + } else if this.state.marquee().is_some() { + this.state.update_marquee(event.position - this.viewport.origin); + cx.notify(); + } + })) + .capture_any_mouse_up(cx.listener(|this, _event, window, cx| { + if this.node_drag.is_some() { + this.on_node_drag_end(window, cx); + } else if this.wire_drag.is_some() { + this.end_wire_drag(window, cx); + } else if this.pan_drag.is_some() { + this.pan_drag = None; + cx.notify(); + } else { + this.end_marquee_or_click(window, cx); + } + })) + .on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _window, cx| { + let factor = match event.delta { + ScrollDelta::Pixels(delta) => 1.0 + delta.y.0 * 0.002, + ScrollDelta::Lines(lines) => 1.0 + lines.y * 0.1, + }; + this.on_scroll_or_pinch(event.position, factor, cx); + })) + .on_pinch(cx.listener(|this, event: &PinchEvent, _window, cx| { + this.on_scroll_or_pinch(event.position, 1.0 + event.delta, cx); + })) + .child(canvas( + move |bounds, _window, cx| { + entity.update(cx, |this, cx| { + this.viewport = bounds; + this.build_draw(cx) + }) + }, + move |bounds, draw: GraphDraw, window, cx| { + NodeGraphView::::paint_draw(&draw, bounds, window, cx); + }, + )) + } +} diff --git a/crates/gpui/src/node_graph/minimap.rs b/crates/gpui/src/node_graph/minimap.rs new file mode 100644 index 0000000000..67e41b2f39 --- /dev/null +++ b/crates/gpui/src/node_graph/minimap.rs @@ -0,0 +1,152 @@ +//! Optional minimap overlay for the node graph. +//! +//! The minimap is a small corner overlay showing a scaled-down viewport +//! indicator: a translucent backdrop plus a rectangle marking the region of +//! the graph currently visible, derived from the view's pan offset and zoom. +//! +//! **Scope note.** The minimap is intentionally minimal: it draws no node +//! rectangles and supports no click/drag navigation, because [`render`] +//! receives neither the data source nor a mutable view state — it only +//! reflects the viewport. [`graph_bounds`] reserves the bounding-box +//! computation a future content-aware minimap would need; it is not yet wired +//! in. + +// `graph_bounds` reserves the bounding-box computation for a future +// content-aware minimap; it has no caller yet. +#![allow(dead_code)] +// The `D` parameter is part of the render contract (the data source the +// minimap would read for node rectangles); it is unused until that feature +// lands. +#![allow(clippy::extra_unused_type_parameters)] + +use crate::{Bounds, Empty, IntoElement, Pixels, Point, Window, canvas, deferred, fill, hsla, point, px, size}; + +use crate::node_graph::{ + DEFAULT_NODE_WIDTH, GraphViewState, NodeElement, NodeGraphDataSource, NodeData, NodeVisualState, +}; + +/// Scale factor from graph-space coordinates to minimap coordinates. +pub const MINIMAP_CONTENT_SCALE: f32 = 0.15; + +/// A small overview map of the entire graph, drawn as a corner overlay. +/// +/// Renders a translucent backdrop and a highlight rectangle indicating the +/// currently visible region (derived from the view's offset and zoom). The +/// minimap draws no node rectangles and does not react to clicks; it is a +/// passive viewport indicator. +pub struct GraphMinimap { + /// Whether the minimap is shown. Toggled by the app's view menu; the + /// minimap renders nothing and ignores input when `false`. + visible: bool, +} + +impl Default for GraphMinimap { + fn default() -> Self { + Self { visible: true } + } +} + +impl GraphMinimap { + /// Creates a visible minimap overlay. + pub fn new() -> Self { + Self::default() + } + + /// Shows or hides the minimap. + pub fn set_visible(&mut self, visible: bool) { + self.visible = visible; + } + + /// Returns whether the minimap is currently shown. + pub fn is_visible(&self) -> bool { + self.visible + } + + /// Computes the axis-aligned bounding box of all nodes in graph space, + /// used as the minimap's content rect. Returns `None` for an empty + /// graph (the minimap then renders only its backdrop). + /// + /// Node extents are derived from each node's position plus its rendered + /// card size ([`DEFAULT_NODE_WIDTH`] × [`NodeElement::height`]). + fn graph_bounds(data: &D) -> Option<(Point, Point)> { + let mut nodes = data.nodes().into_iter(); + let first = nodes.next()?; + let extent = |node: &D::Node| { + let pos = node.position(); + ( + pos, + pos + point( + DEFAULT_NODE_WIDTH, + NodeElement::from_node(node, NodeVisualState::default()).height(), + ), + ) + }; + let (mut min, mut max) = extent(&first); + for node in nodes { + let (node_min, node_max) = extent(&node); + min = min.min(&node_min); + max = max.max(&node_max); + } + Some((min, max)) + } + + /// Renders the minimap: a translucent backdrop with a viewport indicator + /// rectangle, laid out over `viewport_bounds` (the main view's + /// screen-space bounds). + /// + /// The viewport rectangle is derived from `state` (offset + zoom): the + /// screen-space viewport is mapped back into graph space (`-offset / zoom` + /// plus `viewport size / zoom`) and then down to minimap scale. + pub fn render( + &mut self, + state: &GraphViewState, + viewport_bounds: Bounds, + _window: &mut Window, + ) -> impl IntoElement { + if !self.visible { + return deferred(Empty); + } + let offset = state.offset(); + let zoom = state.zoom(); + deferred(canvas( + move |_bounds, _window, _cx| MinimapDraw { + offset, + zoom, + viewport_bounds, + }, + move |bounds, draw, window, _cx| { + // Backdrop. + window.paint_quad(fill(bounds, hsla(0.0, 0.0, 0.0, 0.6))); + + // Viewport indicator: the graph-space viewport rect (screen + // size scaled back through `zoom`) mapped down to minimap + // scale, positioned at `-offset / zoom`. + let scale = MINIMAP_CONTENT_SCALE; + let origin = bounds.origin + + point( + px(-(draw.offset.x.0 / draw.zoom) * scale), + px(-(draw.offset.y.0 / draw.zoom) * scale), + ); + let vp_size = size( + px(draw.viewport_bounds.size.width.0 / draw.zoom * scale), + px(draw.viewport_bounds.size.height.0 / draw.zoom * scale), + ); + window.paint_quad(fill( + Bounds::new(origin, vp_size), + hsla(0.63, 0.55, 0.55, 0.5), + )); + }, + )) + } +} + +/// Per-frame snapshot passed from the canvas prepaint to its paint closure. +#[derive(Clone, Copy)] +struct MinimapDraw { + /// Pan offset (screen-space position of the graph origin). + offset: Point, + /// Zoom factor. + zoom: f32, + /// The main view's screen-space bounds. + viewport_bounds: Bounds, +} diff --git a/crates/gpui/src/node_graph/mod.rs b/crates/gpui/src/node_graph/mod.rs new file mode 100644 index 0000000000..bd2dda9096 --- /dev/null +++ b/crates/gpui/src/node_graph/mod.rs @@ -0,0 +1,90 @@ +//! Node-graph editor widget. +//! +//! This module provides a data-agnostic, interactive node-graph editor in the +//! style of compositing / video-editing tools (Nuke, Blender, DaVinci Fusion). +//! It is built for the Oak video editor but knows nothing about Oak's engine: +//! all graph data is supplied by the embedding application through traits, and +//! every user edit is surfaced as a *request* event rather than being applied +//! directly. +//! +//! # Architecture +//! +//! - **Trait-driven data source.** The widget never owns graph data. The app +//! implements [`NodeGraphDataSource`], [`NodeData`], [`PortData`] and +//! [`EdgeData`] (see [`data`](crate::node_graph::data)) over its own model and hands the view an +//! `Entity`. After the app mutates its model it calls `cx.notify()` on +//! the data entity and the view re-reads everything on the next frame. +//! - **Canvas with pan/zoom.** [`GraphViewState`] (see [`state`](crate::node_graph::state)) holds the +//! viewport (`offset`, `zoom`) and the current selection, plus the pure +//! coordinate transforms between *graph space* (the document coordinate +//! system node positions live in) and *screen space*. +//! - **Edits as requests.** Moving nodes, connecting ports, deleting items — +//! none of these mutate the graph directly. The view emits +//! [`NodeGraphEvent`]s (see [`graph_view`](crate::node_graph::graph_view)); the app validates them against +//! its engine and its undo stack, applies them, and notifies. This keeps the +//! app's engine the single source of truth and makes undo/redo trivial. +//! - **App-supplied connection rules.** Type compatibility, cycle prevention +//! and port cardinality are enforced by the app via +//! [`NodeGraphDataSource::can_connect`]. The widget calls it live during +//! wire drags to highlight valid drop targets, and again on drop before +//! emitting [`NodeGraphEvent::ConnectionRequested`]. +//! +//! # Submodules +//! +//! - [`data`](crate::node_graph::data) — identifier newtypes and the data-source traits. +//! - [`state`](crate::node_graph::state) — viewport/selection state and coordinate math. +//! - [`graph_view`](crate::node_graph::graph_view) — the [`NodeGraphView`] view and [`NodeGraphEvent`]. +//! - [`node_element`](crate::node_graph::node_element) — rendering of a single node card. +//! - [`wire`](crate::node_graph::wire) — bezier wire rendering, including the drag "ghost" wire. +//! - [`minimap`](crate::node_graph::minimap) — overview minimap (backdrop + viewport indicator). +//! +//! # Wiring into Oak +//! +//! Oak's engine (`oakengine`) owns the real node graph (media → transform → +//! OCIO LUT → output, …). The intended integration: +//! +//! | Widget event | Engine operation | +//! |---|---| +//! | [`NodeGraphEvent::NodeMovePreview`] / [`NodeGraphEvent::NodeMoveRequested`] | transient UI feedback / `engine.move_nodes(...)` wrapped in an undo command | +//! | [`NodeGraphEvent::ConnectionRequested`] | `engine.connect(from, to)` (engine re-validates type & cycle rules) | +//! | [`NodeGraphEvent::DisconnectionRequested`] | `engine.disconnect(edge)` | +//! | [`NodeGraphEvent::DeleteRequested`] | `engine.remove(nodes, edges)` as one undo step | +//! | [`NodeGraphEvent::BackgroundClicked`] | open the "add node" menu at the given graph position | +//! +//! The companion [`crate::effect_stack`] module shows the *same* engine graph +//! as a linear effect stack. The two views are exactly that — two views over +//! one model: they share the engine's node identities ([`NodeId`] is typically +//! a newtype over the engine's node key), so selection sync between them is a +//! matter of storing one shared selection set in the app, not of data +//! conversion. Edits made in either view go through the same engine ops and +//! undo stack. +//! +//! [`NodeGraphDataSource`]: crate::node_graph::NodeGraphDataSource +//! [`NodeGraphDataSource::can_connect`]: crate::node_graph::NodeGraphDataSource::can_connect +//! [`NodeData`]: crate::node_graph::NodeData +//! [`PortData`]: crate::node_graph::PortData +//! [`EdgeData`]: crate::node_graph::EdgeData +//! [`NodeId`]: crate::node_graph::NodeId +//! [`GraphViewState`]: crate::node_graph::GraphViewState +//! [`NodeGraphView`]: crate::node_graph::NodeGraphView +//! [`NodeGraphEvent`]: crate::node_graph::NodeGraphEvent +//! [`NodeGraphEvent::NodeMovePreview`]: crate::node_graph::NodeGraphEvent::NodeMovePreview +//! [`NodeGraphEvent::NodeMoveRequested`]: crate::node_graph::NodeGraphEvent::NodeMoveRequested +//! [`NodeGraphEvent::ConnectionRequested`]: crate::node_graph::NodeGraphEvent::ConnectionRequested +//! [`NodeGraphEvent::DisconnectionRequested`]: crate::node_graph::NodeGraphEvent::DisconnectionRequested +//! [`NodeGraphEvent::DeleteRequested`]: crate::node_graph::NodeGraphEvent::DeleteRequested +//! [`NodeGraphEvent::BackgroundClicked`]: crate::node_graph::NodeGraphEvent::BackgroundClicked + +pub mod data; +pub mod graph_view; +pub mod minimap; +pub mod node_element; +pub mod state; +pub mod wire; + +pub use data::*; +pub use graph_view::*; +pub use minimap::*; +pub use node_element::*; +pub use state::*; +pub use wire::*; diff --git a/crates/gpui/src/node_graph/node_element.rs b/crates/gpui/src/node_graph/node_element.rs new file mode 100644 index 0000000000..ebd682b42a --- /dev/null +++ b/crates/gpui/src/node_graph/node_element.rs @@ -0,0 +1,414 @@ +//! Rendering of a single node card. +//! +//! [`NodeElement`] draws one node of the graph: header, port columns, status +//! styling. It is used internally by +//! [`NodeGraphView`](crate::node_graph::NodeGraphView) but is public so apps +//! can customize or reuse the node chrome. +//! +//! # Sizing and port anchors +//! +//! The node is a fixed-width column (`DEFAULT_NODE_WIDTH`) laid out as: +//! +//! ```text +//! ┌──────────────────────────┐ +//! │ ▶ Title (on) │ header — colored, carries collapse & enable toggles +//! │ ● in out ● │ one row per max(inputs, outputs) index +//! │ ● mask │ +//! └──────────────────────────┘ +//! ``` +//! +//! Input ports form a left-aligned column, output ports a right-aligned +//! column, and row *i* of each column shares the same y coordinate, so the +//! port dots of opposite sides on the same row are horizontally aligned. +//! +//! Wires attach at **port dot centers**. The single source of truth for a +//! port's anchor point is [`NodeElement::port_anchor`], computed as: +//! +//! ```text +//! anchor.x = node_bounds.left() + PORT_DOT_RADIUS + PORT_INSET (inputs) +//! anchor.x = node_bounds.right() - PORT_DOT_RADIUS - PORT_INSET (outputs) +//! anchor.y = node_bounds.top() + HEADER_HEIGHT + row * PORT_ROW_HEIGHT +//! + PORT_ROW_HEIGHT / 2 +//! ``` +//! +//! All coordinates are in the node's local space; the view adds the node's +//! screen-space origin. Wire rendering ([`crate::node_graph::wire`]) uses the +//! same function, so anchors and dots can never drift apart. + +use crate::{ + colors::DefaultColors, App, BorderStyle, Bounds, Corners, Edges, Font, Hsla, PaintQuad, Pixels, + Point, SharedString, TextAlign, TextRun, Window, fill, hsla, point, px, size, +}; + +use crate::node_graph::{data::PortData, NodeData, NodeId, PortId}; + +/// The default width of a node card. Node width is fixed; only the height +/// grows with the port count. +pub const DEFAULT_NODE_WIDTH: Pixels = Pixels(180.0); + +/// Height of the node header bar. +pub const HEADER_HEIGHT: Pixels = Pixels(28.0); + +/// Height of a single port row; both port columns share this row pitch. +pub const PORT_ROW_HEIGHT: Pixels = Pixels(22.0); + +/// Radius of a port dot. +pub const PORT_DOT_RADIUS: Pixels = Pixels(5.0); + +/// Horizontal distance between the node's edge and the port dot center. +pub const PORT_INSET: Pixels = Pixels(8.0); + +/// Visual state of a node card, supplied by the view at render time. +#[derive(Clone, Copy, Debug, Default)] +pub struct NodeVisualState { + /// Whether the node is part of the current selection (drawn with a + /// selection outline). + pub selected: bool, + /// Whether a wire drag is in progress and this node contains at least + /// one port that [`NodeGraphDataSource::can_connect`] approved as a drop + /// target (drawn with a subtle glow). + /// + /// [`NodeGraphDataSource::can_connect`]: crate::node_graph::NodeGraphDataSource::can_connect + pub has_compatible_port: bool, +} + +/// A single rendered node card. +/// +/// Constructed per frame by the view from a [`NodeData`] snapshot plus a +/// [`NodeVisualState`]. Carries no interaction state of its own; mouse +/// handling for drags and wire pulls is installed by +/// [`NodeGraphView`](crate::node_graph::NodeGraphView), which owns the +/// gesture state machine. +pub struct NodeElement { + node: NodeId, + title: SharedString, + header_color: Option, + collapsed: bool, + enabled: bool, + visual: NodeVisualState, + inputs: Vec, + outputs: Vec, +} + +/// One rendered port row: everything needed to draw a port dot and label +/// without re-querying the data source. +#[derive(Clone, Debug)] +struct PortRow { + id: PortId, + label: SharedString, + color: Hsla, + connected: bool, +} + +impl NodeElement { + /// Builds the element from a node snapshot and its visual state. + /// + /// Reads title, header color, collapse/enable flags and both port columns + /// off `node`. Port rows are taken in the order returned by + /// [`NodeData::inputs`] / [`NodeData::outputs`], which defines their + /// top-to-bottom draw order. + pub fn from_node(node: &N, visual: NodeVisualState) -> Self { + let inputs = node + .inputs() + .into_iter() + .map(|port| PortRow { + id: port.id(), + label: port.label(), + color: port.data_type().color, + connected: port.is_connected(), + }) + .collect(); + let outputs = node + .outputs() + .into_iter() + .map(|port| PortRow { + id: port.id(), + label: port.label(), + color: port.data_type().color, + connected: port.is_connected(), + }) + .collect(); + Self { + node: node.id(), + title: node.title(), + header_color: node.header_color(), + collapsed: node.is_collapsed(), + enabled: node.is_enabled(), + visual, + inputs, + outputs, + } + } + + /// Returns the id of the node this element renders. + pub fn node_id(&self) -> NodeId { + self.node + } + + /// Returns the total height of the node card: the header plus + /// `max(inputs, outputs)` port rows (zero rows when collapsed). + pub fn height(&self) -> Pixels { + if self.collapsed { + HEADER_HEIGHT + } else { + HEADER_HEIGHT + PORT_ROW_HEIGHT * self.inputs.len().max(self.outputs.len()) as f32 + } + } + + /// Computes the node-local anchor point (port dot center) of the given + /// port, per the formula in the [module docs](crate::node_graph::node_element). + /// Wires attach here. + /// + /// Returns `None` when the port is not part of this node or the node is + /// collapsed (collapsed nodes expose no anchors and cannot be + /// connection targets). + /// + /// # Panics + /// + /// Never panics; unknown ports yield `None`. + pub fn port_anchor(&self, port: PortId) -> Option> { + if self.collapsed { + return None; + } + let row_y = |row: usize| HEADER_HEIGHT + PORT_ROW_HEIGHT * row as f32 + PORT_ROW_HEIGHT * 0.5; + if let Some(row) = self.inputs.iter().position(|p| p.id == port) { + return Some(point(PORT_DOT_RADIUS + PORT_INSET, row_y(row))); + } + if let Some(row) = self.outputs.iter().position(|p| p.id == port) { + return Some(point( + DEFAULT_NODE_WIDTH - PORT_DOT_RADIUS - PORT_INSET, + row_y(row), + )); + } + None + } + + /// Hit-tests a node-local point against port dots and returns the id of + /// the port whose dot (inflated by a small grab margin) contains it. + /// Used to start wire drags. Header and body hits return `None`. + pub fn port_at(&self, position: Point) -> Option { + let hit_radius = PORT_DOT_RADIUS + px(4.0); + for port in self.inputs.iter().chain(self.outputs.iter()) { + if let Some(anchor) = self.port_anchor(port.id) { + let dx = (position.x - anchor.x).0; + let dy = (position.y - anchor.y).0; + if dx * dx + dy * dy <= hit_radius.0 * hit_radius.0 { + return Some(port.id); + } + } + } + None + } + + /// Returns whether a node-local point lands on the collapse toggle in the + /// header. The view uses this to distinguish "toggle collapse" clicks + /// from drag starts. + pub fn collapse_toggle_hit(&self, position: Point) -> bool { + position.x.0 >= 0.0 + && position.x.0 <= HEADER_HEIGHT.0 + && position.y.0 >= 0.0 + && position.y.0 <= HEADER_HEIGHT.0 + } + + /// Returns whether a node-local point lands on the enable/bypass toggle + /// in the header. Toggling emits no dedicated event — it is handled like + /// any other edit: the view emits a request and the app flips the flag in + /// its model. + pub fn enable_toggle_hit(&self, position: Point) -> bool { + position.x.0 >= DEFAULT_NODE_WIDTH.0 - HEADER_HEIGHT.0 + && position.x.0 <= DEFAULT_NODE_WIDTH.0 + && position.y.0 >= 0.0 + && position.y.0 <= HEADER_HEIGHT.0 + } + + /// Paints the node card into the current window layer: header with title + /// and toggles, port dots tinted by data type (filled when connected, + /// hollow otherwise) with labels, selection outline, disabled dimming and + /// the compatible-port glow. `origin` is the card's screen-space top-left + /// corner; all geometry within the card is node-local. + pub(crate) fn paint(&self, origin: Point, window: &mut Window, cx: &mut App) { + let colors = cx.default_colors().clone(); + let bounds = Bounds::new(origin, size(DEFAULT_NODE_WIDTH, self.height())); + + // Compatible-port glow: a slightly inflated rect behind the card while + // a wire drag offers at least one valid drop target on this node. + if self.visual.has_compatible_port { + let glow = Bounds::new( + point(origin.x - px(2.0), origin.y - px(2.0)), + size(DEFAULT_NODE_WIDTH + px(4.0), self.height() + px(4.0)), + ); + window.paint_quad(fill(glow, Hsla::from(colors.selected).opacity(0.2))); + } + + // Card body. + window.paint_quad(fill(bounds, colors.background)); + + // Border quad: transparent fill, themed border (accent when selected). + window.paint_quad(PaintQuad { + bounds, + corner_radii: Corners::all(px(4.0)), + background: hsla(0.0, 0.0, 0.0, 0.0).into(), + border_widths: Edges::all(if self.visual.selected { px(1.5) } else { px(1.0) }), + border_color: if self.visual.selected { + Hsla::from(colors.selected) + } else { + Hsla::from(colors.border) + }, + border_style: BorderStyle::Solid, + }); + + // Header bar with the node's accent color (or the theme container + // color), containing the title and the collapse/enable toggles. + let header_bounds = Bounds::new(origin, size(DEFAULT_NODE_WIDTH, HEADER_HEIGHT)); + window.paint_quad(fill( + header_bounds, + self.header_color.unwrap_or(Hsla::from(colors.container)), + )); + + let text_y = bounds.top() + px((HEADER_HEIGHT.0 - 12.0) / 2.0); + paint_text( + window, + cx, + &self.title, + px(12.0), + point(bounds.left() + px(28.0), text_y), + px(12.0), + Hsla::from(colors.text), + TextAlign::Left, + None, + ); + + // Collapse toggle: "▶" when collapsed (click to expand), "▼" when + // expanded (click to collapse). + paint_text( + window, + cx, + if self.collapsed { "▶" } else { "▼" }, + px(10.0), + point(bounds.left() + px(10.0), text_y), + px(12.0), + Hsla::from(colors.text), + TextAlign::Left, + None, + ); + + // Enable toggle glyph (power symbol) on the right edge of the header. + paint_text( + window, + cx, + "⏻", + px(12.0), + point(bounds.right() - px(20.0), text_y), + px(12.0), + Hsla::from(colors.text), + TextAlign::Left, + None, + ); + + // Port dots and labels, only when the node is expanded. + if !self.collapsed { + let label_font_size = px(11.0); + let label_height = px(12.0); + for port in self.inputs.iter().chain(self.outputs.iter()) { + let Some(anchor) = self.port_anchor(port.id) else { + continue; + }; + let dot_bounds = Bounds::new( + point(anchor.x - PORT_DOT_RADIUS, anchor.y - PORT_DOT_RADIUS), + size(PORT_DOT_RADIUS * 2.0, PORT_DOT_RADIUS * 2.0), + ); + if port.connected { + // Connected dots are solid tinted circles. + window.paint_quad(PaintQuad { + bounds: dot_bounds, + corner_radii: Corners::all(PORT_DOT_RADIUS), + background: port.color.into(), + border_widths: Edges::all(px(0.0)), + border_color: hsla(0.0, 0.0, 0.0, 0.0), + border_style: BorderStyle::Solid, + }); + } else { + // Unconnected dots are hollow: a tinted ring around the + // card's background color. + window.paint_quad(fill(dot_bounds, port.color)); + let inner = Bounds::new( + point(anchor.x - PORT_DOT_RADIUS + px(2.0), anchor.y - PORT_DOT_RADIUS + px(2.0)), + size(PORT_DOT_RADIUS * 2.0 - px(4.0), PORT_DOT_RADIUS * 2.0 - px(4.0)), + ); + window.paint_quad(fill(inner, colors.background)); + } + + if !port.label.is_empty() { + if self.inputs.iter().any(|p| p.id == port.id) { + // Input labels: left-aligned, starting right of the dot. + paint_text( + window, + cx, + &port.label, + label_font_size, + point(anchor.x + PORT_DOT_RADIUS + px(6.0), anchor.y - px(6.0)), + label_height, + Hsla::from(colors.text), + TextAlign::Left, + None, + ); + } else { + // Output labels: right-aligned so they end just left of + // the dot. The box origin sits `align_width` left of the + // dot; the label's right edge lands at the box right. + let align_width = px(100.0); + paint_text( + window, + cx, + &port.label, + label_font_size, + point( + anchor.x - PORT_DOT_RADIUS - px(6.0) - align_width, + anchor.y - px(6.0), + ), + label_height, + Hsla::from(colors.text), + TextAlign::Right, + Some(align_width), + ); + } + } + } + } + + // Disabled nodes are dimmed with a dark overlay. + if !self.enabled { + window.paint_quad(fill(bounds, hsla(0.0, 0.0, 0.0, 0.5))); + } + } +} + +/// Shapes and paints a single text line at `origin` (the top-left of the +/// line box) with the given font size, line height, alignment and color. +fn paint_text( + window: &mut Window, + cx: &mut App, + text: &str, + font_size: Pixels, + origin: Point, + line_height: Pixels, + color: Hsla, + align: TextAlign, + align_width: Option, +) { + let line = window.text_system().shape_line( + SharedString::from(text), + font_size, + &[TextRun { + len: text.len(), + font: Font::default(), + color, + background_color: None, + underline: None, + strikethrough: None, + letter_spacing: None, + }], + None, + ); + let _ = line.paint(origin, line_height, align, align_width, window, cx); +} diff --git a/crates/gpui/src/node_graph/state.rs b/crates/gpui/src/node_graph/state.rs new file mode 100644 index 0000000000..843adc0146 --- /dev/null +++ b/crates/gpui/src/node_graph/state.rs @@ -0,0 +1,245 @@ +//! Viewport and selection state for the node-graph editor. +//! +//! [`GraphViewState`] owns everything about *how* the graph is looked at — +//! pan offset, zoom, selection, marquee — and nothing about the graph itself. +//! The coordinate transforms here are pure and implemented; they are the +//! single source of truth for the graph-space ↔ screen-space mapping used by +//! node rendering, wire anchoring and hit testing alike. + +use std::collections::BTreeSet; + +use crate::{Pixels, Point, point}; + +use crate::node_graph::NodeId; + +/// Minimum zoom factor accepted by [`GraphViewState::set_zoom`] and +/// [`GraphViewState::zoom_at`]: the graph is shown at 10% scale. +pub const MIN_ZOOM: f32 = 0.1; + +/// Maximum zoom factor accepted by [`GraphViewState::set_zoom`] and +/// [`GraphViewState::zoom_at`]: the graph is shown at 400% scale. +pub const MAX_ZOOM: f32 = 4.0; + +/// Pan/zoom viewport and selection state of a +/// [`NodeGraphView`](crate::node_graph::NodeGraphView). +/// +/// # Coordinate spaces +/// +/// - *Graph space* is the unbounded document coordinate system that +/// [`NodeData::position`](crate::node_graph::NodeData::position) returns. +/// - *Screen space* is the element-local pixel coordinate system used for +/// painting and hit testing, with the origin at the top-left corner of the +/// graph view. +/// +/// The mapping is an affine transform with no rotation: +/// +/// ```text +/// screen = graph * zoom + offset +/// graph = (screen - offset) / zoom +/// ``` +#[derive(Clone, Debug)] +pub struct GraphViewState { + /// Pan offset in screen space: the screen-space position of the graph + /// origin. Positive values move the graph content down-right. + offset: Point, + /// Zoom factor, always within [`MIN_ZOOM`]..=[`MAX_ZOOM`]. `1.0` is 100%. + zoom: f32, + /// The currently selected nodes. Kept sorted (B-Tree) so that + /// `SelectionChanged` events are deterministic and cheap to diff. + selection: BTreeSet, + /// An in-progress marquee (rubber-band) selection rectangle, in screen + /// space, if the user is currently dragging one. + marquee: Option, +} + +impl Default for GraphViewState { + fn default() -> Self { + Self { + offset: point(Pixels::ZERO, Pixels::ZERO), + zoom: 1.0, + selection: BTreeSet::new(), + marquee: None, + } + } +} + +impl GraphViewState { + /// Creates a fresh view state: no pan, 100% zoom, empty selection. + pub fn new() -> Self { + Self::default() + } + + /// Returns the current pan offset (the screen-space position of the + /// graph origin). + pub fn offset(&self) -> Point { + self.offset + } + + /// Sets the pan offset directly. No clamping is applied — the graph is + /// unbounded. + pub fn set_offset(&mut self, offset: Point) { + self.offset = offset; + } + + /// Pans the view by a screen-space delta (typically a drag delta). + pub fn pan_by(&mut self, delta: Point) { + self.offset = self.offset + delta; + } + + /// Returns the current zoom factor, guaranteed within + /// [`MIN_ZOOM`]..=[`MAX_ZOOM`]. + pub fn zoom(&self) -> f32 { + self.zoom + } + + /// Sets the zoom factor, clamped to [`MIN_ZOOM`]..=[`MAX_ZOOM`]. + /// + /// Unlike [`zoom_at`](Self::zoom_at) this does not preserve any anchor + /// point; the graph origin stays put and content scales around it. + pub fn set_zoom(&mut self, zoom: f32) { + self.zoom = zoom.clamp(MIN_ZOOM, MAX_ZOOM); + } + + /// Zooms by `factor` (e.g. `1.1` per scroll step) while keeping the + /// graph point under `anchor` (a screen-space position, usually the + /// cursor) stationary on screen. + /// + /// # Math contract + /// + /// Let `z` be the old zoom and `z' = clamp(z * factor, MIN_ZOOM, + /// MAX_ZOOM)` the new one. The offset is adjusted so that + /// `graph_to_screen(g)` is identical before and after for the graph point + /// `g = screen_to_graph(anchor)`: + /// + /// ```text + /// offset' = anchor - (anchor - offset) * (z' / z) + /// ``` + /// + /// When the zoom is clamped (already at the min/max), `z' == z` and the + /// offset is left untouched — the call is then a no-op. + pub fn zoom_at(&mut self, anchor: Point, factor: f32) { + let new_zoom = (self.zoom * factor).clamp(MIN_ZOOM, MAX_ZOOM); + if new_zoom == self.zoom { + return; + } + let scale = new_zoom / self.zoom; + self.offset = point( + anchor.x - (anchor.x - self.offset.x) * scale, + anchor.y - (anchor.y - self.offset.y) * scale, + ); + self.zoom = new_zoom; + } + + /// Maps a graph-space (document) point to screen space: + /// `screen = graph * zoom + offset`. + pub fn graph_to_screen(&self, graph: Point) -> Point { + point( + graph.x * self.zoom + self.offset.x, + graph.y * self.zoom + self.offset.y, + ) + } + + /// Maps a screen-space point to graph space: + /// `graph = (screen - offset) / zoom`. This is the exact inverse of + /// [`graph_to_screen`](Self::graph_to_screen). + pub fn screen_to_graph(&self, screen: Point) -> Point { + point( + (screen.x - self.offset.x) / self.zoom, + (screen.y - self.offset.y) / self.zoom, + ) + } + + /// Returns the set of currently selected nodes. + pub fn selection(&self) -> &BTreeSet { + &self.selection + } + + /// Returns whether the given node is currently selected. + pub fn is_selected(&self, node: NodeId) -> bool { + self.selection.contains(&node) + } + + /// Replaces the selection with exactly the given nodes. + /// + /// The view compares before/after and emits + /// [`NodeGraphEvent::SelectionChanged`](crate::node_graph::NodeGraphEvent::SelectionChanged) + /// when the set actually changed; calling this directly does not emit + /// events on its own. + pub fn set_selection(&mut self, nodes: impl IntoIterator) { + self.selection = nodes.into_iter().collect(); + } + + /// Adds `node` to the selection (shift-click semantics). + pub fn select(&mut self, node: NodeId) { + self.selection.insert(node); + } + + /// Removes `node` from the selection; returns whether it was selected. + pub fn deselect(&mut self, node: NodeId) -> bool { + self.selection.remove(&node) + } + + /// Toggles `node` in the selection (shift-click toggle semantics). + pub fn toggle_selection(&mut self, node: NodeId) { + if !self.deselect(node) { + self.select(node); + } + } + + /// Clears the selection. + pub fn clear_selection(&mut self) { + self.selection.clear(); + } + + /// Returns the in-progress marquee selection rectangle, if any. + pub fn marquee(&self) -> Option<&SelectionRect> { + self.marquee.as_ref() + } + + /// Begins a marquee selection anchored at the given screen-space point. + pub fn begin_marquee(&mut self, anchor: Point) { + self.marquee = Some(SelectionRect { + anchor, + current: anchor, + }); + } + + /// Updates the current corner of the in-progress marquee. Does nothing if + /// no marquee is in progress. + pub fn update_marquee(&mut self, current: Point) { + if let Some(marquee) = &mut self.marquee { + marquee.current = current; + } + } + + /// Ends the marquee and returns it, or `None` if none was in progress. + /// + /// The caller (the view) converts the rect to graph space and selects all + /// nodes intersecting it. + pub fn end_marquee(&mut self) -> Option { + self.marquee.take() + } +} + +/// A marquee (rubber-band) selection rectangle in screen space. +/// +/// The rectangle is defined by the point where the drag started and the +/// current cursor position; use [`normalized`](Self::normalized) to obtain a +/// well-ordered rect regardless of drag direction. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SelectionRect { + /// The screen-space point where the marquee drag started. + pub anchor: Point, + /// The current screen-space corner (usually the cursor position). + pub current: Point, +} + +impl SelectionRect { + /// Returns the axis-aligned rectangle with `min` as the top-left and + /// `max` as the bottom-right corner, independent of drag direction. + pub fn normalized(&self) -> (Point, Point) { + let min = point(self.anchor.x.min(self.current.x), self.anchor.y.min(self.current.y)); + let max = point(self.anchor.x.max(self.current.x), self.anchor.y.max(self.current.y)); + (min, max) + } +} diff --git a/crates/gpui/src/node_graph/wire.rs b/crates/gpui/src/node_graph/wire.rs new file mode 100644 index 0000000000..a15061a5a5 --- /dev/null +++ b/crates/gpui/src/node_graph/wire.rs @@ -0,0 +1,302 @@ +//! Wire (edge) rendering for the node graph. +//! +//! Wires are cubic bezier curves drawn with [`PathBuilder`](crate::PathBuilder), anchored at port +//! dot centers (see [`NodeElement::port_anchor`]). This module also covers +//! the transient "ghost" wire shown while the user drags a connection. +//! +//! [`NodeElement::port_anchor`]: crate::node_graph::NodeElement::port_anchor + +use crate::{Hsla, Path, PathBuilder, Pixels, Point, Window, hsla, point, px}; + +use crate::node_graph::{EdgeId, PortDataType}; + +/// Horizontal distance the bezier control points are pushed out from the +/// endpoints. Larger values make wires leave ports more "horizontally" and +/// sag less. Scaled by zoom so screen-space curvature stays constant. +pub const WIRE_CURVATURE: Pixels = px(60.0); + +/// The visual state of a wire, chosen by the view per frame. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum WireVisualState { + /// A regular, idle wire. + #[default] + Normal, + /// The wire is hovered (slightly brightened; click targets become + /// discoverable). + Hovered, + /// The wire is part of the selection (accent color, thicker stroke). + Selected, + /// The ghost wire of an in-progress drag whose current hover target (if + /// any) was rejected by + /// [`NodeGraphDataSource::can_connect`](crate::node_graph::NodeGraphDataSource::can_connect). + /// Drawn dashed/red to signal "dropping here will not connect". + InvalidDrag, +} + +/// A fully-resolved wire ready to paint: both endpoints are already computed +/// in screen space. +/// +/// Built per frame by [`NodeGraphView`](crate::node_graph::NodeGraphView) +/// from an [`EdgeData`](crate::node_graph::EdgeData) plus the port anchors of +/// the two endpoint nodes. +pub struct Wire { + edge: EdgeId, + from: Point, + to: Point, + color: Hsla, + state: WireVisualState, +} + +impl Wire { + /// Creates a wire between two screen-space anchor points, tinted with the + /// connection's data-type color. + pub fn new( + edge: EdgeId, + from: Point, + to: Point, + data_type: &PortDataType, + state: WireVisualState, + ) -> Self { + Self { + edge, + from, + to, + color: data_type.color, + state, + } + } + + /// Returns the edge this wire represents. + pub fn edge(&self) -> EdgeId { + self.edge + } + + /// Builds the cubic bezier [`crate::Path`] for a wire from `from` to + /// `to`, leaving both endpoints horizontally: the control points are + /// placed `WIRE_CURVATURE * zoom` to the right of `from` and to the left + /// of `to`. Shared by regular wires and the ghost wire so both have + /// identical curvature behavior. + /// + /// Returns `None` when the path cannot be built (degenerate input); the + /// caller simply skips painting that frame. + pub fn build_path( + from: Point, + to: Point, + zoom: f32, + ) -> Option> { + wire_path(from, to, zoom, px(2.0), None) + } + + /// Paints the wire with [`Window::paint_path`], applying the stroke width + /// and color adjustments implied by its [`WireVisualState`]. + pub fn paint(&self, window: &mut Window, zoom: f32) { + let (color, width, dash) = match self.state { + WireVisualState::Normal => (self.color.opacity(0.6), px(2.0), None), + WireVisualState::Hovered => (self.color, px(2.5), None), + WireVisualState::Selected => (self.color, px(3.0), None), + WireVisualState::InvalidDrag => ( + hsla(0.0, 0.85, 0.55, 1.0), + px(2.0), + Some([px(6.0), px(4.0)]), + ), + }; + if let Some(path) = wire_path( + self.from, + self.to, + zoom, + width, + dash.as_ref().map(|dash| &dash[..]), + ) { + window.paint_path(path, color); + } + } +} + +/// Builds the cubic bezier path for a wire stroke with the given width and +/// optional dash array. This is the single place the wire geometry lives; +/// [`Wire::build_path`] and the ghost wire both delegate to it so every wire +/// shares the same curvature behavior. +pub(crate) fn wire_path( + from: Point, + to: Point, + zoom: f32, + width: Pixels, + dash: Option<&[Pixels]>, +) -> Option> { + let mut builder = PathBuilder::stroke(width); + if let Some(dash) = dash { + builder = builder.dash_array(dash); + } + let curvature = WIRE_CURVATURE * zoom; + builder.move_to(from); + builder.cubic_bezier_to( + to, + point(from.x + curvature, from.y), + point(to.x - curvature, to.y), + ); + builder.build().ok() +} + +/// The transient "ghost" wire shown while the user drags a connection from a +/// port. +/// +/// One end stays fixed at the source port's anchor; the other follows the +/// cursor. When the cursor hovers a port, the free end snaps to that port's +/// anchor and the ghost switches between [`WireVisualState::Hovered`] and +/// [`WireVisualState::InvalidDrag`] depending on +/// [`NodeGraphDataSource::can_connect`](crate::node_graph::NodeGraphDataSource::can_connect). +pub struct GhostWire { + /// The screen-space anchor of the port the drag started from. + source: Point, + /// The current screen-space position of the free end (cursor, or a + /// snapped hover-target anchor). + free_end: Point, + /// Data type of the source port; tints the ghost. + color: Hsla, + /// Whether the current hover target is a valid drop (drives the + /// [`WireVisualState::InvalidDrag`] styling). + target_valid: bool, + /// Whether the drag started from an output port. When `false` (drag + /// started from an input), `source`/`free_end` are swapped when building + /// the path so the bezier tangents still point the right way. + from_output: bool, +} + +impl GhostWire { + /// Creates a ghost wire anchored at `source` (screen space), tinted with + /// the source port's data type. `from_output` records the drag direction; + /// see the field docs. + pub fn new( + source: Point, + data_type: &PortDataType, + from_output: bool, + ) -> Self { + Self { + source, + free_end: source, + color: data_type.color, + target_valid: false, + from_output, + } + } + + /// Returns the screen-space anchor of the port the drag started from. + pub(crate) fn source(&self) -> Point { + self.source + } + + /// Returns the current screen-space position of the free end. + pub(crate) fn free_end(&self) -> Point { + self.free_end + } + + /// Returns the data-type color tinting the ghost. + pub(crate) fn color(&self) -> Hsla { + self.color + } + + /// Returns whether the currently hovered port is a valid drop target. + pub(crate) fn is_target_valid(&self) -> bool { + self.target_valid + } + + /// Returns whether the drag started from an output port. + pub(crate) fn is_from_output(&self) -> bool { + self.from_output + } + + /// Moves the free end to `cursor` (screen space) and records whether the + /// currently hovered port — if any — is a valid drop target. Pass + /// `snapped = Some(anchor)` instead of the raw cursor when the cursor is + /// inside a port's grab radius, so the ghost visually snaps onto it. + pub fn update( + &mut self, + cursor: Point, + snapped: Option>, + target_valid: bool, + ) { + self.free_end = snapped.unwrap_or(cursor); + self.target_valid = target_valid; + } + + /// Paints the ghost wire using the same bezier shape as [`Wire`], with + /// its state styling. + pub fn paint(&self, window: &mut Window, zoom: f32) { + let (from, to) = if self.from_output { + (self.source, self.free_end) + } else { + (self.free_end, self.source) + }; + paint_ghost(window, from, to, self.color, self.target_valid, zoom); + } +} + +/// Paints the ghost wire between two screen-space anchors. Valid drops are +/// drawn solid with the data-type tint; invalid drops (hovering an +/// incompatible port) are drawn dashed/red to signal that dropping will not +/// connect. Used both by [`GhostWire::paint`] and by the view's frame +/// snapshot. +pub(crate) fn paint_ghost( + window: &mut Window, + from: Point, + to: Point, + color: Hsla, + target_valid: bool, + zoom: f32, +) { + if target_valid { + if let Some(path) = wire_path(from, to, zoom, px(2.5), None) { + window.paint_path(path, color); + } + } else if let Some(path) = wire_path(from, to, zoom, px(2.0), Some(&[px(6.0), px(4.0)])) { + window.paint_path(path, hsla(0.0, 0.85, 0.55, 1.0)); + } +} + +/// Pixels per second the [`FlowAnimation`] dash phase advances while active. +pub const FLOW_SPEED: f32 = 60.0; + +/// Optional signal-flow animation hook. +/// +/// A subtle animated dash offset travelling along each wire from output to +/// input while playback is running, to visualize which connections are +/// "live". The view calls [`FlowAnimation::advance`] each frame during +/// playback and passes the resulting offset to the wire stroke's dash phase. +/// +/// The phase advances at [`FLOW_SPEED`] pixels per second; wires fall back to +/// their static style while inactive. +#[derive(Clone, Debug, Default)] +pub struct FlowAnimation { + /// Current dash phase in pixels, monotonically increasing while active. + phase: Pixels, + /// Whether the animation is currently running (e.g. during playback). + active: bool, +} + +impl FlowAnimation { + /// Starts the flow animation (e.g. when playback begins), resetting the + /// phase to zero. + pub fn start(&mut self) { + self.active = true; + self.phase = px(0.0); + } + + /// Stops the flow animation; wires fall back to their static style. + pub fn stop(&mut self) { + self.active = false; + } + + /// Advances the phase by one frame. `dt` is the elapsed frame time in + /// seconds; flow speed is a fixed px/s constant. No-op while inactive. + pub fn advance(&mut self, dt: f32) { + if self.active { + self.phase += px(FLOW_SPEED * dt); + } + } + + /// Returns the current dash phase to apply to wire strokes, or `None` + /// while inactive. + pub fn phase(&self) -> Option { + self.active.then_some(self.phase) + } +} diff --git a/crates/gpui/src/timeline/clip.rs b/crates/gpui/src/timeline/clip.rs new file mode 100644 index 0000000000..ee52238d92 --- /dev/null +++ b/crates/gpui/src/timeline/clip.rs @@ -0,0 +1,347 @@ +//! 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, + ) { + // 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, + ) { + // 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, + out_transition: Option, + content: ClipContent, + decorator: Arc>, +} + +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, + out_transition: Option, + decorator: Arc>, + ) -> 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, + out_transition: Option, + content: ClipContent, + decorator: Arc>, +} + +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), + ) + } +} diff --git a/crates/gpui/src/timeline/data.rs b/crates/gpui/src/timeline/data.rs new file mode 100644 index 0000000000..d32ffa00a4 --- /dev/null +++ b/crates/gpui/src/timeline/data.rs @@ -0,0 +1,256 @@ +//! Data-source traits: how the timeline widget reads your model. +//! +//! The timeline is **data-agnostic**: it owns no clips, tracks, or sequence +//! state. Instead the host application implements [`TimelineDataSource`] (and +//! the [`TrackData`] / [`ClipData`] traits it pulls in) over its own model, +//! and the widget re-reads through those traits every time the model entity +//! notifies. +//! +//! # Mapping to Oak engine concepts +//! +//! These traits are deliberately shaped like Oak's engine model so the +//! adapter is a thin, mechanical translation: +//! +//! | Timeline trait concept | Oak engine concept | +//! |-----------------------------------|-------------------------------------------| +//! | [`TrackData::clips`] | A track's block list | +//! | [`ClipData`] (normal clip) | `ClipBlock` | +//! | [`ClipData`] with empty range | `GapBlock` (never emitted — gaps are the absence of clips) | +//! | [`ClipData::in_transition`] etc. | `TransitionBlock` attached to a clip edge | +//! | [`TrackData::kind`] | Track type `k_video` / `k_audio` / `k_subtitle` | +//! | [`TrackData::is_locked`] etc. | Track lock / mute / solo / show flags | +//! | [`ClipData::media_in`] | Clip `media_in` (source offset) | +//! | [`ClipData::linked_ids`] | Linked clips (e.g. audio+video from one source) | +//! | [`TimelineDataSource::frame_rate`] | Per-sequence frame rate (e.g. 30000/1001) | +//! +//! # Consistency requirements +//! +//! The widget assumes, but cannot enforce, that within a single read: +//! +//! * [`ClipId`]s are unique across the whole data source and stable across +//! frames (they key the selection set and drag state). +//! * Clips within one track do not overlap and are returned in ascending +//! frame order. +//! * Values never change except as observed between `cx.notify()` calls — +//! the widget may cache layout between notifications. + +use crate::{Hsla, Pixels, SharedString}; + +use super::time::{Frame, FrameRange, FrameRate}; + +/// A stable, unique identifier for a clip within a [`TimelineDataSource`]. +/// +/// The widget treats these as opaque: they key the selection set, appear in +/// edit-request events, and are passed back to [`ClipData`] providers. The +/// host application chooses the mapping (Oak will use its engine's clip +/// pointers/UUIDs hashed down, or a generational index). +/// +/// Ordered so the selection set can be a `BTreeSet`. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ClipId(pub u64); + +/// The kind of content a track holds. +/// +/// Maps directly onto Oak's track types (`k_video`, `k_audio`, +/// `k_subtitle`). The kind drives default track colors, which toggle buttons +/// the header shows (audio tracks get *mute*, video tracks get *show*), and +/// which clip decorations (waveform vs. thumbnails) are offered. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TrackKind { + /// Video track. Stacks visually above/below siblings by compositing + /// order; upper tracks occlude lower ones. + #[default] + Video, + /// Audio track. Contributes to the mix; subject to mute/solo. + Audio, + /// Subtitle / caption track. + Subtitle, +} + +/// A single clip on a track. +/// +/// Corresponds to Oak's `ClipBlock`. All frames are in **sequence time** +/// (frames on the timeline), except [`ClipData::media_in`], which is in +/// **source media time**. +/// +/// Implementations must be cheap to call repeatedly during paint; anything +/// expensive (thumbnails, waveforms) belongs behind the +/// [`ClipDecorator`](super::ClipDecorator) cache hooks, not here. +pub trait ClipData { + /// The clip's stable, unique [`ClipId`]. See its docs for the stability + /// requirements. + fn id(&self) -> ClipId; + + /// The clip's occupied range in sequence time, `[start, end)`. + /// + /// Must have non-zero length for a real clip. Gaps between clips are not + /// represented (Oak's `GapBlock` is implicit here). + fn range(&self) -> FrameRange; + + /// The offset into the source media, in frames, at which this clip + /// starts playing. + /// + /// A clip created from frame 100 of a source file reports `Frame(100)`. + /// Trimming the clip's left edge by `n` frames increases this by `n`. + /// The widget displays this nowhere directly but forwards it in trim + /// requests' docs and uses it for thumbnail/waveform alignment via the + /// decorator hooks. + fn media_in(&self) -> Frame; + + /// Short label shown on the clip body (typically the source file name). + fn label(&self) -> SharedString; + + /// Base color of the clip body. The widget derives hover/selected/ + /// disabled shades from it. `None` falls back to the track-kind default. + fn color(&self) -> Option { + None + } + + /// Clips that must move and trim together with this one. + /// + /// This is Oak's *linked clips* concept: audio and video clips recorded + /// from the same source are linked, so trimming the video's head trims + /// the audio identically. The widget expands every move/trim request to + /// cover the transitive link group before emitting it — see + /// [`TimelineEvent::ClipMoveRequested`](super::TimelineEvent::ClipMoveRequested). + /// + /// Must not contain `self.id()`. May be empty (the common case). + fn linked_ids(&self) -> Vec { + Vec::new() + } + + /// Duration of the clip's **in transition** (Oak: the `TransitionBlock` + /// attached to the clip's head), if any, in frames. + /// + /// Rendered as a wedge at the clip's left edge. The transition itself is + /// edited elsewhere; the timeline only displays it. + fn in_transition(&self) -> Option { + None + } + + /// Duration of the clip's **out transition** (Oak: the `TransitionBlock` + /// attached to the clip's tail), if any, in frames. + fn out_transition(&self) -> Option { + None + } + + /// Whether the clip is enabled (not disabled/bypassed). + /// + /// Disabled clips render dimmed and are skipped by snapping; the flag + /// itself is toggled through the app's engine, not the timeline. + fn is_enabled(&self) -> bool { + true + } +} + +/// A single track (row) of the timeline. +/// +/// Corresponds to an Oak track of one of the `k_video` / `k_audio` / +/// `k_subtitle` types. +pub trait TrackData { + /// The clip type carried by this track. + type Clip: ClipData; + + /// What kind of content this track holds. + fn kind(&self) -> TrackKind; + + /// Display name for the track header (e.g. `V1`, `Music`). + fn name(&self) -> SharedString; + + /// Whether the track is locked. Locked tracks render normally but reject + /// all edit gestures (no moves, no trims, no drops); the widget checks + /// this before emitting any edit request. + fn is_locked(&self) -> bool { + false + } + + /// Whether the track is muted (audio) — silenced in playback. + /// + /// Meaningful for [`TrackKind::Audio`]; the header only shows the mute + /// button there. + fn is_muted(&self) -> bool { + false + } + + /// Whether the track is soloed (audio) — all non-solo tracks are + /// temporarily silenced. + fn is_solo(&self) -> bool { + false + } + + /// Whether the track is visible (video) — Oak's *show* flag. + /// + /// Meaningful for [`TrackKind::Video`] and [`TrackKind::Subtitle`]. + fn is_visible(&self) -> bool { + true + } + + /// The track's row height in the clip area. + /// + /// This is view state that Oak persists per sequence; it changes via + /// [`TimelineEvent::TrackHeightChanged`](super::TimelineEvent::TrackHeightChanged) + /// and must be written back into the model there. + fn height(&self) -> Pixels; + + /// The clips on this track, in ascending frame order, non-overlapping. + /// + /// Returned as a slice so the widget can binary-search by frame. If your + /// model cannot produce a contiguous slice, collect into a buffer you + /// own and return that. + fn clips(&self) -> &[Self::Clip]; +} + +/// A marker on the sequence ruler (chapter marks, annotations). +#[derive(Debug, Clone, PartialEq)] +pub struct Marker { + /// Where the marker sits, in sequence frames. + pub frame: Frame, + /// Label shown in the marker tooltip / ruler. + pub label: SharedString, + /// Optional marker color; defaults to the theme's accent. + pub color: Option, +} + +/// The root data source the timeline widget reads from. +/// +/// Implement this on the model object your app already holds as an +/// [`Entity`](crate::Entity), and hand that entity to +/// [`TimelineView::new`](super::TimelineView::new). The widget observes the +/// entity and re-reads everything through this trait on `cx.notify()`. +/// +/// # Wiring into Oak +/// +/// In Oak, this trait is implemented on the facade over the current +/// sequence: `frame_rate` and `sequence_length` come from the sequence +/// header, `track` walks the sequence's track list, and every edit the +/// widget requests arrives as a [`TimelineEvent`](super::TimelineEvent) that +/// the facade turns into an undoable engine command. +pub trait TimelineDataSource: 'static { + /// The track type returned by [`TimelineDataSource::track`]. + type Track: TrackData; + + /// The sequence's frame rate (e.g. [`FrameRate::NTSC_2997`]). + /// + /// Assumed constant for the lifetime of the sequence; changing it + /// requires rebuilding the view. + fn frame_rate(&self) -> FrameRate; + + /// Total length of the sequence in frames — the position just past the + /// last frame of content. Playhead and scroll are clamped to this. + fn sequence_length(&self) -> Frame; + + /// Number of tracks. Indices are stable within a single notification + /// cycle. + fn track_count(&self) -> usize; + + /// The track at `index`, or `None` if out of range. + /// + /// Returns by value so implementations can hand out lightweight + /// snapshot views of their internal track storage. + fn track(&self, index: usize) -> Option; + + /// All sequence markers, in ascending frame order. + fn markers(&self) -> Vec { + Vec::new() + } +} diff --git a/crates/gpui/src/timeline/mod.rs b/crates/gpui/src/timeline/mod.rs new file mode 100644 index 0000000000..6f780c967d --- /dev/null +++ b/crates/gpui/src/timeline/mod.rs @@ -0,0 +1,76 @@ +//! Video-editing timeline widget: tracks, clips, ruler, playhead, snapping. +//! +//! This module provides the timeline at the heart of a non-linear video +//! editor (NLE): a ruler with timecode, stacked tracks of clips, a playhead, +//! marquee selection, drag-to-move and drag-to-trim gestures with snapping. +//! +//! # Architecture +//! +//! The widget is **data-agnostic**. It owns no sequence model; instead the +//! host application implements the traits in [`data`](crate::timeline::data) — [`TimelineDataSource`](crate::timeline::TimelineDataSource), +//! [`TrackData`](crate::timeline::TrackData), [`ClipData`](crate::timeline::ClipData) — over its own model and hands the view an +//! [`Entity`](crate::Entity) of that implementation. The view re-reads +//! everything through those traits whenever the entity notifies. +//! +//! **All edits are emitted as *requests*.** The widget never mutates the +//! model. Every gesture (move, trim, toggle, resize) ends by emitting a +//! [`TimelineEvent`](crate::timeline::TimelineEvent); the host applies the request through its engine and +//! undo stack — keeping the engine the single source of truth — and then +//! calls `cx.notify()` on the data-source entity so the view repaints. If +//! the engine rejects an edit, the host simply doesn't notify and the +//! gesture has no visible effect. +//! +//! **Time is frame-exact.** All positions and durations are integer +//! [`Frame`](crate::timeline::Frame)s at a rational [`FrameRate`](crate::timeline::FrameRate) (e.g. `30000/1001` for NTSC +//! 29.97). No float seconds appear in the public API, so repeated edits +//! cannot accumulate rounding drift; see [`time`](crate::timeline::time). +//! +//! # Wiring into Oak +//! +//! Oak (the Olive-fork video editor built on this crate) maps these pieces +//! onto its `oakengine` facade as follows: +//! +//! * [`TimelineDataSource`](crate::timeline::TimelineDataSource) is implemented on the facade's snapshot of the +//! current sequence: frame rate and length from the sequence header, +//! tracks from the track list (`k_video` / `k_audio` / `k_subtitle` → +//! [`TrackKind`](crate::timeline::TrackKind)), clips from each track's block list (`ClipBlock` → +//! [`ClipData`](crate::timeline::ClipData); `GapBlock` is implicit, `TransitionBlock` → +//! [`ClipData::in_transition`](crate::timeline::ClipData::in_transition) / [`ClipData::out_transition`](crate::timeline::ClipData::out_transition)). +//! * Each [`TimelineEvent`](crate::timeline::TimelineEvent) becomes an undoable engine command: +//! `ClipMoveRequested` → `move_clip`, `ClipTrimRequested` → `trim_clip` +//! (both expanded to the clip's linked group), header toggles → track +//! flag setters. After applying, the facade notifies the sequence entity. +//! * [`ClipDecorator`](crate::timeline::ClipDecorator) is implemented over Oak's codec frame cache and audio +//! peak cache to paint thumbnails and waveforms. +//! * [`PlayheadTicker`](crate::timeline::PlayheadTicker) is driven by the engine's playback state; seek +//! requests flow back as non-undoable `set_playhead` calls. +//! +//! See `examples/learn/timeline.rs` for a minimal working sketch with a mock +//! data source. +//! +//! # Status +//! +//! Implemented: the pure time/state arithmetic ([`time`](crate::timeline::time), +//! [`TimelineState`](crate::timeline::TimelineState)) is unit-tested, and +//! [`TimelineView`](crate::timeline::TimelineView) composes the ruler, track +//! headers, clips and playhead into the layout documented above, wiring up +//! seek, clip move/trim with snapping, marquee selection, zoom and +//! track-height resize. + +pub mod clip; +pub mod data; +pub mod playhead; +pub mod ruler; +pub mod state; +pub mod time; +pub mod track_header; +pub mod timeline_view; + +pub use clip::*; +pub use data::*; +pub use playhead::*; +pub use ruler::*; +pub use state::*; +pub use time::*; +pub use track_header::*; +pub use timeline_view::*; diff --git a/crates/gpui/src/timeline/playhead.rs b/crates/gpui/src/timeline/playhead.rs new file mode 100644 index 0000000000..320cb69cb4 --- /dev/null +++ b/crates/gpui/src/timeline/playhead.rs @@ -0,0 +1,159 @@ +//! The playhead: its on-screen element and the playback ticker. +//! +//! [`PlayheadElement`] draws the current-position line across ruler and +//! tracks plus a grab handle on the ruler. [`PlayheadTicker`] advances the +//! playhead during playback by converting wall-clock elapsed time into +//! frames at the sequence rate — the only sanctioned wall-clock→frames +//! conversion in the widget, going through +//! [`seconds_to_frame`](super::seconds_to_frame). + +use std::time::Instant; + +use crate::{ + App, Bounds, Hsla, PathBuilder, Pixels, Window, canvas, fill, point, px, size, prelude::*, +}; + +use super::time::{Frame, FrameRate}; + +/// The vertical playhead line and its ruler grab handle. +/// +/// Geometry is supplied pre-computed: `x` is the playhead's screen position +/// ([`TimelineState::point_at_frame`](super::TimelineState::point_at_frame)), +/// so the element itself does no time math. +#[derive(IntoElement)] +pub struct PlayheadElement { + x: Pixels, + color: Hsla, +} + +impl PlayheadElement { + /// Creates the playhead element at screen position `x`. + pub fn new(x: Pixels, color: Hsla) -> Self { + PlayheadElement { x, color } + } + + /// Screen x of the line. + pub fn x(&self) -> Pixels { + self.x + } +} + +impl RenderOnce for PlayheadElement { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let x = self.x; + let color = self.color; + + canvas( + move |_bounds, _window, _cx| (), + move |bounds, (), window, cx| { + let _ = cx; + let x = bounds.left() + x; + + // The playhead line, full height of the element. The width is + // 1 px and the line is centered on `x`. + window.paint_quad(fill( + Bounds { + origin: point(x, bounds.top()), + size: size(px(1.0), bounds.size.height), + }, + color, + )); + + // A small downward-pointing grab handle at the top of the + // line, marking where the user can drag to seek. + let handle_height = 8.0; + let mut path = PathBuilder::fill(); + path.move_to(point(px(x.0 - 5.0), bounds.top())); + path.line_to(point(px(x.0 + 6.0), bounds.top())); + path.line_to(point(px(x.0 + 0.5), bounds.top() + px(handle_height))); + path.close(); + let path = path + .build() + .expect("playhead handle path always builds"); + window.paint_path(path, color); + }, + ) + } +} + +/// Drives playhead advancement while the sequence is playing. +/// +/// # Drift-free accumulation +/// +/// The ticker records the wall-clock [`Instant`] and the exact [`Frame`] at +/// which playback started, and on every animation frame computes: +/// +/// ```text +/// playhead = start_frame + seconds_to_frame(rate, now - start_instant) +/// ``` +/// +/// It never adds a per-tick delta to the previous playhead — that would +/// accumulate rounding error and drift against the audio clock. Because both +/// anchors are fixed, total error stays under half a frame no matter how +/// long playback runs. +/// +/// The ticker is driven by [`Window::request_animation_frame`](crate::Window::request_animation_frame) and stops +/// re-scheduling itself when [`PlayheadTicker::stop`] is called or the view +/// is released. Every computed position goes through +/// [`TimelineState::set_playhead`](super::TimelineState::set_playhead) +/// (clamped to the sequence) and emits [`TimelineEvent::PlayheadChanged`](super::TimelineEvent::PlayheadChanged) +/// when it changes. +pub struct PlayheadTicker { + rate: FrameRate, + start_frame: Frame, + start_instant: Option, +} + +impl PlayheadTicker { + /// Creates a stopped ticker for sequences running at `rate`. + pub fn new(rate: FrameRate) -> Self { + PlayheadTicker { + rate, + start_frame: Frame::ZERO, + start_instant: None, + } + } + + /// Whether playback is currently running. + pub fn is_playing(&self) -> bool { + self.start_instant.is_some() + } + + /// Starts playback from `start_frame`. + /// + /// Re-anchors both the wall-clock and frame anchors (see the type docs), + /// so pausing and resuming never accumulates error. If already playing, + /// this restarts the anchor — useful for jog/shuttle seeks mid-playback. + /// + /// The owning [`TimelineView`](super::TimelineView) is responsible for + /// scheduling the animation-frame loop + /// ([`Window::request_animation_frame`](crate::Window::request_animation_frame)) and polling + /// [`Self::current_frame`] each tick, pushing the result through + /// [`TimelineState::set_playhead`](super::TimelineState::set_playhead) + /// and emitting [`TimelineEvent::PlayheadChanged`](super::TimelineEvent::PlayheadChanged) on change. + pub fn start(&mut self, start_frame: Frame) { + self.start_frame = start_frame; + self.start_instant = Some(Instant::now()); + } + + /// The playhead position right now, per the drift-free formula in the + /// type docs. When stopped, returns the last anchor frame. + pub fn current_frame(&self) -> Frame { + match self.start_instant { + Some(instant) => { + self.start_frame + + super::time::seconds_to_frame(self.rate, instant.elapsed().as_secs_f64()) + } + None => self.start_frame, + } + } + + /// Stops playback. Returns the frame playback stopped at, so the caller + /// can make it the new playhead rest position. + pub fn stop(&mut self) -> Frame { + let frame = self.current_frame(); + self.start_instant = None; + self.start_frame = frame; + frame + } +} diff --git a/crates/gpui/src/timeline/ruler.rs b/crates/gpui/src/timeline/ruler.rs new file mode 100644 index 0000000000..9414ec5c7d --- /dev/null +++ b/crates/gpui/src/timeline/ruler.rs @@ -0,0 +1,348 @@ +//! The ruler: timecode ticks, labels, work-area band, and seek handling. +//! +//! [`TimelineRuler`] is a custom element (built on [`canvas`](crate::canvas)) +//! painted across the top of [`TimelineView`](super::TimelineView). It shares +//! the clip area's frame↔pixel mapping via [`TimelineState`], so ticks and +//! clips stay aligned at every zoom level. +//! +//! # Adaptive tick spacing +//! +//! The ruler picks the smallest "nice" step whose on-screen spacing is at +//! least [`TimelineRuler::MIN_TICK_SPACING`], walking this ladder (in frames, at the +//! sequence's [`FrameRate`]): +//! +//! `1, 2, 5, 10, 30, 1 s, 2 s, 5 s, 10 s, 30 s, 1 min, 5 min, 10 min, …` +//! +//! so labels never overlap whether you're zoomed to a single frame or to a +//! two-hour sequence. Major ticks get a +//! [`format_timecode`](super::format_timecode) label; minor ticks are drawn +//! shorter and unlabeled. + +use crate::{ + App, Bounds, Font, SharedString, TextAlign, TextRun, Window, canvas, fill, hsla, point, px, + size, prelude::*, +}; + +use super::{ + state::TimelineState, + time::{Frame, FrameRange, FrameRate, TimeDisplay}, +}; + +/// The sequence ruler rendered above the tracks. +/// +/// Cheap to construct — all fields are plain data copied out of the view +/// each frame. The element is purely visual: it paints ticks, labels and the +/// work-area band on a [`canvas`](crate::canvas). Hit-testing and seeking +/// (mouse-down = move the playhead, dragging the work-area band edges) are +/// wired up by [`TimelineView`](super::TimelineView)'s interactive wrapper, +/// because elements cannot emit events. +#[derive(IntoElement)] +pub struct TimelineRuler { + state: TimelineState, + frame_rate: FrameRate, + sequence_length: Frame, + display: TimeDisplay, +} + +impl TimelineRuler { + /// Minimum on-screen distance between two labeled ticks, in pixels. The + /// adaptive step ladder never picks a step smaller than this. + pub const MIN_TICK_SPACING: f32 = 80.0; + + /// Creates a ruler element snapshotting the given view state. + /// + /// * `state` — supplies zoom and horizontal scroll; the ruler shares the + /// clip area's mapping exactly. + /// * `frame_rate` / `sequence_length` — from the + /// [`TimelineDataSource`](super::TimelineDataSource). + pub fn new( + state: TimelineState, + frame_rate: FrameRate, + sequence_length: Frame, + ) -> Self { + TimelineRuler { + state, + frame_rate, + sequence_length, + display: TimeDisplay::default(), + } + } + + /// Builder: how to label major ticks. Defaults to + /// [`TimeDisplay::Timecode`]. + pub fn time_display(mut self, display: TimeDisplay) -> Self { + self.display = display; + self + } + + /// The tick step (in frames) the ruler would choose at the given zoom. + /// + /// Exposed for tests and for snapping the playhead-drag indicator to the + /// visible grid. Must return a value from the "nice step" ladder + /// described in the module docs such that + /// `step * zoom >= Self::MIN_TICK_SPACING` for all but the coarsest + /// step. + pub fn tick_step(&self, zoom: f32) -> Frame { + // Nominal (integer) frames per second, matching the non-drop-frame + // convention used by `format_timecode` (NTSC 29.97 labels in 30 fps). + let fps = self.frame_rate.as_f64().round() as i64; + // The "nice step" ladder, finest to coarsest, in frames: + // 1, 2, 5, 10, 30 (frames), 1/2/5/10/30 seconds, 1/5/10/30 minutes, + // 1/2 hours. + let ladder = [ + 1, + 2, + 5, + 10, + 30, + fps, + 2 * fps, + 5 * fps, + 10 * fps, + 30 * fps, + 60 * fps, + 300 * fps, + 600 * fps, + 1800 * fps, + 3600 * fps, + 7200 * fps, + ]; + for step in ladder { + if step as f32 * zoom >= Self::MIN_TICK_SPACING { + return Frame(step); + } + } + // Coarsest step; the spacing contract allows the last ladder entry to + // fall short of `MIN_TICK_SPACING`. + Frame(7200 * fps) + } + + /// The work-area band to paint, if any. + pub fn work_area(&self) -> Option { + self.state.work_area + } + + /// Label text for a major tick at `frame`, per + /// [`Self::time_display`]. + pub fn tick_label(&self, frame: Frame) -> SharedString { + super::time::format_timecode(frame, self.frame_rate, self.display).into() + } +} + +/// A single ruler tick computed during canvas prepaint. +struct RulerTick { + /// Local x within the ruler (relative to its left edge, which aligns + /// with the clip area's left edge). + x: f32, + /// Whether this is a major (tall, labeled) tick. + major: bool, + /// The label for major ticks. + label: Option, +} + +/// Everything the canvas paint closure needs, computed in prepaint. +struct RulerContent { + ticks: Vec, + /// Local x-extents `(left, right)` of the work-area band, if a work + /// area is set. + work_area: Option<(f32, f32)>, +} + +impl RenderOnce for TimelineRuler { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + canvas( + move |bounds, _window, _cx| { + // All state is captured by moving `self` into this prepaint + // closure; the paint closure only needs the precomputed + // `RulerContent`, so it borrows nothing from `self`. + let state = &self.state; + let work_area = self.work_area(); + let step = self.tick_step(state.zoom); + let sequence_length = self.sequence_length; + + // First and last on-screen frames in ruler-local coordinates, + // which match the clip area's, so `TimelineState`'s + // frame↔pixel mapping applies directly. The last frame is + // clamped to the sequence so the ruler doesn't draw an + // endless row of ticks if the view is scrolled far right. + let first = state.frame_at_point(px(0.0)); + let last = Frame( + state + .frame_at_point(bounds.size.width) + .0 + .min(sequence_length.0), + ); + // Align to multiples of `step` so ticks stay put relative to + // the clip grid while scrolling. + let start = Frame(first.0.div_euclid(step.0) * step.0); + + let mut ticks = Vec::new(); + let mut frame = start; + while frame <= last { + ticks.push(RulerTick { + x: state.point_at_frame(frame).0, + major: true, + label: Some(self.tick_label(frame)), + }); + frame = frame + step; + } + + // Minor ticks at the midpoint between majors, only when they + // keep enough pixel separation to be legible. + if step.0 >= 2 && (step.0 as f32 / 2.0) * state.zoom >= 4.0 { + let mut frame = Frame(start.0 + step.0 / 2); + while frame <= last { + ticks.push(RulerTick { + x: state.point_at_frame(frame).0, + major: false, + label: None, + }); + frame = frame + step; + } + } + + RulerContent { + ticks, + work_area: work_area.map(|range| { + ( + state.point_at_frame(range.start).0, + state.point_at_frame(range.end).0, + ) + }), + } + }, + move |bounds, content, window, cx| { + let baseline_color = hsla(0.0, 0.0, 0.5, 0.5); + let major_color = hsla(0.0, 0.0, 0.6, 0.9); + let minor_color = hsla(0.0, 0.0, 0.6, 0.45); + let text_color = hsla(0.0, 0.0, 0.5, 1.0); + let band_color = hsla(0.63, 0.55, 0.55, 0.10); + let bottom = bounds.bottom(); + + // Work-area band under the ticks, with edge lines. + if let Some((left, right)) = content.work_area { + let width = px((right - left).max(0.0)); + let left = bounds.left() + px(left); + let band = Bounds { + origin: point(left, bounds.top()), + size: size(width, bounds.size.height), + }; + window.paint_quad(fill(band, band_color)); + for edge in [left.0, left.0 + width.0] { + window.paint_quad(fill( + Bounds { + origin: point(px(edge), bounds.top()), + size: size(px(1.0), bounds.size.height), + }, + band_color, + )); + } + } + + // Baseline along the bottom of the ruler. + window.paint_quad(fill( + Bounds { + origin: point(bounds.left(), bottom - px(1.0)), + size: size(bounds.size.width, px(1.0)), + }, + baseline_color, + )); + + for tick in content.ticks { + let x = bounds.left() + px(tick.x); + let height = if tick.major { 16.0 } else { 8.0 }; + window.paint_quad(fill( + Bounds { + origin: point(x, bottom - px(height)), + size: size(px(1.0), px(height)), + }, + if tick.major { + major_color + } else { + minor_color + }, + )); + if let Some(label) = tick.label { + let len = label.len(); + let line = window.text_system().shape_line( + label, + px(11.0), + &[TextRun { + len, + font: Font::default(), + color: text_color, + background_color: None, + underline: None, + strikethrough: None, + letter_spacing: None, + }], + None, + ); + let _ = line.paint( + point(px(x.0 + 4.0), bottom - px(23.0)), + px(12.0), + TextAlign::Left, + None, + window, + cx, + ); + } + } + }, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tick_step_returns_nice_ladder_step() { + let ruler = TimelineRuler::new( + TimelineState::new(), + FrameRate::new(30, 1), + Frame(30 * 60 * 60), + ); + // At 100 px/frame a single frame spans 100 px: the finest step wins. + assert_eq!(ruler.tick_step(100.0), Frame(1)); + // At 3 px/frame a 1-second step spans 90 px: the smallest ladder + // entry that clears MIN_TICK_SPACING is one second (30 frames). + assert_eq!(ruler.tick_step(3.0), Frame(30)); + // At 1/3000 px/frame a 2-hour step spans 64.8 px, under the minimum; + // the ladder's last entry is the fallback. + assert_eq!(ruler.tick_step(0.0003), Frame(7200 * 30)); + } + + #[test] + fn tick_step_uses_nominal_fps_for_fractional_rates() { + // NTSC 29.97 labels ticks in nominal 30 fps, matching + // `format_timecode`'s non-drop-frame convention. + let ruler = TimelineRuler::new( + TimelineState::new(), + FrameRate::NTSC_2997, + Frame(30 * 60 * 60), + ); + assert_eq!(ruler.tick_step(3.0), Frame(30)); + } + + #[test] + fn tick_step_keeps_labels_apart() { + let ruler = TimelineRuler::new( + TimelineState::new(), + FrameRate::new(25, 1), + Frame(25 * 60 * 60), + ); + for zoom in [0.001, 0.01, 0.1, 0.5, 1.0, 3.0, 10.0, 100.0, 1000.0] { + let step = ruler.tick_step(zoom); + // The coarsest step (2 hours at 25 fps) is the documented + // exception to the spacing contract. + if step.0 != 7200 * 25 { + assert!( + step.0 as f32 * zoom >= TimelineRuler::MIN_TICK_SPACING, + "zoom {zoom}: step {step:?} under-spaces" + ); + } + } + } +} diff --git a/crates/gpui/src/timeline/state.rs b/crates/gpui/src/timeline/state.rs new file mode 100644 index 0000000000..75a86a6f1e --- /dev/null +++ b/crates/gpui/src/timeline/state.rs @@ -0,0 +1,228 @@ +//! Pure view-state for the timeline: zoom, scroll, playhead, selection. +//! +//! [`TimelineState`] holds no [`Entity`](crate::Entity) handles and no model +//! references — it is plain data with pure methods, which makes it the +//! unit-testable core of the widget. [`TimelineView`](super::TimelineView) +//! owns one of these and funnels every interaction through it. +//! +//! # Coordinate model +//! +//! The clip area uses a single affine mapping between sequence frames and +//! pixels, parameterized by `zoom` (pixels per frame) and `scroll_offset` +//! (the content position currently at the top-left of the viewport): +//! +//! ```text +//! screen_x(frame) = frame * zoom - scroll_offset.x +//! frame_at(x) = (x + scroll_offset.x) / zoom +//! ``` +//! +//! [`TimelineState::frame_at_point`] and [`TimelineState::point_at_frame`] +//! are exact inverses (up to float rounding and frame truncation) and both +//! are implemented here — they are the contract the painter and the mouse +//! handlers share. + +use std::collections::BTreeSet; + +use crate::{Pixels, Point, px, point}; + +use super::data::ClipId; +use super::time::{Frame, FrameRange}; + +/// Minimum zoom: 0.001 pixels per frame (a full day of 24 fps fits in ~86 px). +pub const MIN_ZOOM: f32 = 0.001; + +/// Maximum zoom: 1000 pixels per frame (one frame fills a large display). +pub const MAX_ZOOM: f32 = 1000.0; + +/// View-local state of the timeline: zoom, scroll, playhead, selection. +/// +/// All fields are public so tests can construct states directly, but the +/// invariants below are only maintained if you go through the methods: +/// +/// * `zoom` stays within `[MIN_ZOOM, MAX_ZOOM]` (use [`Self::set_zoom`]). +/// * `scroll_offset` components stay `>= px(0.)`. +/// * `playhead` stays within `[Frame::ZERO, sequence_length]` (use +/// [`Self::set_playhead`]). +#[derive(Debug, Clone)] +pub struct TimelineState { + /// Horizontal scale, in **pixels per frame**. Drives both the clip area + /// and the ruler. Clamped to `[MIN_ZOOM, MAX_ZOOM]` by + /// [`Self::set_zoom`]. + pub zoom: f32, + + /// Content coordinate currently at the top-left of the viewport. `x` + /// scrolls the whole timeline; `y` scrolls the stacked tracks. + pub scroll_offset: Point, + + /// Current playhead position, in sequence frames. + pub playhead: Frame, + + /// The current clip selection. + /// + /// A `BTreeSet` so iteration order is deterministic (paint z-order of + /// selection outlines, test assertions) and membership tests are cheap. + pub selection: BTreeSet, + + /// Whether drag operations snap to clip edges, the playhead, work-area + /// edges and markers. Toggled by the user (Oak: the magnet toolbar + /// button); checked by every drag handler before calling + /// [`snap`](super::snap). + pub snap_enabled: bool, + + /// The work area (render/export in-out range), if set. Shown as a band + /// on the ruler and offered as snap points. + pub work_area: Option, +} + +impl Default for TimelineState { + fn default() -> Self { + TimelineState { + zoom: 1.0, + scroll_offset: point(px(0.), px(0.)), + playhead: Frame::ZERO, + selection: BTreeSet::new(), + snap_enabled: true, + work_area: None, + } + } +} + +impl TimelineState { + /// Creates a default state: zoom 1 px/frame, no scroll, playhead at + /// zero, empty selection, snapping on, no work area. + pub fn new() -> Self { + Self::default() + } + + /// Maps a horizontal screen position in the clip area to a sequence + /// frame: `(x + scroll_offset.x) / zoom`, rounded **toward zero** to the + /// nearest whole frame. + /// + /// Positions left of the content start yield negative frames; callers + /// clamp to the sequence as appropriate. This is the exact inverse of + /// [`Self::point_at_frame`] — see the module docs for the mapping. + /// + /// # Panics + /// + /// Never panics in release; in debug it asserts that `zoom > 0`. + pub fn frame_at_point(&self, x: Pixels) -> Frame { + debug_assert!(self.zoom > 0.0, "zoom must be positive"); + let content_x = x + self.scroll_offset.x; + Frame((content_x / px(self.zoom)) as i64) + } + + /// Maps a sequence frame to its horizontal screen position in the clip + /// area: `frame * zoom - scroll_offset.x`. + /// + /// Frames scrolled off-screen yield negative or beyond-viewport values; + /// that is expected — painters clip to their bounds. + pub fn point_at_frame(&self, frame: Frame) -> Pixels { + px(frame.0 as f32 * self.zoom) - self.scroll_offset.x + } + + /// Sets the zoom, clamped to `[MIN_ZOOM, MAX_ZOOM]`, while keeping the + /// frame under `anchor` (a screen x position, typically the cursor) + /// stationary on screen. + /// + /// # Math contract + /// + /// Let `f = frame_at_point(anchor)` (fractional, before truncation). + /// After zooming, `scroll_offset.x` is adjusted so that + /// `f * new_zoom - new_scroll_x == anchor`, i.e.: + /// + /// ```text + /// new_scroll_x = (anchor + old_scroll_x) * (new_zoom / old_zoom) - anchor + /// ``` + /// + /// clamped to `>= px(0.)`. This is the standard "zoom to cursor" + /// behavior of every NLE. + pub fn set_zoom(&mut self, zoom: f32, anchor: Pixels) { + let new_zoom = zoom.clamp(MIN_ZOOM, MAX_ZOOM); + let old_zoom = self.zoom.max(MIN_ZOOM); + let content_at_anchor = anchor + self.scroll_offset.x; + let new_scroll = content_at_anchor * (new_zoom / old_zoom) - anchor; + self.zoom = new_zoom; + self.scroll_offset.x = if new_scroll < px(0.) { px(0.) } else { new_scroll }; + } + + /// Sets the playhead, clamped to `[Frame::ZERO, sequence_length]`. + /// + /// `sequence_length` comes from + /// [`TimelineDataSource::sequence_length`](super::TimelineDataSource::sequence_length); + /// passing the inclusive end is legal — the playhead may rest one frame + /// past the last content frame. + pub fn set_playhead(&mut self, frame: Frame, sequence_length: Frame) { + self.playhead = frame.clamp(Frame::ZERO, sequence_length); + } + + /// Replaces the selection with exactly `id`. + pub fn select(&mut self, id: ClipId) { + self.selection.clear(); + self.selection.insert(id); + } + + /// Adds `id` to the selection without disturbing the rest (shift-click). + pub fn add_to_selection(&mut self, id: ClipId) { + self.selection.insert(id); + } + + /// Toggles `id`'s membership in the selection (ctrl/cmd-click). + pub fn toggle(&mut self, id: ClipId) { + if !self.selection.remove(&id) { + self.selection.insert(id); + } + } + + /// Empties the selection. + pub fn clear_selection(&mut self) { + self.selection.clear(); + } + + /// Selects exactly the given clips (marquee/rubber-band result). + /// + /// The hit-testing that produces `ids` lives in + /// [`TimelineView`](super::TimelineView); this method only stores the + /// outcome, replacing any previous selection. + pub fn select_range(&mut self, ids: impl IntoIterator) { + self.selection = ids.into_iter().collect(); + } + + /// Whether `id` is currently selected. + pub fn is_selected(&self, id: ClipId) -> bool { + self.selection.contains(&id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn point_and_frame_are_inverse() { + let mut state = TimelineState::new(); + state.zoom = 2.5; + state.scroll_offset.x = px(40.); + let x = state.point_at_frame(Frame(100)); + assert_eq!(state.frame_at_point(x), Frame(100)); + } + + #[test] + fn zoom_keeps_anchor_frame_stationary() { + let mut state = TimelineState::new(); + state.scroll_offset.x = px(100.); + let anchor = px(200.); + // At zoom 1, frame 300 sits at screen x = 300 - 100 = 200 = anchor. + state.set_zoom(4.0, anchor); + // After zooming, frame 300 must still sit exactly under the anchor. + assert_eq!(state.point_at_frame(Frame(300)), anchor); + } + + #[test] + fn playhead_is_clamped() { + let mut state = TimelineState::new(); + state.set_playhead(Frame(-5), Frame(100)); + assert_eq!(state.playhead, Frame::ZERO); + state.set_playhead(Frame(500), Frame(100)); + assert_eq!(state.playhead, Frame(100)); + } +} diff --git a/crates/gpui/src/timeline/time.rs b/crates/gpui/src/timeline/time.rs new file mode 100644 index 0000000000..7fdc8d0fa3 --- /dev/null +++ b/crates/gpui/src/timeline/time.rs @@ -0,0 +1,559 @@ +//! Frame-exact time types for the timeline widget. +//! +//! The timeline's canonical time unit is the **frame**, expressed as an +//! [`i64`] inside [`Frame`]. Durations and positions are never stored as +//! floating-point seconds: floats drift and accumulate rounding error, which +//! is unacceptable for an editing tool where an off-by-one frame is a visible +//! bug. The only place seconds appear is at the edges of the system — +//! converting wall-clock playback time into frames (see +//! [`super::playhead::PlayheadTicker`]) and formatting human-readable labels +//! via [`format_timecode`]. +//! +//! This mirrors the Oak engine's time model, where canonical time is a +//! rational `int64 num/den` and the ABI exchanges `int64` frame timestamps +//! together with a per-sequence [`FrameRate`] such as `30000/1001`. +//! +//! # Invariants +//! +//! * A [`FrameRate`] is always normalized to a positive, non-zero numerator +//! and denominator by [`FrameRate::new`]. +//! * All conversions that produce frames from floats round deterministically; +//! see [`seconds_to_frame`] for the exact rounding policy. +//! * [`FrameRange`] is half-open: `[start, end)`. + +use crate::{Pixels, px}; + +/// A rational frame rate: `num / den` frames per second. +/// +/// Frame rates in professional video are frequently *not* integers; the +/// classic example is NTSC 29.97 fps, which is exactly `30000/1001`. Storing +/// the rate as a pair of integers keeps every downstream computation exact. +/// +/// # Invariants +/// +/// Both `num` and `den` are guaranteed non-zero after construction through +/// [`FrameRate::new`]. Constructing the struct literal directly is possible +/// (the fields are public so the type is usable in `const` contexts) but +/// callers must uphold the non-zero invariant themselves. +/// +/// # Examples +/// +/// ``` +/// # use gpui::timeline::FrameRate; +/// let ntsc = FrameRate::new(30000, 1001); +/// assert!((ntsc.as_f64() - 29.97002997).abs() < 1e-6); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct FrameRate { + /// Numerator of the rate (frames per `den` seconds). Must be non-zero. + pub num: u32, + /// Denominator of the rate. Must be non-zero. + pub den: u32, +} + +impl FrameRate { + /// NTSC "29.97" fps, exactly `30000/1001`. + pub const NTSC_2997: FrameRate = FrameRate { + num: 30000, + den: 1001, + }; + + /// NTSC "23.976" fps, exactly `24000/1001`. + pub const NTSC_23976: FrameRate = FrameRate { + num: 24000, + den: 1001, + }; + + /// Creates a frame rate from a numerator and denominator. + /// + /// # Panics + /// + /// Panics if either `num` or `den` is zero — a zero frame rate is + /// meaningless and would cause division by zero in every conversion. + /// + /// # Examples + /// + /// ``` + /// # use gpui::timeline::FrameRate; + /// let pal = FrameRate::new(25, 1); + /// assert_eq!(pal.as_f64(), 25.0); + /// ``` + pub fn new(num: u32, den: u32) -> Self { + assert!(num != 0 && den != 0, "frame rate components must be non-zero"); + FrameRate { num, den } + } + + /// Returns the rate as a floating-point frames-per-second value. + /// + /// Intended for display and for one-shot wall-clock conversions only; + /// never store positions or durations derived from this value. + pub fn as_f64(self) -> f64 { + self.num as f64 / self.den as f64 + } +} + +/// An absolute position or duration on the timeline, in frames. +/// +/// Negative values are representable (they occasionally arise during drag +/// interactions before clamping) but are never valid as final positions in a +/// sequence; consumers should clamp to `[Frame(0), sequence_length)`. +/// +/// `Frame` is `Ord`, so collections of frames (and of [`ClipId`](super::ClipId)s +/// keyed by frame) sort naturally. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Frame(pub i64); + +impl Frame { + /// Frame zero, the start of every sequence. + pub const ZERO: Frame = Frame(0); + + /// Returns the raw frame number. + pub fn number(self) -> i64 { + self.0 + } + + /// Returns this frame as a [`Pixels`] offset at the given zoom + /// (pixels per frame). Pure scaling, no scroll compensation — see + /// [`TimelineState::point_at_frame`](super::TimelineState::point_at_frame) + /// for the scroll-aware variant. + pub fn to_pixels(self, zoom: f32) -> Pixels { + Pixels::from(self.0 as f32 * zoom) + } +} + +impl std::ops::Add for Frame { + type Output = Frame; + + fn add(self, rhs: Frame) -> Frame { + Frame(self.0 + rhs.0) + } +} + +impl std::ops::Sub for Frame { + type Output = Frame; + + fn sub(self, rhs: Frame) -> Frame { + Frame(self.0 - rhs.0) + } +} + +impl std::ops::AddAssign for Frame { + fn add_assign(&mut self, rhs: Frame) { + self.0 += rhs.0; + } +} + +/// A half-open range of frames, `[start, end)`. +/// +/// Half-open semantics match the Oak engine's block model: a clip occupying +/// frames `[10, 20)` has length 10 and abuts a clip starting at frame 20 with +/// no overlap and no gap. An empty range (`start == end`) is legal and +/// represents zero duration. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub struct FrameRange { + /// First frame of the range (inclusive). + pub start: Frame, + /// End of the range (exclusive). + pub end: Frame, +} + +impl FrameRange { + /// Creates a range from `start` (inclusive) to `end` (exclusive). + /// + /// # Panics + /// + /// Panics if `end < start`. + pub fn new(start: Frame, end: Frame) -> Self { + assert!(end >= start, "frame range end must not precede start"); + FrameRange { start, end } + } + + /// The number of frames in the range. Zero for an empty range. + pub fn len(&self) -> Frame { + Frame(self.end.0 - self.start.0) + } + + /// Whether the range contains no frames. + pub fn is_empty(&self) -> bool { + self.start == self.end + } + + /// Whether `frame` lies inside the range (`start <= frame < end`). + pub fn contains(&self, frame: Frame) -> bool { + self.start <= frame && frame < self.end + } + + /// Whether two ranges share at least one frame. + pub fn overlaps(&self, other: &FrameRange) -> bool { + self.start < other.end && other.start < self.end + } +} + +/// How time should be presented to the user in rulers and inspectors. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TimeDisplay { + /// `HH:MM:SS:FF` timecode. This is the default and the standard in + /// professional video editing. + /// + /// Drop-frame timecode (`;` separator, frame-number skipping for NTSC + /// rates) is **not implemented yet**; [`format_timecode`] currently + /// always produces non-drop-frame timecode. See its documentation. + #[default] + Timecode, + /// A plain frame counter, e.g. `1048576`. + Frames, + /// Seconds with millisecond precision, e.g. `83.708`. + Seconds, +} + +/// Converts a frame position to floating-point seconds at `rate`. +/// +/// This is a pure, one-shot conversion: `frame * den / num`. It is exact for +/// any frame whose magnitude keeps `frame * den` within `f64`'s 53-bit +/// mantissa (far beyond any realistic sequence length). +/// +/// # Examples +/// +/// ``` +/// # use gpui::timeline::{Frame, FrameRate, frame_to_seconds}; +/// let rate = FrameRate::new(30000, 1001); +/// // Frame 30 at ~29.97 fps is just over one second. +/// assert!((frame_to_seconds(Frame(30), rate) - 1.001).abs() < 1e-9); +/// ``` +pub fn frame_to_seconds(frame: Frame, rate: FrameRate) -> f64 { + frame.0 as f64 * rate.den as f64 / rate.num as f64 +} + +/// Converts floating-point seconds to the nearest frame at `rate`. +/// +/// # Rounding policy +/// +/// The result is rounded to the **nearest frame, ties away from zero** +/// (`f64::round` semantics). This makes [`seconds_to_frame`] and +/// [`frame_to_seconds`] approximate inverses for any input that is already +/// near a frame boundary, and it is deterministic across platforms. +/// +/// This function exists for converting wall-clock durations (mouse drags +/// measured in seconds, playback elapsed time) into frames. It must never be +/// used to *store* time — convert once, then keep the [`Frame`]. +/// +/// # Examples +/// +/// ``` +/// # use gpui::timeline::{Frame, FrameRate, seconds_to_frame}; +/// let rate = FrameRate::new(24, 1); +/// assert_eq!(seconds_to_frame(rate, 1.0), Frame(24)); +/// assert_eq!(seconds_to_frame(rate, 1.02), Frame(24)); // rounds to nearest +/// assert_eq!(seconds_to_frame(rate, 1.03), Frame(25)); +/// ``` +pub fn seconds_to_frame(rate: FrameRate, seconds: f64) -> Frame { + Frame((seconds * rate.num as f64 / rate.den as f64).round() as i64) +} + +/// Formats `frame` for display according to `display`. +/// +/// * [`TimeDisplay::Frames`] — the raw frame number. +/// * [`TimeDisplay::Seconds`] — seconds with millisecond precision. +/// * [`TimeDisplay::Timecode`] — non-drop-frame `HH:MM:SS:FF`. The frame +/// component width derives from the frame rate (two digits for rates below +/// 100 fps). +/// +/// # Drop-frame timecode +/// +/// Drop-frame timecode (the `HH:MM:SS;FF` convention used with NTSC rates so +/// that timecode stays in lockstep with wall-clock time) is **future work**. +/// Calling this with [`TimeDisplay::Timecode`] and an NTSC rate such as +/// [`FrameRate::NTSC_2997`] currently yields non-drop-frame timecode, which +/// drifts from wall-clock time by about 3.6 seconds per hour. Callers that +/// need broadcast-correct labels must not rely on this function yet. +/// +/// Negative frames are formatted with a leading `-` applied to the whole +/// timecode (e.g. `-00:00:01:12`). +/// +/// # Examples +/// +/// ``` +/// # use gpui::timeline::{Frame, FrameRate, TimeDisplay, format_timecode}; +/// let rate = FrameRate::new(24, 1); +/// assert_eq!(format_timecode(Frame(0), rate, TimeDisplay::Timecode), "00:00:00:00"); +/// assert_eq!( +/// format_timecode(Frame(24 * 60 * 60 + 24 * 60 + 24 + 12), rate, TimeDisplay::Timecode), +/// "01:01:01:12", +/// ); +/// assert_eq!(format_timecode(Frame(42), rate, TimeDisplay::Frames), "42"); +/// ``` +pub fn format_timecode(frame: Frame, rate: FrameRate, display: TimeDisplay) -> String { + match display { + TimeDisplay::Frames => frame.0.to_string(), + TimeDisplay::Seconds => format!("{:.3}", frame_to_seconds(frame, rate)), + TimeDisplay::Timecode => { + let negative = frame.0 < 0; + let mut n = frame.0.unsigned_abs(); + // Nominal (integer) frame count per second, matching the + // non-drop-frame convention: NTSC 29.97 uses 30 frames/sec. + let fps = rate.as_f64().round() as u64; + let frames = n % fps; + n /= fps; + let seconds = n % 60; + n /= 60; + let minutes = n % 60; + let hours = n / 60; + format!( + "{}{:02}:{:02}:{:02}:{:02}", + if negative { "-" } else { "" }, + hours, + minutes, + seconds, + frames + ) + } + } +} + +/// What produced a [`SnapPoint`]. Used by the UI to pick an indicator style +/// and by tests to assert snapping priority. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SnapKind { + /// The start edge of a clip. + ClipStart, + /// The end edge of a clip. + ClipEnd, + /// The playhead. + Playhead, + /// An edge of the work area (in or out point). + WorkAreaEdge, + /// A user or chapter marker. + Marker, +} + +/// A frame position that dragged elements can snap to. +/// +/// Snap points are gathered fresh on every drag move from the current +/// [`TimelineDataSource`](super::TimelineDataSource): clip edges, the +/// playhead, work-area edges and markers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SnapPoint { + /// The frame to snap to. + pub frame: Frame, + /// What this point represents. + pub kind: SnapKind, +} + +/// The outcome of a successful [`snap`] query. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SnapResult { + /// The snapped frame — equal to the winning [`SnapPoint`]'s frame. + pub frame: Frame, + /// The kind of the winning snap point. + pub kind: SnapKind, + /// On-screen distance between the drag position and the snap point, in + /// pixels. Always `<=` the threshold passed to [`snap`]. Useful for + /// fading the snap indicator as the cursor approaches. + pub distance: Pixels, +} + +/// Finds the best snap target for a dragged position, or `None` if nothing +/// is close enough. +/// +/// # Contract +/// +/// * `target` is the unsnapped frame position of the dragged edge or clip. +/// * `points` is evaluated lazily; the iterator may be cheap to reconstruct +/// per mouse-move, so implementations should consume it exactly once. +/// * `threshold_px` is the maximum on-screen distance at which snapping +/// engages, converted to frames internally via `zoom` (pixels per frame). +/// A threshold of `0` disables snapping. +/// * When several points are within range, the **nearest on screen** wins. +/// Ties are broken by the **earlier frame** first, then by [`SnapKind`] +/// priority — `Playhead` first, then `ClipStart`/`ClipEnd`, then +/// `WorkAreaEdge`, then `Marker` — so the behavior is deterministic +/// regardless of iterator order. +/// +/// The returned frame is always exactly one of the provided snap points' +/// frames; this function never invents intermediate positions. +pub fn snap( + target: Frame, + points: impl Iterator, + threshold_px: Pixels, + zoom: f32, +) -> Option { + if threshold_px.0 <= 0.0 || zoom <= 0.0 { + return None; + } + + // Work in frame space for the distance comparison: a threshold given in + // pixels is `threshold_px / zoom` frames at this zoom, and a point's + // on-screen distance is `|point - target| * zoom`. Both quantities scale + // by the same positive zoom, so ordering is preserved — we compare in + // frame space and only convert the winning distance back to pixels. + let threshold_frames = threshold_px.0 / zoom; + + // Priority per [`SnapKind`] for deterministic tie-breaking: `Playhead` + // first, then `ClipStart`/`ClipEnd`, then `WorkAreaEdge`, then `Marker`. + let kind_rank = |kind: SnapKind| match kind { + SnapKind::Playhead => 0, + SnapKind::ClipStart | SnapKind::ClipEnd => 1, + SnapKind::WorkAreaEdge => 2, + SnapKind::Marker => 3, + }; + + // Best candidate, compared lexicographically: (screen distance in frames, + // frame number, kind rank). Smaller is better on every component. The + // frame-number tiebreak prefers the earlier snap point when two points are + // equally close (so the result is always exactly one of the given frames, + // never an interpolated position), and the kind rank breaks ties between + // points sharing a frame; both keep the outcome independent of iterator + // order. + let mut best: Option<(f32, i64, u8)> = None; + let mut best_kind = SnapKind::Marker; + for point in points { + let dist = (point.frame.0 - target.0).unsigned_abs() as f32; + if dist > threshold_frames { + continue; + } + let candidate = (dist, point.frame.0, kind_rank(point.kind)); + if best.map_or(true, |current| candidate < current) { + best = Some(candidate); + best_kind = point.kind; + } + } + + best.map(|(dist, frame, _)| SnapResult { + frame: Frame(frame), + kind: best_kind, + distance: px(dist * zoom), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frame_range_is_half_open() { + let range = FrameRange::new(Frame(10), Frame(20)); + assert_eq!(range.len(), Frame(10)); + assert!(range.contains(Frame(10))); + assert!(!range.contains(Frame(20))); + assert!(range.overlaps(&FrameRange::new(Frame(19), Frame(30)))); + assert!(!range.overlaps(&FrameRange::new(Frame(20), Frame(30)))); + } + + #[test] + fn seconds_round_trips_to_nearest_frame() { + let rate = FrameRate::NTSC_2997; + let frame = seconds_to_frame(rate, 10.0); + assert!((frame_to_seconds(frame, rate) - 10.0).abs() < 0.02); + } + + #[test] + fn snap_disabled_by_zero_threshold() { + let points = [SnapPoint { + frame: Frame(10), + kind: SnapKind::Playhead, + }]; + assert!(snap(Frame(12), points.into_iter(), px(0.0), 1.0).is_none()); + } + + #[test] + fn snap_requires_positive_zoom() { + let points = [SnapPoint { + frame: Frame(10), + kind: SnapKind::Playhead, + }]; + assert!(snap(Frame(10), points.into_iter(), px(10.0), 0.0).is_none()); + } + + #[test] + fn snap_returns_none_when_nothing_is_within_range() { + let points = [SnapPoint { + frame: Frame(100), + kind: SnapKind::Playhead, + }]; + // At zoom 1.0 a 5-px threshold is 5 frames; target 92 is 8 frames away. + assert!(snap(Frame(92), points.into_iter(), px(5.0), 1.0).is_none()); + } + + #[test] + fn snap_prefers_the_nearest_point() { + let points = [ + SnapPoint { + frame: Frame(90), + kind: SnapKind::Marker, + }, + SnapPoint { + frame: Frame(95), + kind: SnapKind::Marker, + }, + ]; + let result = snap(Frame(92), points.into_iter(), px(50.0), 1.0).unwrap(); + assert_eq!(result.frame, Frame(90)); + assert_eq!(result.kind, SnapKind::Marker); + assert_eq!(result.distance, px(2.0)); + } + + #[test] + fn snap_ties_break_by_kind_priority_regardless_of_iteration_order() { + let priority_points = [ + SnapPoint { + frame: Frame(100), + kind: SnapKind::Playhead, + }, + SnapPoint { + frame: Frame(100), + kind: SnapKind::ClipStart, + }, + ]; + // Equal distance; higher-priority kind wins even though it appears + // first in the iterator. + let result = snap(Frame(100), priority_points.into_iter(), px(10.0), 1.0).unwrap(); + assert_eq!(result.kind, SnapKind::Playhead); + assert_eq!(result.frame, Frame(100)); + + // Reversed iteration order changes nothing. + let reversed = [ + SnapPoint { + frame: Frame(100), + kind: SnapKind::ClipStart, + }, + SnapPoint { + frame: Frame(100), + kind: SnapKind::Playhead, + }, + ]; + let result = snap(Frame(100), reversed.into_iter(), px(10.0), 1.0).unwrap(); + assert_eq!(result.kind, SnapKind::Playhead); + } + + #[test] + fn snap_result_frame_is_always_a_snap_point_frame() { + // Two points at equal distance on opposite sides; the earlier frame + // wins via the frame-number tiebreak, never an interpolated position. + let points = [ + SnapPoint { + frame: Frame(98), + kind: SnapKind::WorkAreaEdge, + }, + SnapPoint { + frame: Frame(102), + kind: SnapKind::ClipEnd, + }, + ]; + let result = snap(Frame(100), points.into_iter(), px(10.0), 1.0).unwrap(); + assert_eq!(result.frame, Frame(98)); + assert_eq!(result.distance, px(2.0)); + } + + #[test] + fn snap_threshold_scales_with_zoom() { + // At zoom 2.0 the same 10-px threshold covers only 5 frames. + let points = [SnapPoint { + frame: Frame(50), + kind: SnapKind::ClipEnd, + }]; + assert!(snap(Frame(56), points.into_iter(), px(10.0), 2.0).is_none()); + let result = snap(Frame(55), points.into_iter(), px(10.0), 2.0).unwrap(); + assert_eq!(result.frame, Frame(50)); + assert_eq!(result.distance, px(10.0)); + } +} diff --git a/crates/gpui/src/timeline/timeline_view.rs b/crates/gpui/src/timeline/timeline_view.rs new file mode 100644 index 0000000000..3bd17d408a --- /dev/null +++ b/crates/gpui/src/timeline/timeline_view.rs @@ -0,0 +1,1020 @@ +//! The timeline view: layout, interaction handling, and edit-request events. +//! +//! [`TimelineView`] is the top-level widget. It is generic over the app's +//! [`TimelineDataSource`] implementation and owns a [`TimelineState`] for +//! view-local state (zoom, scroll, playhead, selection). +//! +//! # Layout +//! +//! ```text +//! ┌──────────────────────────────────────────────────────┐ +//! │ ruler (timecode ticks, work-area band, markers) │ +//! ├─────────┬────────────────────────────────────────────┤ +//! │ track │ clip area (per-track rows) │ +//! │ headers │ ┌──────┐ ┌─────────┐ │ +//! │ V1 │ │ clip │ │ clip │ ┆ playhead │ +//! │ V2 │ ┌──────────┐ ┆ │ +//! │ A1 │ ┌─────────┐ ┆ │ +//! ├─────────┴────────────────────────────────────────────┤ +//! │ horizontal scrollbar │ +//! └──────────────────────────────────────────────────────┘ +//! ``` +//! +//! The ruler is painted by [`TimelineRuler`](super::TimelineRuler), headers +//! by [`TrackHeader`](super::TrackHeader), clips by +//! [`ClipElement`](super::ClipElement), and the playhead by +//! [`PlayheadElement`](super::PlayheadElement). The clip area lays out one +//! row per track from the data source, sized and stacked purely from +//! [`TrackData::height`](super::TrackData::height). +//! +//! # Interactions +//! +//! * **Ruler click** — seek: moves the playhead to the pointed frame +//! (through [`TimelineState::set_playhead`]) and emits +//! [`TimelineEvent::PlayheadChanged`]. +//! * **Clip drag** — move: horizontal motion shifts the clip in time; +//! vertical motion across a track boundary requests a cross-track move +//! (never onto a locked track). While dragging, the clip snaps to nearby +//! snap points (clip edges, playhead, work-area edges, markers) when +//! [`TimelineState::snap_enabled`]. On mouse-up the widget emits +//! [`TimelineEvent::ClipMoveRequested`]; the host expands the request to +//! the clip's transitive [`ClipData::linked_ids`](super::ClipData::linked_ids) +//! group — and the widget does not change anything itself. +//! * **Clip edge drag** — trim: the ~6 px hit zones at each clip edge start +//! a trim gesture. Emits [`TimelineEvent::ClipTrimRequested`]; the host +//! expands the request to linked clips and adjusts `media_in` for +//! [`TrimEdge::Start`]. +//! * **Marquee** — rubber-band selection: dragging on empty clip-area space +//! draws a selection rect; on release the hit clips go through +//! [`TimelineState::select_range`] and [`TimelineEvent::SelectionChanged`] +//! is emitted. +//! * **Scroll wheel** — horizontal pan; **ctrl-scroll / pinch** — zoom +//! anchored at the cursor via [`TimelineState::set_zoom`], emitting +//! [`TimelineEvent::ZoomChanged`]. +//! * **Header separator drag** — track height resize, emitting +//! [`TimelineEvent::TrackHeightChanged`]. +//! +//! # Edit model (read this before wiring events) +//! +//! The widget **never mutates the model**. Every gesture ends in a +//! [`TimelineEvent`] describing the requested edit. The host applies it +//! through its engine and undo stack — keeping the engine the single source +//! of truth — and then calls `cx.notify()` on the data-source entity, which +//! makes the view re-read and repaint. If the engine rejects the edit +//! (locked track, collision, …), simply don't notify and the gesture has no +//! visible effect. + +use std::collections::BTreeSet; +use std::sync::{Arc, RwLock}; + +use crate::{ + AnyElement, App, Context, DragMoveEvent, ElementId, Entity, EventEmitter, FocusHandle, + Focusable, Hsla, MouseButton, MouseDownEvent, Pixels, PinchEvent, Point, Render, ScrollDelta, + ScrollWheelEvent, SharedString, Window, div, hsla, px, prelude::*, +}; + +use super::{ + clip::{ClipContent, ClipDecorator, ClipElement, NoopClipDecorator, TRIM_HANDLE_WIDTH}, + data::{ClipData, ClipId, TimelineDataSource, TrackData, TrackKind}, + playhead::PlayheadElement, + ruler::TimelineRuler, + state::TimelineState, + time::{Frame, FrameRange, SnapKind, SnapPoint, snap}, + track_header::TrackHeader, +}; + +/// Which edge of a clip a trim gesture grabbed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TrimEdge { + /// The clip's left (in) edge. Trimming it changes both the clip's start + /// and its [`ClipData::media_in`](super::ClipData::media_in). + Start, + /// The clip's right (out) edge. + End, +} + +/// An edit or view-state change requested by the timeline widget. +/// +/// The widget emits these and then **does nothing itself**. The host +/// application applies the request through its engine and undo stack, then +/// calls `cx.notify()` on the data-source entity so the view re-reads. +/// +/// # Wiring into Oak +/// +/// Each variant maps onto an undoable facade call in `oakengine`: +/// +/// * `ClipMoveRequested` → `engine.move_clip(clip, new_track, new_start)` +/// (ripple off), wrapped in an undo command together with the clip's +/// linked group. +/// * `ClipTrimRequested` → `engine.trim_clip(clip, edge, new_frame)`, +/// likewise grouped with linked clips. +/// * `PlayheadChanged` → `engine.set_playhead(frame)` — not undoable. +/// * `SelectionChanged` → update the app's selection state; drives the +/// inspector/effect stack panels. +/// * `TrackHeightChanged` / `ZoomChanged` → persist as per-sequence view +/// state; not undoable. +#[derive(Debug, Clone, PartialEq)] +pub enum TimelineEvent { + /// The user asked to move a clip (and, per the gesture docs on + /// [`TimelineView`], its linked group) to a new position. + /// + /// `new_track` is an index into + /// [`TimelineDataSource::track`]; + /// `new_start` is the requested [`FrameRange`](super::FrameRange) start + /// after snapping. The engine must validate collisions and track + /// compatibility; the widget only guarantees the source track was not + /// locked. + ClipMoveRequested { + /// The clip the gesture grabbed. The host expands this to the full + /// linked group. + clip: ClipId, + /// Target track index. + new_track: usize, + /// Requested new start frame. + new_start: Frame, + }, + + /// The user asked to trim one edge of a clip. + /// + /// `new_frame` is the requested new position of the grabbed `edge`, + /// after snapping and after clamping to the neighboring clips and to + /// zero minimum length. For [`TrimEdge::Start`] the host must also + /// adjust `media_in` by the same delta. + ClipTrimRequested { + /// The trimmed clip (expand to the linked group, as with moves). + clip: ClipId, + /// Which edge was grabbed. + edge: TrimEdge, + /// Requested new frame position of that edge. + new_frame: Frame, + }, + + /// The playhead moved, by any means (ruler seek, keyboard, playback + /// ticker). Carries the new position. Not undoable. + PlayheadChanged(Frame), + + /// The selection changed. The new set is readable from + /// [`TimelineView::selection`]. Not undoable. + SelectionChanged, + + /// A track's height changed via header-separator drag. The host should + /// write this back so [`TrackData::height`](super::TrackData::height) + /// returns it on the next read. + TrackHeightChanged { + /// Index of the resized track. + track: usize, + /// New row height. + height: Pixels, + }, + + /// The zoom (pixels per frame) changed via ctrl-scroll or pinch. + /// Persist as view state if desired. + ZoomChanged(f32), +} + +/// The video-editing timeline widget. +/// +/// Construct with [`TimelineView::new`] over an [`Entity`] of your +/// [`TimelineDataSource`] implementation, place it in your layout like any +/// other view, and subscribe to its [`TimelineEvent`]s to apply edits: +/// +/// ```ignore +/// let timeline = cx.new(|cx| TimelineView::new(model.clone(), window, cx)); +/// cx.subscribe(&timeline, |this, timeline, event: &TimelineEvent, cx| { +/// match event { +/// TimelineEvent::ClipMoveRequested { clip, new_track, new_start } => { +/// this.engine.move_clip(*clip, *new_track, *new_start, cx); // undoable +/// this.model.update(cx, |_, cx| cx.notify()); +/// } +/// // ... +/// } +/// }).detach(); +/// ``` +/// +/// See the module-level docs for the layout, the full interaction list, and +/// the edit-request contract. +pub struct TimelineView { + source: Entity, + /// View-local state (zoom, scroll, playhead, selection). Public for + /// read access; mutate through [`TimelineState`]'s methods to preserve + /// invariants. + pub state: TimelineState, + focus_handle: FocusHandle, +} + +/// Width of the track-headers column, in pixels. +const HEADER_WIDTH: f32 = 160.0; + +/// Minimum row height enforced by the height-resize drag, in pixels. +const MIN_TRACK_HEIGHT: f32 = 24.0; + +/// Snap engagement threshold, in pixels. +const SNAP_THRESHOLD_PX: f32 = 8.0; + +impl TimelineView { + /// Creates a timeline view over `source`. + /// + /// Subscribes to `source`'s notifications: after the host applies an + /// edit and calls `cx.notify()` on the source entity, the view re-reads + /// all data and repaints. + pub fn new(source: Entity, _window: &mut Window, cx: &mut Context) -> Self { + let focus_handle = cx.focus_handle(); + cx.observe(&source, |_this, _source, cx| cx.notify()).detach(); + TimelineView { + source, + state: TimelineState::new(), + focus_handle, + } + } + + /// Builder: sets the initial zoom (pixels per frame), clamped to + /// [`MIN_ZOOM`](super::MIN_ZOOM)..=[`MAX_ZOOM`](super::MAX_ZOOM). + pub fn zoom(mut self, zoom: f32) -> Self { + self.state.set_zoom(zoom, crate::px(0.)); + self + } + + /// Builder: enables or disables snapping initially. + pub fn snap_enabled(mut self, enabled: bool) -> Self { + self.state.snap_enabled = enabled; + self + } + + /// The data source entity this view was created over. + pub fn source(&self) -> &Entity { + &self.source + } + + /// The current selection, in deterministic (id) order. + pub fn selection(&self) -> &BTreeSet { + &self.state.selection + } + + /// Seeks the playhead to `frame`, clamped to the sequence, emitting + /// [`TimelineEvent::PlayheadChanged`] if the position changed. + pub fn seek(&mut self, frame: Frame, cx: &mut Context) { + let seq_len = self.sequence_length(cx); + let old = self.state.playhead; + self.state.set_playhead(frame, seq_len); + if self.state.playhead != old { + cx.emit(TimelineEvent::PlayheadChanged(self.state.playhead)); + cx.notify(); + } + } + + /// The sequence length from the data source. + fn sequence_length(&self, cx: &mut Context) -> Frame { + self.source.read(cx).sequence_length() + } + + /// The current range of the clip with `id`, scanned from the data source. + /// Falls back to an empty range when the clip no longer exists. + fn clip_range(&self, id: ClipId, cx: &mut Context) -> FrameRange { + let source = self.source.read(cx); + for index in 0..source.track_count() { + if let Some(track) = source.track(index) { + for clip in track.clips() { + if clip.id() == id { + return clip.range(); + } + } + } + } + FrameRange::new(Frame::ZERO, Frame::ZERO) + } + + /// Snap points gathered fresh from the data source: work-area edges, + /// markers, the playhead, and every enabled clip edge. `dragged` (the + /// clip being moved) is excluded so a clip never snaps to itself. + fn snap_points(&self, dragged: Option, cx: &mut Context) -> Vec { + let source = self.source.read(cx); + let mut points = Vec::new(); + if let Some(area) = self.state.work_area { + points.push(SnapPoint { + frame: area.start, + kind: SnapKind::WorkAreaEdge, + }); + points.push(SnapPoint { + frame: area.end, + kind: SnapKind::WorkAreaEdge, + }); + } + for marker in source.markers() { + points.push(SnapPoint { + frame: marker.frame, + kind: SnapKind::Marker, + }); + } + points.push(SnapPoint { + frame: self.state.playhead, + kind: SnapKind::Playhead, + }); + for index in 0..source.track_count() { + if let Some(track) = source.track(index) { + for clip in track.clips() { + if clip.is_enabled() && Some(clip.id()) != dragged { + let range = clip.range(); + points.push(SnapPoint { + frame: range.start, + kind: SnapKind::ClipStart, + }); + points.push(SnapPoint { + frame: range.end, + kind: SnapKind::ClipEnd, + }); + } + } + } + } + points + } + + /// Index of the track whose row contains screen `y` (relative to the + /// clip area's top). Falls back to the last track when below all rows. + fn track_at_y(&self, y: f32, cx: &mut Context) -> usize { + let source = self.source.read(cx); + let count = source.track_count(); + let mut acc = 0.0; + for index in 0..count { + if let Some(track) = source.track(index) { + acc += track.height().0.max(MIN_TRACK_HEIGHT); + if y < acc { + return index; + } + } + } + count.saturating_sub(1) + } + + /// Whether the track at `index` is locked. Out-of-range tracks count as + /// locked so drop requests onto them are rejected. + fn track_locked(&self, index: usize, cx: &mut Context) -> bool { + self.source + .read(cx) + .track(index) + .map(|track| track.is_locked()) + .unwrap_or(true) + } + + /// Updates a clip-move drag from the pointer position: computes the new + /// start frame (with snapping) and the track under the cursor. + fn update_clip_drag( + &mut self, + event: &DragMoveEvent>>, + cx: &mut Context, + ) { + let drag = Arc::clone(event.drag(cx)); + let press = cx + .active_drag + .as_ref() + .map(|drag| drag.cursor_offset) + .unwrap_or_default(); + let now = event.event.position - event.bounds.origin; + let mut drag = drag.write().expect("clip drag lock is not poisoned"); + let wrapper_x = self.state.point_at_frame(drag.original_start).0; + let dx = now.x.0 - (wrapper_x + press.x.0); + let mut new_start = Frame(drag.original_start.0 + (dx / self.state.zoom).round() as i64); + let new_track = self.track_at_y(now.y.0, cx); + if self.state.snap_enabled { + if let Some(result) = snap( + new_start, + self.snap_points(Some(drag.clip), cx).into_iter(), + px(SNAP_THRESHOLD_PX), + self.state.zoom, + ) { + new_start = result.frame; + } + } + drag.new_start = new_start; + drag.new_track = new_track; + } + + /// Updates a trim drag from the pointer position: computes the new + /// position of the grabbed edge, clamped to keep the clip non-empty and + /// inside the sequence, then snapped and re-clamped. + fn update_trim_drag( + &mut self, + event: &DragMoveEvent>>, + cx: &mut Context, + ) { + let drag = Arc::clone(event.drag(cx)); + let press = cx + .active_drag + .as_ref() + .map(|drag| drag.cursor_offset) + .unwrap_or_default(); + let now = event.event.position - event.bounds.origin; + let seq_len = self.sequence_length(cx); + let mut drag = drag.write().expect("trim drag lock is not poisoned"); + let range = self.clip_range(drag.clip, cx); + let handle_x = match drag.edge { + TrimEdge::Start => now.x.0 - press.x.0, + TrimEdge::End => now.x.0 - press.x.0 + TRIM_HANDLE_WIDTH, + }; + let mut new_frame = self.state.frame_at_point(px(handle_x)); + let clamp = |frame: Frame| match drag.edge { + TrimEdge::Start => frame.max(Frame::ZERO).min(Frame((range.end.0 - 1).max(0))), + TrimEdge::End => frame + .max(Frame((range.start.0 + 1).min(seq_len.0))) + .min(seq_len), + }; + new_frame = clamp(new_frame); + if self.state.snap_enabled { + if let Some(result) = snap( + new_frame, + self.snap_points(Some(drag.clip), cx).into_iter(), + px(SNAP_THRESHOLD_PX), + self.state.zoom, + ) { + new_frame = clamp(result.frame); + } + } + drag.new_frame = new_frame; + } + + /// Updates a track-height drag from the pointer position. + fn update_height_drag( + &mut self, + event: &DragMoveEvent>>, + cx: &mut Context, + ) { + let drag = Arc::clone(event.drag(cx)); + let press = cx + .active_drag + .as_ref() + .map(|drag| drag.cursor_offset) + .unwrap_or_default(); + let now = event.event.position - event.bounds.origin; + let mut drag = drag.write().expect("height drag lock is not poisoned"); + let dy = now.y.0 - (press.y.0 + drag.separator_y); + drag.new_height = px((drag.start_height.0 + dy).max(MIN_TRACK_HEIGHT)); + } + + /// Updates a marquee selection from the pointer position: hit-tests the + /// rows intersecting the rubber-band rect and selects the clips inside. + fn update_marquee( + &mut self, + event: &DragMoveEvent, + rows: &[RowData], + cx: &mut Context, + ) { + let press = cx + .active_drag + .as_ref() + .map(|drag| drag.cursor_offset) + .unwrap_or_default(); + let now = event.event.position - event.bounds.origin; + let mut frame_start = self.state.frame_at_point(px(press.x.0)); + let mut frame_end = self.state.frame_at_point(px(now.x.0)); + if frame_start > frame_end { + std::mem::swap(&mut frame_start, &mut frame_end); + } + let y0 = press.y.min(now.y).0; + let y1 = press.y.max(now.y).0; + let mut ids = BTreeSet::new(); + for row in rows { + if y1 >= row.y && y0 <= row.y + row.height { + for clip in &row.clips { + if clip.enabled + && clip.range.end.0 > frame_start.0 + && clip.range.start.0 < frame_end.0 + { + ids.insert(clip.id); + } + } + } + } + self.state.select_range(ids); + } + + /// Emits [`TimelineEvent::ClipMoveRequested`] for a finished clip move, + /// unless the gesture didn't move the clip or a locked track was involved. + fn finish_clip_drag(&mut self, drag: &Arc>, cx: &mut Context) { + let (clip, original_start, original_track, new_start, new_track) = { + let drag = drag.read().expect("clip drag lock is not poisoned"); + ( + drag.clip, + drag.original_start, + drag.original_track, + drag.new_start, + drag.new_track, + ) + }; + if (new_start, new_track) != (original_start, original_track) + && !self.track_locked(original_track, cx) + && !self.track_locked(new_track, cx) + { + cx.emit(TimelineEvent::ClipMoveRequested { + clip, + new_track, + new_start, + }); + cx.notify(); + } + } + + /// Emits [`TimelineEvent::ClipTrimRequested`] for a finished trim. + fn finish_trim_drag(&mut self, drag: &Arc>, cx: &mut Context) { + let (clip, edge, original_frame, new_frame) = { + let drag = drag.read().expect("trim drag lock is not poisoned"); + (drag.clip, drag.edge, drag.original_frame, drag.new_frame) + }; + if new_frame != original_frame { + cx.emit(TimelineEvent::ClipTrimRequested { + clip, + edge, + new_frame, + }); + cx.notify(); + } + } + + /// Emits [`TimelineEvent::TrackHeightChanged`] for a finished resize. + fn finish_height_drag(&mut self, drag: &Arc>, cx: &mut Context) { + let (track, start_height, new_height) = { + let drag = drag.read().expect("height drag lock is not poisoned"); + (drag.track, drag.start_height, drag.new_height) + }; + if new_height.0 != start_height.0 { + cx.emit(TimelineEvent::TrackHeightChanged { track, height: new_height }); + cx.notify(); + } + } + + /// Emits [`TimelineEvent::SelectionChanged`] for a finished marquee. + fn finish_marquee(&mut self, cx: &mut Context) { + cx.emit(TimelineEvent::SelectionChanged); + cx.notify(); + } + + // --- interaction handlers (private) --- + // + // The gesture logic lives in the render method's inline listeners and + // the `update_*` / `finish_*` helpers above; these signatures are + // retained (with `#[allow(dead_code)]`) as documentation of the + // gesture set the view wires up. + + #[allow(dead_code)] // gesture documentation; see the section comment above + fn on_ruler_mouse_down(&mut self, _window: &mut Window, _cx: &mut Context) {} + + #[allow(dead_code)] // gesture documentation; see the section comment above + fn on_clip_drag_move(&mut self, _clip: ClipId, _window: &mut Window, _cx: &mut Context) {} + + #[allow(dead_code)] // gesture documentation; see the section comment above + fn on_clip_drag_drop(&mut self, _clip: ClipId, _window: &mut Window, _cx: &mut Context) {} + + #[allow(dead_code)] // gesture documentation; see the section comment above + fn on_trim_drag(&mut self, _clip: ClipId, _edge: TrimEdge, _window: &mut Window, _cx: &mut Context) {} + + #[allow(dead_code)] // gesture documentation; see the section comment above + fn on_marquee(&mut self, _window: &mut Window, _cx: &mut Context) {} + + #[allow(dead_code)] // gesture documentation; see the section comment above + fn on_scroll(&mut self, _window: &mut Window, _cx: &mut Context) {} +} + +impl Focusable for TimelineView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl EventEmitter for TimelineView {} + +impl Render for TimelineView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let state = self.state.clone(); + let source = self.source.read(cx); + let frame_rate = source.frame_rate(); + let seq_len = source.sequence_length(); + + // Snapshot the rows: one per track, stacked from the top of the clip + // area, with y positions accumulated from the model's track heights. + let mut rows: Vec = Vec::new(); + let mut y = 0.0; + for index in 0..source.track_count() { + if let Some(track) = source.track(index) { + let height = track.height().0.max(MIN_TRACK_HEIGHT); + let clips = track + .clips() + .iter() + .map(|clip| { + let color = clip.color().unwrap_or_else(|| kind_color(track.kind())); + let in_transition = clip + .in_transition() + .filter(|duration| duration.0 > 0) + .map(|duration| FrameRange::new(Frame::ZERO, duration)); + let out_transition = clip + .out_transition() + .filter(|duration| duration.0 > 0) + .map(|duration| FrameRange::new(Frame::ZERO, duration)); + ClipRenderData { + id: clip.id(), + range: clip.range(), + label: clip.label(), + color, + enabled: clip.is_enabled(), + in_transition, + out_transition, + } + }) + .collect(); + rows.push(RowData { + index, + name: track.name(), + kind: track.kind(), + height, + y, + locked: track.is_locked(), + muted: track.is_muted(), + solo: track.is_solo(), + visible: track.is_visible(), + clips, + }); + y += height; + } + } + + // The marquee handler needs the row geometry; keep a snapshot for it + // (the clip-area child iterator consumes `rows` below). + let marquee_rows = Arc::new(rows.clone()); + let playhead_x = state.point_at_frame(state.playhead).0; + let decorator: Arc> = Arc::new(RwLock::new(NoopClipDecorator)); + + let ruler = div() + .flex_row() + .h(px(32.)) + .flex_shrink_0() + .child(div().w(px(HEADER_WIDTH)).flex_shrink_0()) + .child( + div() + .flex_1() + .h_full() + .id("timeline-ruler") + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, event: &MouseDownEvent, _window, cx| { + // The ruler's left edge aligns with the clip + // area's, i.e. HEADER_WIDTH px from the window's + // left edge (this widget is expected to sit at + // window x = 0). + let frame = + this.state.frame_at_point(event.position.x - px(HEADER_WIDTH)); + this.seek(frame, cx); + }), + ) + .child(TimelineRuler::new(state.clone(), frame_rate, seq_len)), + ); + + let headers = div() + .w(px(HEADER_WIDTH)) + .flex_shrink_0() + .flex_col() + .id("timeline-track-headers") + .on_drag_move( + cx.listener( + |this, event: &DragMoveEvent>>, _window, cx| { + this.update_height_drag(event, cx); + }, + ), + ) + .on_drop( + cx.listener(|this, drag: &Arc>, _window, cx| { + this.finish_height_drag(drag, cx); + }), + ) + .children(rows.iter().map(|row| { + let height = row.height; + let separator_y = row.y + height - TrackHeader::SEPARATOR_HEIGHT; + div() + .h(px(height)) + .relative() + .child( + TrackHeader::new(row.index, row.name.clone(), row.kind) + .locked(row.locked) + .muted(row.muted) + .solo(row.solo) + .visible(row.visible), + ) + .child( + div() + .absolute() + .left(px(0.)) + .right(px(0.)) + .bottom(px(0.)) + .h(px(TrackHeader::SEPARATOR_HEIGHT)) + .id(ElementId::named_usize("timeline-track-resize", row.index)) + .cursor_row_resize() + .on_drag( + Arc::new(RwLock::new(HeightDrag { + track: row.index, + start_height: px(height), + new_height: px(height), + separator_y, + })), + drag_ghost, + ), + ) + })); + + let clip_area = div() + .flex_1() + .h_full() + .id("timeline-clip-area") + .relative() + .overflow_hidden() + .flex_col() + .on_scroll_wheel( + cx.listener(|this, event: &ScrollWheelEvent, _window, cx| { + if event.modifiers.control || event.modifiers.platform { + let anchor = event.position.x - px(HEADER_WIDTH); + let factor = match event.delta { + ScrollDelta::Pixels(delta) => 1.0 + delta.y.0 * 0.01, + ScrollDelta::Lines(delta) => 1.0 + delta.y * 0.01, + } + .clamp(0.5, 2.0); + let old = this.state.zoom; + this.state.set_zoom(old * factor, anchor); + if (this.state.zoom - old).abs() > f32::EPSILON { + cx.emit(TimelineEvent::ZoomChanged(this.state.zoom)); + } + } else { + let dx = match event.delta { + ScrollDelta::Pixels(delta) => delta.y.0, + ScrollDelta::Lines(delta) => delta.y * 24.0, + }; + this.state.scroll_offset.x = px((this.state.scroll_offset.x.0 + dx).max(0.0)); + } + cx.notify(); + }), + ) + .on_pinch( + cx.listener(|this, event: &PinchEvent, _window, cx| { + let anchor = event.position.x - px(HEADER_WIDTH); + let old = this.state.zoom; + this.state.set_zoom(old * (1.0 + event.delta), anchor); + if (this.state.zoom - old).abs() > f32::EPSILON { + cx.emit(TimelineEvent::ZoomChanged(this.state.zoom)); + } + cx.notify(); + }), + ) + .on_drag(MarqueeDrag, drag_ghost) + .on_drag_move( + cx.listener( + |this, event: &DragMoveEvent>>, _window, cx| { + this.update_clip_drag(event, cx); + }, + ), + ) + .on_drag_move( + cx.listener( + |this, event: &DragMoveEvent>>, _window, cx| { + this.update_trim_drag(event, cx); + }, + ), + ) + .on_drag_move({ + cx.listener( + move |this, event: &DragMoveEvent, _window, cx| { + this.update_marquee(event, marquee_rows.as_slice(), cx); + }, + ) + }) + .on_drop( + cx.listener(|this, drag: &Arc>, _window, cx| { + this.finish_clip_drag(drag, cx); + }), + ) + .on_drop( + cx.listener(|this, drag: &Arc>, _window, cx| { + this.finish_trim_drag(drag, cx); + }), + ) + .on_drop( + cx.listener(|this, _drag: &MarqueeDrag, _window, cx| { + this.finish_marquee(cx); + }), + ) + .on_drop( + cx.listener(|this, drag: &Arc>, _window, cx| { + this.finish_height_drag(drag, cx); + }), + ) + .children(rows.into_iter().map(move |row| { + let height = row.height; + let row_locked = row.locked; + let kind = row.kind; + let row_index = row.index; + let state = &state; + let decorator = &decorator; + div() + .h(px(height)) + .relative() + .children(row.clips.into_iter().map(move |clip| { + let x0 = state.point_at_frame(clip.range.start).0; + let x1 = state.point_at_frame(clip.range.end).0; + let width = (x1 - x0).max(1.0); + let clip_height = (height - TrackHeader::SEPARATOR_HEIGHT).max(1.0); + let mut children: Vec = vec![ + ClipElement::new( + clip.id, + clip.label.clone(), + clip.color, + clip.in_transition, + clip.out_transition, + decorator.clone(), + ) + .selected(state.is_selected(clip.id)) + .enabled(clip.enabled) + .locked(row_locked) + .content(match kind { + TrackKind::Video => ClipContent::Thumbnails, + TrackKind::Audio => ClipContent::Waveform, + TrackKind::Subtitle => ClipContent::None, + }) + .into_any_element(), + ]; + if !row_locked { + children.push( + div() + .absolute() + .left(px(0.)) + .top(px(0.)) + .bottom(px(0.)) + .w(px(TRIM_HANDLE_WIDTH)) + .id(ElementId::named_usize( + "timeline-trim-start", + clip.id.0 as usize, + )) + .cursor_ew_resize() + .on_drag( + Arc::new(RwLock::new(TrimDrag { + clip: clip.id, + edge: TrimEdge::Start, + original_frame: clip.range.start, + new_frame: clip.range.start, + })), + drag_ghost, + ) + .into_any_element(), + ); + children.push( + div() + .absolute() + .right(px(0.)) + .top(px(0.)) + .bottom(px(0.)) + .w(px(TRIM_HANDLE_WIDTH)) + .id(ElementId::named_usize( + "timeline-trim-end", + clip.id.0 as usize, + )) + .cursor_ew_resize() + .on_drag( + Arc::new(RwLock::new(TrimDrag { + clip: clip.id, + edge: TrimEdge::End, + original_frame: clip.range.end, + new_frame: clip.range.end, + })), + drag_ghost, + ) + .into_any_element(), + ); + } + div() + .absolute() + .left(px(x0)) + .top(px(0.)) + .w(px(width)) + .h(px(clip_height)) + .id(ElementId::named_usize("timeline-clip", clip.id.0 as usize)) + .on_drag( + Arc::new(RwLock::new(ClipDrag { + clip: clip.id, + original_start: clip.range.start, + original_track: row_index, + new_start: clip.range.start, + new_track: row_index, + })), + drag_ghost, + ) + .children(children) + })) + })); + + let playhead = div() + .absolute() + .left(px(HEADER_WIDTH + playhead_x)) + .top(px(0.)) + .bottom(px(0.)) + .w(px(1.)) + .child(PlayheadElement::new(px(0.), playhead_color())); + + div() + .size_full() + .flex() + .flex_col() + .child(ruler) + .child( + div() + .flex_row() + .flex_1() + .relative() + .child(headers) + .child(clip_area) + .child(playhead), + ) + } +} + +/// Shared state for a clip-move gesture (the clip wrapper starts it, the +/// clip area updates and finishes it). +struct ClipDrag { + clip: ClipId, + original_start: Frame, + original_track: usize, + new_start: Frame, + new_track: usize, +} + +/// Shared state for a trim gesture; see [`TrimEdge`]. +struct TrimDrag { + clip: ClipId, + edge: TrimEdge, + original_frame: Frame, + new_frame: Frame, +} + +/// Shared state for a track-height resize gesture. +struct HeightDrag { + track: usize, + start_height: Pixels, + new_height: Pixels, + /// The separator strip's top edge, relative to the headers column's top. + separator_y: f32, +} + +/// Marker type for a marquee (rubber-band) selection gesture. +struct MarqueeDrag; + +/// Layout snapshot of one track row, captured during render. +#[derive(Clone)] +struct RowData { + index: usize, + name: SharedString, + kind: TrackKind, + height: f32, + y: f32, + locked: bool, + muted: bool, + solo: bool, + visible: bool, + clips: Vec, +} + +/// Layout snapshot of one clip, captured during render. +#[derive(Clone)] +struct ClipRenderData { + id: ClipId, + range: FrameRange, + label: SharedString, + color: Hsla, + enabled: bool, + in_transition: Option, + out_transition: Option, +} + +/// The ghost rendered under the cursor during any timeline drag. +struct DragPreview; + +impl Render for DragPreview { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().bg(hsla(0.6, 0.7, 0.9, 0.35)) + } +} + +/// Builds the drag ghost view for a drag of any value type. +fn drag_ghost( + _drag: &T, + _offset: Point, + _window: &mut Window, + cx: &mut App, +) -> Entity { + cx.new(|_cx| DragPreview) +} + +/// The playhead line color. +fn playhead_color() -> Hsla { + hsla(0.0, 0.0, 0.9, 0.9) +} + +/// The default body color for clips on a track of `kind`. +fn kind_color(kind: TrackKind) -> Hsla { + match kind { + TrackKind::Video => hsla(0.58, 0.45, 0.35, 1.0), + TrackKind::Audio => hsla(0.35, 0.45, 0.35, 1.0), + TrackKind::Subtitle => hsla(0.10, 0.45, 0.35, 1.0), + } +} diff --git a/crates/gpui/src/timeline/track_header.rs b/crates/gpui/src/timeline/track_header.rs new file mode 100644 index 0000000000..cebb1c5d35 --- /dev/null +++ b/crates/gpui/src/timeline/track_header.rs @@ -0,0 +1,196 @@ +//! Track headers: the per-track control column left of the clip area. +//! +//! One [`TrackHeader`] per track shows the track name and the toggle buttons +//! appropriate for its [`TrackKind`], and hosts the drag separator that +//! resizes the track's height. +//! +//! # Toggles per track kind +//! +//! | [`TrackKind`] | Buttons | +//! |---------------------------|-----------------------------| +//! | [`TrackKind::Video`] | lock, show (visibility) | +//! | [`TrackKind::Audio`] | lock, mute, solo | +//! | [`TrackKind::Subtitle`] | lock, show | +//! +//! Toggles emit [`TrackHeaderEvent`]s; like every other edit surface of this +//! module the header never mutates the model — the host applies the change +//! through its engine and the next data read reflects it. +//! +//! The element is purely visual (like [`TimelineRuler`](super::TimelineRuler)): +//! it paints the name and the toggle state glyphs, while the click handlers +//! that turn a press into a [`TrackHeaderEvent`] are attached by +//! [`TimelineView`](super::TimelineView)'s interactive wrapper. + +use crate::{App, Hsla, SharedString, Window, div, hsla, px, prelude::*}; + +use super::data::TrackKind; + +/// A user action on a track header's controls. +/// +/// The host applies these to its track state (Oak: the facade's track +/// lock/mute/solo/show setters, wrapped in undo where the engine treats them +/// as undoable) and notifies the data source. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TrackHeaderEvent { + /// Lock toggle requested. Locked tracks reject all clip edits. + ToggleLock, + /// Mute toggle requested (audio tracks). + ToggleMute, + /// Solo toggle requested (audio tracks). + ToggleSolo, + /// Visibility (show) toggle requested (video/subtitle tracks). + ToggleVisibility, +} + +/// The header control for one track, rendered in the left column. +/// +/// The header fills its cell in the view's row layout; the bottom +/// [`Self::SEPARATOR_HEIGHT`] pixels are the height-resize drag zone. +#[derive(IntoElement)] +pub struct TrackHeader { + index: usize, + name: SharedString, + kind: TrackKind, + locked: bool, + muted: bool, + solo: bool, + visible: bool, +} + +impl TrackHeader { + /// Height of the drag separator zone at the header's bottom edge, in + /// pixels. + pub const SEPARATOR_HEIGHT: f32 = 4.0; + + /// Creates a header for the track at `index` from its + /// [`TrackData`](super::TrackData) snapshot. + pub fn new(index: usize, name: SharedString, kind: TrackKind) -> Self { + TrackHeader { + index, + name, + kind, + locked: false, + muted: false, + solo: false, + visible: true, + } + } + + /// Builder: current lock state (drives the lock button's active style). + pub fn locked(mut self, locked: bool) -> Self { + self.locked = locked; + self + } + + /// Builder: current mute state. Only meaningful for + /// [`TrackKind::Audio`]; ignored otherwise. + pub fn muted(mut self, muted: bool) -> Self { + self.muted = muted; + self + } + + /// Builder: current solo state. Only meaningful for + /// [`TrackKind::Audio`]; ignored otherwise. + pub fn solo(mut self, solo: bool) -> Self { + self.solo = solo; + self + } + + /// Builder: current visibility (show) state. Only meaningful for + /// [`TrackKind::Video`] and [`TrackKind::Subtitle`]; ignored otherwise. + pub fn visible(mut self, visible: bool) -> Self { + self.visible = visible; + self + } + + /// Index of the track this header controls. + pub fn track_index(&self) -> usize { + self.index + } + + /// The background tint for a track of `kind`. + fn kind_background(kind: TrackKind) -> Hsla { + match kind { + TrackKind::Video => hsla(0.58, 0.45, 0.32, 0.18), + TrackKind::Audio => hsla(0.35, 0.45, 0.32, 0.18), + TrackKind::Subtitle => hsla(0.10, 0.45, 0.32, 0.18), + } + } + + /// The label color for a track of `kind`. + fn kind_text(kind: TrackKind) -> Hsla { + match kind { + TrackKind::Video => hsla(0.58, 0.35, 0.85, 1.0), + TrackKind::Audio => hsla(0.35, 0.35, 0.85, 1.0), + TrackKind::Subtitle => hsla(0.10, 0.35, 0.85, 1.0), + } + } + + /// A small toggle glyph (one or two letters) reflecting `active`. + fn toggle_glyph(&self, label: &str, active: bool) -> impl IntoElement { + div() + .px_1() + .rounded(px(3.)) + .text_xs() + .font_weight(if active { + crate::FontWeight::BOLD + } else { + crate::FontWeight::NORMAL + }) + .text_color(if active { + hsla(0.63, 0.6, 0.65, 1.0) + } else { + hsla(0.0, 0.0, 0.5, 0.55) + }) + .child(label.to_string()) + } + + /// The kind-appropriate toggle glyphs, left of the separator. + fn toggle_row(&self) -> impl IntoElement { + let lock = self.toggle_glyph("L", self.locked); + match self.kind { + TrackKind::Audio => { + div().flex().flex_row().items_center().gap(px(3.)).child(lock).child( + self.toggle_glyph("M", self.muted), + ).child(self.toggle_glyph("S", self.solo)) + } + TrackKind::Video | TrackKind::Subtitle => { + div().flex().flex_row().items_center().gap(px(3.)).child(lock).child( + self.toggle_glyph("V", self.visible), + ) + } + } + } +} + +impl RenderOnce for TrackHeader { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let background = Self::kind_background(self.kind); + let text = Self::kind_text(self.kind); + let separator_height = px(TrackHeader::SEPARATOR_HEIGHT); + + div() + .size_full() + .bg(background) + .flex() + .flex_col() + .child( + div() + .flex_1() + .flex() + .flex_row() + .items_center() + .gap(px(6.)) + .px_2() + .child(div().text_sm().text_color(text).child(self.name.clone())) + .child(div().flex_1()) + .child(self.toggle_row()), + ) + .child( + div() + .h(separator_height) + .w_full() + .bg(hsla(0.0, 0.0, 0.5, 0.25)), + ) + } +}