feat(gpui): add dock, effect stack, node graph, and timeline widgets
Implements four workspace widget modules with accompanying learn examples: - dock: dockable panel layout system (tabs, splits, drag-to-dock) with serde-based persistence via PanelRegistry / DockLayoutState - effect_stack: linear effect-stack inspector widget - node_graph: node-graph editor (nodes, ports, wires, pan/zoom canvas) - timeline: video-editing timeline (tracks, clips, ruler, playhead) Timeline snapping prefers the earlier frame when two snap points are equally close, with SnapKind priority breaking same-frame ties.
This commit is contained in:
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Dock Layout Example (intended-usage sketch)
|
||||
//!
|
||||
//! Sketch of how the Oak video editor will wire up `gpui::dock`: an app with
|
||||
//! a single [`DockArea`] hosting four placeholder panels — project bin,
|
||||
//! viewer, inspector, and timeline — plus a [`PanelRegistry`] so layouts can
|
||||
//! be saved and restored.
|
||||
//!
|
||||
//! NOTE: the dock implementation is not finished yet (all of its methods are
|
||||
//! `todo!()`), so this example compiles but panics at runtime until the
|
||||
//! implementation lands.
|
||||
|
||||
// The modules under demo are skeletons whose bodies are `todo!()` by design.
|
||||
#![allow(clippy::todo)]
|
||||
|
||||
#[path = "../shared/prelude.rs"]
|
||||
mod example_prelude;
|
||||
|
||||
use example_prelude::init_example;
|
||||
use gpui::dock::{
|
||||
DockArea, DockEvent, DockLayoutState, DockPanel, PanelEvent, PanelHandle, PanelId,
|
||||
PanelRegistry,
|
||||
};
|
||||
use gpui::{
|
||||
AnyElement, App, Bounds, Context, Entity, EventEmitter, Render, SharedString, Window,
|
||||
WindowBounds, WindowOptions, div, prelude::*, px, size,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
// ============================================================================
|
||||
// Demo panels
|
||||
//
|
||||
// Each placeholder is a normal GPUI view plus a `DockPanel` impl. The panel
|
||||
// ids double as the persistence mapping below.
|
||||
// ============================================================================
|
||||
|
||||
const PROJECT_BIN_ID: PanelId = PanelId::new(1);
|
||||
const VIEWER_ID: PanelId = PanelId::new(2);
|
||||
const INSPECTOR_ID: PanelId = PanelId::new(3);
|
||||
const TIMELINE_ID: PanelId = PanelId::new(4);
|
||||
|
||||
/// Shared shape of the demo placeholders: a labeled box.
|
||||
struct PlaceholderPanel {
|
||||
id: PanelId,
|
||||
title: &'static str,
|
||||
}
|
||||
|
||||
impl PlaceholderPanel {
|
||||
fn new(id: PanelId, title: &'static str) -> Self {
|
||||
Self { id, title }
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for PlaceholderPanel {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(format!("{} (placeholder)", self.title))
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for PlaceholderPanel {}
|
||||
|
||||
impl DockPanel for PlaceholderPanel {
|
||||
fn panel_id(&self) -> PanelId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> SharedString {
|
||||
self.title.into()
|
||||
}
|
||||
|
||||
fn tab_content(&self, _cx: &App) -> AnyElement {
|
||||
div().child(self.title).into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Panel registry: bridges string keys (persisted) and live panel views.
|
||||
// ============================================================================
|
||||
|
||||
struct DemoPanelRegistry;
|
||||
|
||||
impl PanelRegistry for DemoPanelRegistry {
|
||||
fn panel_key(&self, id: PanelId) -> Option<String> {
|
||||
match id {
|
||||
PROJECT_BIN_ID => Some("project-bin".into()),
|
||||
VIEWER_ID => Some("viewer".into()),
|
||||
INSPECTOR_ID => Some("inspector".into()),
|
||||
TIMELINE_ID => Some("timeline".into()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_panel(&self, key: &str, _window: &mut Window, cx: &mut App) -> Option<PanelHandle> {
|
||||
let (id, title) = match key {
|
||||
"project-bin" => (PROJECT_BIN_ID, "Project Bin"),
|
||||
"viewer" => (VIEWER_ID, "Viewer"),
|
||||
"inspector" => (INSPECTOR_ID, "Inspector"),
|
||||
"timeline" => (TIMELINE_ID, "Timeline"),
|
||||
_ => return None,
|
||||
};
|
||||
Some(PanelHandle::new(
|
||||
cx.new(|_| PlaceholderPanel::new(id, title)),
|
||||
cx,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Root view: just hosts the dock area and logs its events.
|
||||
// ============================================================================
|
||||
|
||||
struct DockLayoutExample {
|
||||
dock: Entity<DockArea>,
|
||||
}
|
||||
|
||||
impl DockLayoutExample {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let dock = cx.new(|cx| DockArea::new(cx).with_registry(Arc::new(DemoPanelRegistry)));
|
||||
|
||||
// Seed a default workspace. Once the dock is implemented this will
|
||||
// instead attempt `restore_state` from a persisted `DockLayoutState`
|
||||
// first, falling back to this default when none exists.
|
||||
let panels: Vec<PanelHandle> = ["project-bin", "viewer", "inspector", "timeline"]
|
||||
.into_iter()
|
||||
.filter_map(|key| DemoPanelRegistry.build_panel(key, window, cx))
|
||||
.collect();
|
||||
dock.update(cx, |dock, cx| {
|
||||
for panel in panels {
|
||||
dock.add_panel(panel, None, cx);
|
||||
}
|
||||
});
|
||||
|
||||
// Autosave hook: persist on every layout change.
|
||||
cx.subscribe(&dock, |_this, dock: Entity<DockArea>, event: &DockEvent, cx| {
|
||||
if let DockEvent::LayoutChanged = event {
|
||||
let _state: DockLayoutState = dock.read(cx).save_state();
|
||||
todo!("serialize `_state` with serde_json and write it to the app data dir");
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self { dock }
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for DockLayoutExample {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().size_full().child(self.dock.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
gpui_platform::application().run(|cx: &mut App| {
|
||||
let bounds = Bounds::centered(None, size(px(1200.), px(800.)), cx);
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
..Default::default()
|
||||
},
|
||||
|window, cx| cx.new(|cx| DockLayoutExample::new(window, cx)),
|
||||
)
|
||||
.expect("Failed to open window");
|
||||
|
||||
init_example(cx, "Dock Layout");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Intended-usage sketch of the `gpui::effect_stack` widget.
|
||||
//!
|
||||
//! Builds a mock stack (Media → Transform → OCIO LUT → Output), hosts an
|
||||
//! [`EffectStackView`], and logs every edit request it emits. The mock data
|
||||
//! source simply applies requests in place and calls `cx.notify()`; a real
|
||||
//! app (Oak) would route them through its engine and undo stack.
|
||||
//!
|
||||
//! NOTE: the widget implementation itself is still a skeleton (`todo!()`),
|
||||
//! so this example compiles but does not render yet.
|
||||
|
||||
// The modules under demo are skeletons whose bodies are `todo!()` by design.
|
||||
#![allow(clippy::todo)]
|
||||
|
||||
#[path = "../shared/prelude.rs"]
|
||||
mod example_prelude;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use example_prelude::init_example;
|
||||
use gpui::effect_stack::{
|
||||
EffectCardKind, EffectData, EffectId, EffectStackDataSource, EffectStackEvent, EffectStackView,
|
||||
};
|
||||
use gpui::{
|
||||
App, Bounds, Context, Entity, Render, SharedString, Window, WindowBounds, WindowOptions, div,
|
||||
prelude::*, px, size,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct MockEffect {
|
||||
id: EffectId,
|
||||
kind: EffectCardKind,
|
||||
title: String,
|
||||
subtitle: Option<String>,
|
||||
enabled: bool,
|
||||
expanded: bool,
|
||||
badge: Option<usize>,
|
||||
}
|
||||
|
||||
impl EffectData for MockEffect {
|
||||
fn id(&self) -> EffectId {
|
||||
self.id
|
||||
}
|
||||
fn kind(&self) -> EffectCardKind {
|
||||
self.kind
|
||||
}
|
||||
fn title(&self) -> SharedString {
|
||||
self.title.clone().into()
|
||||
}
|
||||
fn subtitle(&self) -> Option<SharedString> {
|
||||
self.subtitle.clone().map(Into::into)
|
||||
}
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
fn is_expanded(&self) -> bool {
|
||||
self.expanded
|
||||
}
|
||||
fn badge_count(&self) -> Option<usize> {
|
||||
self.badge
|
||||
}
|
||||
}
|
||||
|
||||
/// The mock data source. In Oak this would be a view-model entity deriving
|
||||
/// the ordered card list from the node-graph path of the selected clip.
|
||||
struct MockStack {
|
||||
clip_name: String,
|
||||
effects: Vec<MockEffect>,
|
||||
/// Reserved for allocating ids to effects added at runtime.
|
||||
#[allow(dead_code)]
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl MockStack {
|
||||
fn demo() -> Self {
|
||||
Self {
|
||||
clip_name: "A001_C002_0103.mov".to_string(),
|
||||
effects: vec![
|
||||
MockEffect {
|
||||
id: EffectId(0),
|
||||
kind: EffectCardKind::Source,
|
||||
title: "Media".into(),
|
||||
subtitle: Some("A001_C002_0103.mov".into()),
|
||||
enabled: true,
|
||||
expanded: false,
|
||||
badge: None,
|
||||
},
|
||||
MockEffect {
|
||||
id: EffectId(1),
|
||||
kind: EffectCardKind::Effect,
|
||||
title: "Transform".into(),
|
||||
subtitle: Some("scale 100%, rotate 0°".into()),
|
||||
enabled: true,
|
||||
expanded: true,
|
||||
badge: Some(2),
|
||||
},
|
||||
MockEffect {
|
||||
id: EffectId(2),
|
||||
kind: EffectCardKind::Effect,
|
||||
title: "OCIO LUT".into(),
|
||||
subtitle: Some("filmic_to_display.cube".into()),
|
||||
enabled: true,
|
||||
expanded: false,
|
||||
badge: None,
|
||||
},
|
||||
MockEffect {
|
||||
id: EffectId(3),
|
||||
kind: EffectCardKind::Output,
|
||||
title: "Output".into(),
|
||||
subtitle: None,
|
||||
enabled: true,
|
||||
expanded: false,
|
||||
badge: None,
|
||||
},
|
||||
],
|
||||
next_id: 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EffectStackDataSource for MockStack {
|
||||
fn effects(&self) -> Vec<Arc<dyn EffectData>> {
|
||||
self.effects
|
||||
.iter()
|
||||
.map(|effect| {
|
||||
Arc::new(MockEffect {
|
||||
id: effect.id,
|
||||
kind: effect.kind,
|
||||
title: effect.title.clone(),
|
||||
subtitle: effect.subtitle.clone(),
|
||||
enabled: effect.enabled,
|
||||
expanded: effect.expanded,
|
||||
badge: effect.badge,
|
||||
}) as Arc<dyn EffectData>
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn target_label(&self) -> Option<SharedString> {
|
||||
Some(self.clip_name.clone().into())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Root view: hosts the stack and applies edit requests to the mock model.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Placeholder parameter view. A real app builds the effect's controls here
|
||||
/// and calls [`EffectStackView::notify_parameter_changed`] after edits.
|
||||
struct MockParams {
|
||||
effect: EffectId,
|
||||
}
|
||||
|
||||
impl Render for MockParams {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().child(format!("parameters for {} (mock)", self.effect))
|
||||
}
|
||||
}
|
||||
|
||||
struct StackDemoRoot {
|
||||
data: Entity<MockStack>,
|
||||
stack: Entity<EffectStackView<MockStack>>,
|
||||
}
|
||||
|
||||
impl StackDemoRoot {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let data = cx.new(|_cx| MockStack::demo());
|
||||
|
||||
let stack = cx.new(|cx| {
|
||||
EffectStackView::new(data.clone(), cx).params_renderer(|id, _window, cx| {
|
||||
// Real apps build the effect's parameter controls here. The
|
||||
// mock just shows a placeholder label.
|
||||
cx.new(|_cx| MockParams { effect: *id }).into()
|
||||
})
|
||||
});
|
||||
|
||||
// The "edits are requests" loop: log each request, apply it to the
|
||||
// model (Oak: engine command + undo), then notify.
|
||||
cx.subscribe_in(&stack, window, {
|
||||
let data = data.clone();
|
||||
move |_root, _stack, event: &EffectStackEvent, _window, cx| {
|
||||
println!("[effect_stack] request: {event:?}");
|
||||
data.update(cx, |data, cx| {
|
||||
match event {
|
||||
EffectStackEvent::EnableToggled { effect, enabled } => {
|
||||
if let Some(e) = data.effects.iter_mut().find(|e| e.id == *effect) {
|
||||
e.enabled = *enabled;
|
||||
}
|
||||
}
|
||||
EffectStackEvent::ExpansionToggled { effect, expanded } => {
|
||||
if let Some(e) = data.effects.iter_mut().find(|e| e.id == *effect) {
|
||||
e.expanded = *expanded;
|
||||
}
|
||||
}
|
||||
EffectStackEvent::ReorderRequested { .. }
|
||||
| EffectStackEvent::RemoveRequested(_)
|
||||
| EffectStackEvent::AddRequested { .. }
|
||||
| EffectStackEvent::ContextMenuRequested { .. }
|
||||
| EffectStackEvent::ParameterChanged { .. } => {
|
||||
todo!("apply {event:?} to the mock model (or engine, in a real app)")
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self { data, stack }
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for StackDemoRoot {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let _ = &self.data;
|
||||
let _ = cx;
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(self.stack.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
gpui_platform::application().run(|cx: &mut App| {
|
||||
init_example(cx, "Effect Stack");
|
||||
|
||||
let bounds = Bounds::centered(None, size(px(420.0), px(640.0)), cx);
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
..Default::default()
|
||||
},
|
||||
|window, cx| cx.new(|cx| StackDemoRoot::new(window, cx)),
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
//! Intended-usage sketch for the `gpui::node_graph` node-graph editor.
|
||||
//!
|
||||
//! Builds a mock video pipeline (media → transform → output), implements the
|
||||
//! data-source traits over it, and subscribes to the view's edit-request
|
||||
//! events, logging each one. The real Oak integration maps these events onto
|
||||
//! engine operations wrapped in undo commands — see the "Wiring into Oak"
|
||||
//! section of [`gpui::node_graph`].
|
||||
//!
|
||||
//! NOTE: the widget itself is still an API skeleton (`todo!()` bodies), so
|
||||
//! running this example will panic as soon as the view renders. It exists to
|
||||
//! pin down the intended usage and keep it compiling.
|
||||
|
||||
// The modules under demo are skeletons whose bodies are `todo!()` by design.
|
||||
#![allow(clippy::todo)]
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Context, Entity, Hsla, Pixels, Point, Render, SharedString, Window, WindowBounds,
|
||||
WindowOptions, div, point, prelude::*, px, size,
|
||||
};
|
||||
use gpui::node_graph::{
|
||||
EdgeData, EdgeId, NodeData, NodeGraphDataSource, NodeGraphEvent, NodeGraphView, NodeId,
|
||||
PortData, PortDataType, PortId, PortKind,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock graph data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MockPort {
|
||||
id: PortId,
|
||||
kind: PortKind,
|
||||
label: &'static str,
|
||||
data_type: PortDataType,
|
||||
connected: bool,
|
||||
}
|
||||
|
||||
impl PortData for MockPort {
|
||||
fn id(&self) -> PortId {
|
||||
self.id
|
||||
}
|
||||
fn kind(&self) -> PortKind {
|
||||
self.kind
|
||||
}
|
||||
fn label(&self) -> SharedString {
|
||||
self.label.into()
|
||||
}
|
||||
fn data_type(&self) -> PortDataType {
|
||||
self.data_type.clone()
|
||||
}
|
||||
fn is_connected(&self) -> bool {
|
||||
self.connected
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MockNode {
|
||||
id: NodeId,
|
||||
title: &'static str,
|
||||
position: Point<Pixels>,
|
||||
inputs: Vec<MockPort>,
|
||||
outputs: Vec<MockPort>,
|
||||
header_color: Option<Hsla>,
|
||||
}
|
||||
|
||||
impl NodeData for MockNode {
|
||||
type Port = MockPort;
|
||||
|
||||
fn id(&self) -> NodeId {
|
||||
self.id
|
||||
}
|
||||
fn title(&self) -> SharedString {
|
||||
self.title.into()
|
||||
}
|
||||
fn position(&self) -> Point<Pixels> {
|
||||
self.position
|
||||
}
|
||||
fn inputs(&self) -> Vec<MockPort> {
|
||||
self.inputs.clone()
|
||||
}
|
||||
fn outputs(&self) -> Vec<MockPort> {
|
||||
self.outputs.clone()
|
||||
}
|
||||
fn header_color(&self) -> Option<Hsla> {
|
||||
self.header_color
|
||||
}
|
||||
fn is_collapsed(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn is_enabled(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MockEdge {
|
||||
id: EdgeId,
|
||||
from_node: NodeId,
|
||||
from_port: PortId,
|
||||
to_node: NodeId,
|
||||
to_port: PortId,
|
||||
}
|
||||
|
||||
impl EdgeData for MockEdge {
|
||||
fn id(&self) -> EdgeId {
|
||||
self.id
|
||||
}
|
||||
fn from_node(&self) -> NodeId {
|
||||
self.from_node
|
||||
}
|
||||
fn from_port(&self) -> PortId {
|
||||
self.from_port
|
||||
}
|
||||
fn to_node(&self) -> NodeId {
|
||||
self.to_node
|
||||
}
|
||||
fn to_port(&self) -> PortId {
|
||||
self.to_port
|
||||
}
|
||||
}
|
||||
|
||||
/// The mock pipeline: media → transform → output.
|
||||
struct MockGraph {
|
||||
nodes: Vec<MockNode>,
|
||||
edges: Vec<MockEdge>,
|
||||
}
|
||||
|
||||
impl MockGraph {
|
||||
fn new() -> Self {
|
||||
let video = PortDataType::new("video", Hsla::blue());
|
||||
// Port id packing: node id in the high bits, port index low. Inputs
|
||||
// and outputs share one index space per node.
|
||||
let port = |node: u64, index: u64| PortId((node << 32) | index);
|
||||
|
||||
let media = MockNode {
|
||||
id: NodeId(1),
|
||||
title: "Media",
|
||||
position: point(px(40.), px(80.)),
|
||||
inputs: vec![],
|
||||
outputs: vec![MockPort {
|
||||
id: port(1, 0),
|
||||
kind: PortKind::Output,
|
||||
label: "video",
|
||||
data_type: video.clone(),
|
||||
connected: true,
|
||||
}],
|
||||
header_color: Some(Hsla::green()),
|
||||
};
|
||||
let transform = MockNode {
|
||||
id: NodeId(2),
|
||||
title: "Transform",
|
||||
position: point(px(320.), px(140.)),
|
||||
inputs: vec![MockPort {
|
||||
id: port(2, 0),
|
||||
kind: PortKind::Input,
|
||||
label: "in",
|
||||
data_type: video.clone(),
|
||||
connected: true,
|
||||
}],
|
||||
outputs: vec![MockPort {
|
||||
id: port(2, 1),
|
||||
kind: PortKind::Output,
|
||||
label: "out",
|
||||
data_type: video.clone(),
|
||||
connected: true,
|
||||
}],
|
||||
header_color: None,
|
||||
};
|
||||
let output = MockNode {
|
||||
id: NodeId(3),
|
||||
title: "Output",
|
||||
position: point(px(600.), px(200.)),
|
||||
inputs: vec![MockPort {
|
||||
id: port(3, 0),
|
||||
kind: PortKind::Input,
|
||||
label: "in",
|
||||
data_type: video,
|
||||
connected: true,
|
||||
}],
|
||||
outputs: vec![],
|
||||
header_color: Some(Hsla::red()),
|
||||
};
|
||||
|
||||
let edges = vec![
|
||||
MockEdge {
|
||||
id: EdgeId(1),
|
||||
from_node: NodeId(1),
|
||||
from_port: port(1, 0),
|
||||
to_node: NodeId(2),
|
||||
to_port: port(2, 0),
|
||||
},
|
||||
MockEdge {
|
||||
id: EdgeId(2),
|
||||
from_node: NodeId(2),
|
||||
from_port: port(2, 1),
|
||||
to_node: NodeId(3),
|
||||
to_port: port(3, 0),
|
||||
},
|
||||
];
|
||||
|
||||
Self {
|
||||
nodes: vec![media, transform, output],
|
||||
edges,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeGraphDataSource for MockGraph {
|
||||
type Node = MockNode;
|
||||
type Edge = MockEdge;
|
||||
|
||||
fn nodes(&self) -> Vec<MockNode> {
|
||||
self.nodes.clone()
|
||||
}
|
||||
fn edges(&self) -> Vec<MockEdge> {
|
||||
self.edges.clone()
|
||||
}
|
||||
fn can_connect(&self, _from: PortId, _to: PortId) -> bool {
|
||||
// A real app checks type compatibility, cycles and cardinality here.
|
||||
// The mock allows everything between distinct ports.
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App view: hosts the graph view and logs edit requests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct NodeGraphExample {
|
||||
graph: Entity<MockGraph>,
|
||||
view: Entity<NodeGraphView<MockGraph>>,
|
||||
}
|
||||
|
||||
impl NodeGraphExample {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let graph = cx.new(|_cx| MockGraph::new());
|
||||
let view = cx.new(|cx| NodeGraphView::new(graph.clone(), window, cx));
|
||||
|
||||
// In Oak, each event becomes an engine operation wrapped in an undo
|
||||
// command; here we just log the request.
|
||||
cx.subscribe(&view, |_this, _view, event: &NodeGraphEvent, cx| {
|
||||
println!("[node_graph] edit request: {event:?}");
|
||||
// After applying a request to the model, notify so the view
|
||||
// re-reads it, e.g.: `graph.update(cx, |_, cx| cx.notify())`.
|
||||
let _ = cx;
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self { graph, view }
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for NodeGraphExample {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let _ = &self.graph;
|
||||
div().size_full().child(self.view.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
gpui_platform::application().run(|cx: &mut App| {
|
||||
let bounds = Bounds::centered(None, size(px(900.), px(600.)), cx);
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
..Default::default()
|
||||
},
|
||||
|window, cx| cx.new(|cx| NodeGraphExample::new(window, cx)),
|
||||
)
|
||||
.expect("failed to open window");
|
||||
cx.activate(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Demo of the `gpui::timeline` video-editing timeline widget.
|
||||
//!
|
||||
//! This is the intended-usage sketch: a mock [`TimelineDataSource`] with two
|
||||
//! video and two audio tracks carrying a handful of static clips, a
|
||||
//! [`TimelineView`] placed in a window, and an event subscription that logs
|
||||
//! the edit requests the widget emits.
|
||||
//!
|
||||
//! In a real host (Oak), the `match` arm in `TimelineExample::new` is where
|
||||
//! each [`TimelineEvent`] becomes an undoable engine command, followed by a
|
||||
//! `cx.notify()` on the model entity.
|
||||
//!
|
||||
//! NOTE: the timeline's rendering and interaction internals are still
|
||||
//! `todo!()`; this example compiles and shows the wiring, not a usable UI.
|
||||
|
||||
// The modules under demo are skeletons whose bodies are `todo!()` by design.
|
||||
#![allow(clippy::todo)]
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Context, Entity, Pixels, Render, SharedString, Window, WindowBounds,
|
||||
WindowOptions, div, prelude::*, px, size,
|
||||
};
|
||||
use gpui::timeline::{
|
||||
ClipData, ClipId, Frame, FrameRange, FrameRate, TimelineDataSource, TimelineEvent,
|
||||
TimelineView, TrackData, TrackKind,
|
||||
};
|
||||
|
||||
#[path = "../shared/prelude.rs"]
|
||||
mod example_prelude;
|
||||
|
||||
// --- mock model ------------------------------------------------------------
|
||||
|
||||
struct MockClip {
|
||||
id: ClipId,
|
||||
range: FrameRange,
|
||||
media_in: Frame,
|
||||
label: SharedString,
|
||||
}
|
||||
|
||||
impl ClipData for MockClip {
|
||||
fn id(&self) -> ClipId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn range(&self) -> FrameRange {
|
||||
self.range
|
||||
}
|
||||
|
||||
fn media_in(&self) -> Frame {
|
||||
self.media_in
|
||||
}
|
||||
|
||||
fn label(&self) -> SharedString {
|
||||
self.label.clone()
|
||||
}
|
||||
}
|
||||
|
||||
struct MockTrack {
|
||||
kind: TrackKind,
|
||||
name: SharedString,
|
||||
height: Pixels,
|
||||
clips: Vec<MockClip>,
|
||||
}
|
||||
|
||||
impl TrackData for MockTrack {
|
||||
type Clip = MockClip;
|
||||
|
||||
fn kind(&self) -> TrackKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
fn name(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn height(&self) -> Pixels {
|
||||
self.height
|
||||
}
|
||||
|
||||
fn clips(&self) -> &[Self::Clip] {
|
||||
&self.clips
|
||||
}
|
||||
}
|
||||
|
||||
struct MockSequence {
|
||||
tracks: Vec<MockTrack>,
|
||||
}
|
||||
|
||||
impl MockSequence {
|
||||
fn demo() -> Self {
|
||||
let clip = |id: u64, start: i64, end: i64, label: &str| MockClip {
|
||||
id: ClipId(id),
|
||||
range: FrameRange::new(Frame(start), Frame(end)),
|
||||
media_in: Frame::ZERO,
|
||||
label: label.into(),
|
||||
};
|
||||
MockSequence {
|
||||
tracks: vec![
|
||||
MockTrack {
|
||||
kind: TrackKind::Video,
|
||||
name: "V1".into(),
|
||||
height: px(64.),
|
||||
clips: vec![clip(1, 0, 240, "opening.mov"), clip(2, 240, 600, "b-roll.mp4")],
|
||||
},
|
||||
MockTrack {
|
||||
kind: TrackKind::Video,
|
||||
name: "V2".into(),
|
||||
height: px(64.),
|
||||
clips: vec![clip(3, 120, 300, "title.mov")],
|
||||
},
|
||||
MockTrack {
|
||||
kind: TrackKind::Audio,
|
||||
name: "A1".into(),
|
||||
height: px(48.),
|
||||
clips: vec![clip(4, 0, 600, "dialog.wav")],
|
||||
},
|
||||
MockTrack {
|
||||
kind: TrackKind::Audio,
|
||||
name: "A2".into(),
|
||||
height: px(48.),
|
||||
clips: vec![clip(5, 0, 480, "score.flac")],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TimelineDataSource for MockSequence {
|
||||
type Track = MockTrack;
|
||||
|
||||
fn frame_rate(&self) -> FrameRate {
|
||||
FrameRate::NTSC_2997
|
||||
}
|
||||
|
||||
fn sequence_length(&self) -> Frame {
|
||||
Frame(600)
|
||||
}
|
||||
|
||||
fn track_count(&self) -> usize {
|
||||
self.tracks.len()
|
||||
}
|
||||
|
||||
fn track(&self, index: usize) -> Option<Self::Track> {
|
||||
// A real host returns a lightweight snapshot; the mock simply
|
||||
// reports the track's existence. Returning an owned value here is
|
||||
// what the trait requires, so the mock clones its clips.
|
||||
self.tracks.get(index).map(|t| MockTrack {
|
||||
kind: t.kind,
|
||||
name: t.name.clone(),
|
||||
height: t.height,
|
||||
clips: t
|
||||
.clips
|
||||
.iter()
|
||||
.map(|c| MockClip {
|
||||
id: c.id,
|
||||
range: c.range,
|
||||
media_in: c.media_in,
|
||||
label: c.label.clone(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- the example view ------------------------------------------------------
|
||||
|
||||
struct TimelineExample {
|
||||
timeline: Entity<TimelineView<MockSequence>>,
|
||||
}
|
||||
|
||||
impl TimelineExample {
|
||||
fn new(model: Entity<MockSequence>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let timeline = cx.new(|cx| TimelineView::new(model, window, cx).zoom(2.0));
|
||||
cx.subscribe(
|
||||
&timeline,
|
||||
|_this, _timeline, event: &TimelineEvent, _cx| {
|
||||
// In Oak, each request becomes an undoable engine command
|
||||
// here, followed by `model.update(cx, |_, cx| cx.notify())`.
|
||||
println!("timeline edit request: {event:?}");
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
TimelineExample { timeline }
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for TimelineExample {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().size_full().child(self.timeline.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
gpui_platform::application().run(|cx: &mut App| {
|
||||
let bounds = Bounds::centered(None, size(px(1000.), px(480.)), cx);
|
||||
let model = cx.new(|_cx| MockSequence::demo());
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
..Default::default()
|
||||
},
|
||||
|window, cx| cx.new(|cx| TimelineExample::new(model, window, cx)),
|
||||
)
|
||||
.expect("Failed to open window");
|
||||
|
||||
example_prelude::init_example(cx, "Timeline");
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<Pixels>,
|
||||
}
|
||||
|
||||
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<WindowBounds>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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<Self>) -> 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))
|
||||
}
|
||||
}
|
||||
@@ -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<DockNode>,
|
||||
},
|
||||
/// A tab group showing one of several panels at a time.
|
||||
Tabs {
|
||||
/// Panels in tab order. Non-empty.
|
||||
panels: Vec<PanelId>,
|
||||
/// 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<Axis> {
|
||||
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<PanelId>,
|
||||
/// 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<usize>);
|
||||
|
||||
/// 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<DockNode>,
|
||||
}
|
||||
|
||||
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<NodePath> {
|
||||
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<usize>) -> Option<NodePath> {
|
||||
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<DropTarget>) -> 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<DockNode> {
|
||||
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<DockNode> = 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<PanelId> {
|
||||
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<PanelId>) {
|
||||
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<f32> {
|
||||
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<String> {
|
||||
/// 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<PanelHandle>
|
||||
/// {
|
||||
/// 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<String>;
|
||||
|
||||
/// 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<PanelHandle>;
|
||||
}
|
||||
|
||||
/// 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<SerializedNode>,
|
||||
/// 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<String, SerializedFloating>,
|
||||
}
|
||||
|
||||
/// 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<SerializedNode>,
|
||||
},
|
||||
/// Serialized [`DockNode::Tabs`]; `active` is an index into `panels`.
|
||||
Tabs {
|
||||
/// Registry keys of the tabbed panels, in tab order.
|
||||
panels: Vec<String>,
|
||||
/// 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<SerializedNode> {
|
||||
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<SerializedNode> = 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<DockNode> {
|
||||
match node {
|
||||
SerializedNode::Panel(key) => Some(DockNode::Panel(interim_id(key))),
|
||||
SerializedNode::Tabs { panels, active } => {
|
||||
if panels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let panels: Vec<PanelId> = 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<DockNode> = 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<String> {
|
||||
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<String>) {
|
||||
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())
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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<PanelEvent>`] 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<PanelEvent> 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<PanelEvent> + '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<Self>) -> 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<Self>) {}
|
||||
}
|
||||
|
||||
/// 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<Subscription>,
|
||||
}
|
||||
|
||||
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<P: DockPanel>(panel: Entity<P>, 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<Subscription> {
|
||||
&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<Subscription>) {
|
||||
self.subscription = subscription;
|
||||
}
|
||||
}
|
||||
@@ -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<Pixels>,
|
||||
}
|
||||
|
||||
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<Self>,
|
||||
) {
|
||||
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<Self>) {
|
||||
cx.emit(SplitHandleEvent::ResetRequested {
|
||||
path: self.path.clone(),
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<SplitHandleEvent> for SplitHandle {}
|
||||
|
||||
impl Render for SplitHandle {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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<Pixels>,
|
||||
_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<Self>) -> 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
|
||||
}
|
||||
}
|
||||
@@ -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<PanelId>,
|
||||
/// 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<Pixels>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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<PanelId>,
|
||||
/// 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<TabGeometry>,
|
||||
/// Tab titles from the last sync, rendered as the tab labels.
|
||||
titles: Vec<SharedString>,
|
||||
/// Per-tab closability from the last sync.
|
||||
closable: Vec<bool>,
|
||||
}
|
||||
|
||||
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<PanelId>, 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>,
|
||||
) {
|
||||
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<Pixels>) -> Option<usize> {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<TabBarEvent> for TabBar {}
|
||||
|
||||
impl Render for TabBar {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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::<PanelId>(cx.listener(|this, event: &DragMoveEvent<PanelId>, _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<Pixels>,
|
||||
_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<Self>) -> 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())
|
||||
}
|
||||
}
|
||||
@@ -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<SharedString>,
|
||||
enabled: bool,
|
||||
expanded: bool,
|
||||
removable: bool,
|
||||
reorderable: bool,
|
||||
badge_count: Option<usize>,
|
||||
params: Option<AnyView>,
|
||||
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<SharedString>) -> Self {
|
||||
self.title = title.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the optional muted secondary line (e.g. a LUT filename).
|
||||
pub fn subtitle(mut self, subtitle: Option<SharedString>) -> 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<usize>) -> 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<Pixels>,
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<u64> 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<SharedString> {
|
||||
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<usize> {
|
||||
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<Arc<dyn EffectData>>;
|
||||
|
||||
/// 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<SharedString>;
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
@@ -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<EffectStackView<_>>`](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::*;
|
||||
@@ -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<dyn Fn(&EffectId, &mut Window, &mut App) -> 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<Pixels>,
|
||||
},
|
||||
/// 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<EffectId>,
|
||||
/// Current insertion index (into the post-removal list) while dragging,
|
||||
/// if the pointer is over a valid drop position.
|
||||
pub insertion_index: Option<usize>,
|
||||
}
|
||||
|
||||
/// 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<EffectStackEvent>`].
|
||||
///
|
||||
/// # 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<D: EffectStackDataSource> {
|
||||
data: Entity<D>,
|
||||
params_renderer: Option<ParamsRenderer>,
|
||||
focus_handle: FocusHandle,
|
||||
drag_state: DragState,
|
||||
}
|
||||
|
||||
impl<D: EffectStackDataSource> EffectStackView<D> {
|
||||
/// 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<D>, cx: &mut Context<Self>) -> 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<D> {
|
||||
&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<Self>) {
|
||||
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<EffectId>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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>) {
|
||||
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>) {
|
||||
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>) {
|
||||
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>) {
|
||||
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<Pixels>, cx: &mut Context<Self>) {
|
||||
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<Self>) {
|
||||
let index = self.data.read(cx).effects().len();
|
||||
cx.emit(EffectStackEvent::AddRequested { index });
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl<D: EffectStackDataSource> EventEmitter<EffectStackEvent> for EffectStackView<D> {}
|
||||
|
||||
impl<D: EffectStackDataSource> Focusable for EffectStackView<D> {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl<D: EffectStackDataSource> Render for EffectStackView<D> {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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<Pixels>, _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::<EffectId>(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::<EffectId>(cx.listener(move |this, event, _window, cx| {
|
||||
this.update_drag(id, event, cx);
|
||||
}))
|
||||
.on_drop::<EffectId>(
|
||||
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::<EffectId>());
|
||||
}
|
||||
|
||||
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<DragGhost>) -> 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())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SharedString>, 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<Pixels>;
|
||||
|
||||
/// Returns the input ports of this node, in top-to-bottom draw order.
|
||||
fn inputs(&self) -> Vec<Self::Port>;
|
||||
|
||||
/// Returns the output ports of this node, in top-to-bottom draw order.
|
||||
fn outputs(&self) -> Vec<Self::Port>;
|
||||
|
||||
/// 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<Hsla>;
|
||||
|
||||
/// 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<Self::Node>;
|
||||
|
||||
/// 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<Self::Edge>;
|
||||
|
||||
/// 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;
|
||||
}
|
||||
@@ -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<NodeId>,
|
||||
/// Element-local cursor position where the drag started.
|
||||
anchor: Point<Pixels>,
|
||||
/// Accumulated graph-space displacement since drag start.
|
||||
delta: Point<Pixels>,
|
||||
}
|
||||
|
||||
/// 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<EdgeId>,
|
||||
/// Ports currently approved as drop targets by
|
||||
/// [`NodeGraphDataSource::can_connect`].
|
||||
valid_ports: BTreeSet<PortId>,
|
||||
}
|
||||
|
||||
/// 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<Pixels>,
|
||||
/// The viewport offset when the pan started.
|
||||
start_offset: Point<Pixels>,
|
||||
}
|
||||
|
||||
/// 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<Pixels>, NodeElement)>,
|
||||
/// Edge wires, in graph order, positioned in window space.
|
||||
wires: Vec<Wire>,
|
||||
/// The in-progress ghost wire, if any, in window space.
|
||||
ghost: Option<GhostSnapshot>,
|
||||
/// The in-progress marquee rectangle, in element-local space.
|
||||
marquee: Option<SelectionRect>,
|
||||
/// The pan offset used to compute this frame.
|
||||
offset: Point<Pixels>,
|
||||
/// 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<Pixels>,
|
||||
/// Window-space position of the free (cursor) end.
|
||||
to: Point<Pixels>,
|
||||
/// 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<Pixels>,
|
||||
max1: Point<Pixels>,
|
||||
min2: Point<Pixels>,
|
||||
max2: Point<Pixels>,
|
||||
) -> 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<NodeId>,
|
||||
/// Accumulated graph-space displacement since drag start.
|
||||
delta: Point<Pixels>,
|
||||
},
|
||||
|
||||
/// 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<NodeId>,
|
||||
/// Total graph-space displacement to apply.
|
||||
delta: Point<Pixels>,
|
||||
},
|
||||
|
||||
/// 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<NodeId>,
|
||||
/// The edges to delete, including edges incident to `nodes`.
|
||||
edges: Vec<EdgeId>,
|
||||
},
|
||||
|
||||
/// 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<NodeId>,
|
||||
},
|
||||
|
||||
/// 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<Pixels>,
|
||||
/// 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<Pixels>,
|
||||
},
|
||||
}
|
||||
|
||||
use NodeGraphEvent::*;
|
||||
|
||||
/// The interactive node-graph editor view.
|
||||
///
|
||||
/// Generic over the app's data source `D`. Construct with
|
||||
/// [`NodeGraphView::new`], place the returned `Entity<NodeGraphView<D>>` 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<D: NodeGraphDataSource> {
|
||||
/// The app-supplied graph model. Read every frame; never mutated.
|
||||
data: Entity<D>,
|
||||
/// 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<Pixels>,
|
||||
/// In-progress node move drag, if any.
|
||||
node_drag: Option<NodeDragState>,
|
||||
/// In-progress wire drag, if any.
|
||||
wire_drag: Option<WireDragState>,
|
||||
/// In-progress pan drag, if any.
|
||||
pan_drag: Option<PanDragState>,
|
||||
/// Whether the space key is currently held down (space-drag pans).
|
||||
space_down: bool,
|
||||
}
|
||||
|
||||
impl<D: NodeGraphDataSource + 'static> NodeGraphView<D> {
|
||||
/// 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<D>, _window: &mut Window, cx: &mut Context<Self>) -> 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<D> {
|
||||
&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<Pixels>, 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<Pixels>,
|
||||
toggle: bool,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
let data = self.data.read(cx);
|
||||
let mut found: Option<(Point<Pixels>, Point<Pixels>, PortKind, Option<PortDataType>)> =
|
||||
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<Self>) {
|
||||
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<PortId> = BTreeSet::new();
|
||||
let mut snapped: Option<Point<Pixels>> = 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<Self>) {
|
||||
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<Pixels>,
|
||||
button: MouseButton,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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<Pixels>, cx: &mut Context<Self>) {
|
||||
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<Pixels>, factor: f32, cx: &mut Context<Self>) {
|
||||
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<Self>) {
|
||||
let nodes = self.state.selection().iter().copied().collect::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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<NodeId>, cx: &mut Context<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) -> 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<PortId, PortDataType> = HashMap::new();
|
||||
let mut elements: HashMap<NodeId, (Point<Pixels>, NodeElement)> = HashMap::new();
|
||||
let mut order: Vec<NodeId> = Vec::new();
|
||||
let mut top: Vec<NodeId> = 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<Pixels>, 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<D: NodeGraphDataSource + 'static> EventEmitter<NodeGraphEvent> for NodeGraphView<D> {}
|
||||
|
||||
impl<D: NodeGraphDataSource + 'static> Focusable for NodeGraphView<D> {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl<D: NodeGraphDataSource + 'static> Render for NodeGraphView<D> {
|
||||
/// 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<Self>) -> 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::<D>::paint_draw(&draw, bounds, window, cx);
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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<D: NodeGraphDataSource>(data: &D) -> Option<(Point<Pixels>, Point<Pixels>)> {
|
||||
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<D: NodeGraphDataSource>(
|
||||
&mut self,
|
||||
state: &GraphViewState,
|
||||
viewport_bounds: Bounds<Pixels>,
|
||||
_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<Pixels>,
|
||||
/// Zoom factor.
|
||||
zoom: f32,
|
||||
/// The main view's screen-space bounds.
|
||||
viewport_bounds: Bounds<Pixels>,
|
||||
}
|
||||
@@ -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<D>`. 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::*;
|
||||
@@ -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<Hsla>,
|
||||
collapsed: bool,
|
||||
enabled: bool,
|
||||
visual: NodeVisualState,
|
||||
inputs: Vec<PortRow>,
|
||||
outputs: Vec<PortRow>,
|
||||
}
|
||||
|
||||
/// 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<N: NodeData>(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<Point<Pixels>> {
|
||||
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<Pixels>) -> Option<PortId> {
|
||||
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<Pixels>) -> 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<Pixels>) -> 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<Pixels>, 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<Pixels>,
|
||||
line_height: Pixels,
|
||||
color: Hsla,
|
||||
align: TextAlign,
|
||||
align_width: Option<Pixels>,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
@@ -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<Pixels>,
|
||||
/// 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<NodeId>,
|
||||
/// An in-progress marquee (rubber-band) selection rectangle, in screen
|
||||
/// space, if the user is currently dragging one.
|
||||
marquee: Option<SelectionRect>,
|
||||
}
|
||||
|
||||
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<Pixels> {
|
||||
self.offset
|
||||
}
|
||||
|
||||
/// Sets the pan offset directly. No clamping is applied — the graph is
|
||||
/// unbounded.
|
||||
pub fn set_offset(&mut self, offset: Point<Pixels>) {
|
||||
self.offset = offset;
|
||||
}
|
||||
|
||||
/// Pans the view by a screen-space delta (typically a drag delta).
|
||||
pub fn pan_by(&mut self, delta: Point<Pixels>) {
|
||||
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<Pixels>, 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<Pixels>) -> Point<Pixels> {
|
||||
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<Pixels>) -> Point<Pixels> {
|
||||
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<NodeId> {
|
||||
&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<Item = NodeId>) {
|
||||
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<Pixels>) {
|
||||
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<Pixels>) {
|
||||
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<SelectionRect> {
|
||||
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<Pixels>,
|
||||
/// The current screen-space corner (usually the cursor position).
|
||||
pub current: Point<Pixels>,
|
||||
}
|
||||
|
||||
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<Pixels>, Point<Pixels>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<Pixels>,
|
||||
to: Point<Pixels>,
|
||||
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<Pixels>,
|
||||
to: Point<Pixels>,
|
||||
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<Pixels>,
|
||||
to: Point<Pixels>,
|
||||
zoom: f32,
|
||||
) -> Option<Path<Pixels>> {
|
||||
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<Pixels>,
|
||||
to: Point<Pixels>,
|
||||
zoom: f32,
|
||||
width: Pixels,
|
||||
dash: Option<&[Pixels]>,
|
||||
) -> Option<Path<Pixels>> {
|
||||
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<Pixels>,
|
||||
/// The current screen-space position of the free end (cursor, or a
|
||||
/// snapped hover-target anchor).
|
||||
free_end: Point<Pixels>,
|
||||
/// 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<Pixels>,
|
||||
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<Pixels> {
|
||||
self.source
|
||||
}
|
||||
|
||||
/// Returns the current screen-space position of the free end.
|
||||
pub(crate) fn free_end(&self) -> Point<Pixels> {
|
||||
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<Pixels>,
|
||||
snapped: Option<Point<Pixels>>,
|
||||
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<Pixels>,
|
||||
to: Point<Pixels>,
|
||||
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<Pixels> {
|
||||
self.active.then_some(self.phase)
|
||||
}
|
||||
}
|
||||
@@ -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<Pixels>,
|
||||
) {
|
||||
// no-op by default
|
||||
}
|
||||
|
||||
/// Paints an audio waveform for `clip` covering `visible_range` into
|
||||
/// `bounds`. Same media-time mapping as
|
||||
/// [`ClipDecorator::paint_thumbnail`]. Typically drawn with
|
||||
/// [`PathBuilder`](crate::PathBuilder) from cached peak data.
|
||||
fn paint_waveform(
|
||||
&mut self,
|
||||
_window: &mut Window,
|
||||
_clip: ClipId,
|
||||
_visible_range: FrameRange,
|
||||
_bounds: Bounds<Pixels>,
|
||||
) {
|
||||
// no-op by default
|
||||
}
|
||||
}
|
||||
|
||||
/// The default decorator: paints no thumbnails and no waveforms.
|
||||
///
|
||||
/// Used when the host doesn't need rich clip content (or hasn't wired its
|
||||
/// caches yet — Oak will replace this with a decorator backed by its codec
|
||||
/// and render caches).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct NoopClipDecorator;
|
||||
|
||||
impl ClipDecorator for NoopClipDecorator {}
|
||||
|
||||
/// One clip body in the clip area.
|
||||
///
|
||||
/// Constructed per visible clip per frame by
|
||||
/// [`TimelineView`](super::TimelineView) from a
|
||||
/// [`ClipData`](super::ClipData) snapshot. The element owns no frame→pixel
|
||||
/// mapping: the view positions and sizes it via its container, and internal
|
||||
/// decorations (transition wedges, decorator content) fall back to
|
||||
/// [`PX_PER_FRAME_FALLBACK`] until a zoom is threaded through — see that
|
||||
/// constant for the limitation.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ClipElement {
|
||||
id: ClipId,
|
||||
label: SharedString,
|
||||
color: Hsla,
|
||||
selected: bool,
|
||||
enabled: bool,
|
||||
locked: bool,
|
||||
in_transition: Option<FrameRange>,
|
||||
out_transition: Option<FrameRange>,
|
||||
content: ClipContent,
|
||||
decorator: Arc<std::sync::RwLock<dyn ClipDecorator>>,
|
||||
}
|
||||
|
||||
impl ClipElement {
|
||||
/// Creates a clip element.
|
||||
///
|
||||
/// * `id` / `label` / `color` — from the clip's
|
||||
/// [`ClipData`](super::ClipData); `color` is the clip color or the
|
||||
/// track-kind default resolved by the caller.
|
||||
/// * `in_transition` / `out_transition` — the frame ranges (in
|
||||
/// *clip-local* time) of the head/tail transition wedges, if any.
|
||||
/// * `decorator` — shared decorator instance; see [`ClipDecorator`].
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
id: ClipId,
|
||||
label: SharedString,
|
||||
color: Hsla,
|
||||
in_transition: Option<FrameRange>,
|
||||
out_transition: Option<FrameRange>,
|
||||
decorator: Arc<std::sync::RwLock<dyn ClipDecorator>>,
|
||||
) -> Self {
|
||||
ClipElement {
|
||||
id,
|
||||
label,
|
||||
color,
|
||||
selected: false,
|
||||
enabled: true,
|
||||
locked: false,
|
||||
in_transition,
|
||||
out_transition,
|
||||
content: ClipContent::None,
|
||||
decorator,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder: render in the selected state (selection outline).
|
||||
pub fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: render in the disabled state (dimmed, no snapping target).
|
||||
pub fn enabled(mut self, enabled: bool) -> Self {
|
||||
self.enabled = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: render as belonging to a locked track (no trim handles, no
|
||||
/// drag cursor).
|
||||
pub fn locked(mut self, locked: bool) -> Self {
|
||||
self.locked = locked;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: what content the decorator should paint inside the body.
|
||||
pub fn content(mut self, content: ClipContent) -> Self {
|
||||
self.content = content;
|
||||
self
|
||||
}
|
||||
|
||||
/// The clip this element renders.
|
||||
pub fn clip_id(&self) -> ClipId {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// The trim-handle hit zone width, in pixels.
|
||||
pub fn trim_handle_width(&self) -> Pixels {
|
||||
crate::px(TRIM_HANDLE_WIDTH)
|
||||
}
|
||||
|
||||
// TODO(implementor): register the trim-handle hit zones when the element
|
||||
// grows interactive handles; they are currently part of the view's
|
||||
// interaction layer.
|
||||
}
|
||||
|
||||
/// Paint-time snapshot of a [`ClipElement`], captured by the canvas prepaint
|
||||
/// callback and consumed by the paint callback.
|
||||
struct ClipPaint {
|
||||
id: ClipId,
|
||||
color: Hsla,
|
||||
selected: bool,
|
||||
enabled: bool,
|
||||
locked: bool,
|
||||
in_transition: Option<FrameRange>,
|
||||
out_transition: Option<FrameRange>,
|
||||
content: ClipContent,
|
||||
decorator: Arc<std::sync::RwLock<dyn ClipDecorator>>,
|
||||
}
|
||||
|
||||
impl RenderOnce for ClipElement {
|
||||
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||
let ClipElement {
|
||||
id,
|
||||
label,
|
||||
color,
|
||||
selected,
|
||||
enabled,
|
||||
locked,
|
||||
in_transition,
|
||||
out_transition,
|
||||
content,
|
||||
decorator,
|
||||
..
|
||||
} = self;
|
||||
|
||||
// The clip body is custom-painted (body quad, transition wedges,
|
||||
// state overlays, decorator content); the label rides in a styled
|
||||
// overlay div so it inherits the usual text elision.
|
||||
div()
|
||||
.relative()
|
||||
.size_full()
|
||||
.child(canvas(
|
||||
move |_bounds, _window, _cx| ClipPaint {
|
||||
id,
|
||||
color,
|
||||
selected,
|
||||
enabled,
|
||||
locked,
|
||||
in_transition,
|
||||
out_transition,
|
||||
content,
|
||||
decorator,
|
||||
},
|
||||
|bounds, paint, window, _cx| {
|
||||
// Body quad, dimmed when the clip is disabled.
|
||||
let body_color = if paint.enabled {
|
||||
paint.color
|
||||
} else {
|
||||
Hsla {
|
||||
h: paint.color.h,
|
||||
s: paint.color.s,
|
||||
l: paint.color.l,
|
||||
a: paint.color.a * 0.5,
|
||||
}
|
||||
};
|
||||
window.paint_quad(fill(bounds, body_color));
|
||||
|
||||
// Transition wedges: triangles tapering into the clip
|
||||
// from each edge, capped at 40% of the body width so tiny
|
||||
// clips don't vanish.
|
||||
let max_wedge = bounds.size.width.0 * 0.4;
|
||||
if let Some(range) = paint.in_transition {
|
||||
let w = (range.len().0 as f32 * PX_PER_FRAME_FALLBACK).min(max_wedge);
|
||||
if w > 0.0 {
|
||||
let mut path = PathBuilder::fill();
|
||||
path.move_to(point(bounds.left(), bounds.top()));
|
||||
path.line_to(point(bounds.left() + px(w), bounds.top()));
|
||||
path.line_to(point(bounds.left(), bounds.bottom()));
|
||||
path.close();
|
||||
window.paint_path(path.build().expect("wedge path is valid"), paint.color);
|
||||
}
|
||||
}
|
||||
if let Some(range) = paint.out_transition {
|
||||
let w = (range.len().0 as f32 * PX_PER_FRAME_FALLBACK).min(max_wedge);
|
||||
if w > 0.0 {
|
||||
let mut path = PathBuilder::fill();
|
||||
path.move_to(point(bounds.right(), bounds.top()));
|
||||
path.line_to(point(bounds.right() - px(w), bounds.top()));
|
||||
path.line_to(point(bounds.right(), bounds.bottom()));
|
||||
path.close();
|
||||
window.paint_path(path.build().expect("wedge path is valid"), paint.color);
|
||||
}
|
||||
}
|
||||
|
||||
// State overlays, in back-to-front order.
|
||||
if !paint.enabled {
|
||||
window.paint_quad(fill(bounds, hsla(0., 0., 0.05, 0.55)));
|
||||
}
|
||||
if paint.locked {
|
||||
window.paint_quad(fill(bounds, hsla(0., 0., 0.1, 0.25)));
|
||||
}
|
||||
|
||||
// Rich content via the decorator, scoped to the frames
|
||||
// visible inside this body.
|
||||
let visible_range = FrameRange::new(
|
||||
Frame::ZERO,
|
||||
Frame((bounds.size.width.0 / PX_PER_FRAME_FALLBACK) as i64),
|
||||
);
|
||||
match paint.content {
|
||||
ClipContent::Thumbnails => paint
|
||||
.decorator
|
||||
.write()
|
||||
.expect("clip decorator lock is not poisoned")
|
||||
.paint_thumbnail(window, paint.id, visible_range, bounds),
|
||||
ClipContent::Waveform => paint
|
||||
.decorator
|
||||
.write()
|
||||
.expect("clip decorator lock is not poisoned")
|
||||
.paint_waveform(window, paint.id, visible_range, bounds),
|
||||
ClipContent::None => {}
|
||||
}
|
||||
|
||||
// Selection outline on top of everything.
|
||||
if paint.selected {
|
||||
window.paint_quad(
|
||||
outline(bounds, hsla(0.6, 0.8, 0.6, 1.), BorderStyle::Solid),
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
.size_full())
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left(px(4.))
|
||||
.top(px(4.))
|
||||
.right(px(4.))
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.text_size(px(11.))
|
||||
.text_color(hsla(0., 0., 1., 0.92))
|
||||
.child(label),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<Hsla> {
|
||||
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<ClipId> {
|
||||
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<Frame> {
|
||||
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<Frame> {
|
||||
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<Hsla>,
|
||||
}
|
||||
|
||||
/// 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<Self::Track>;
|
||||
|
||||
/// All sequence markers, in ascending frame order.
|
||||
fn markers(&self) -> Vec<Marker> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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<Instant>,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<FrameRange> {
|
||||
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<SharedString>,
|
||||
}
|
||||
|
||||
/// Everything the canvas paint closure needs, computed in prepaint.
|
||||
struct RulerContent {
|
||||
ticks: Vec<RulerTick>,
|
||||
/// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Pixels>,
|
||||
|
||||
/// 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<ClipId>,
|
||||
|
||||
/// 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<FrameRange>,
|
||||
}
|
||||
|
||||
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<Item = ClipId>) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<Item = SnapPoint>,
|
||||
threshold_px: Pixels,
|
||||
zoom: f32,
|
||||
) -> Option<SnapResult> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user