feat(widgets): right-click context-menu events for timeline, node graph, explorer

TimelineView emits ContextMenuRequested with hit target (clip/empty/
track head/ruler marker), NodeGraphView emits it for nodes and the
background, and ProjectExplorer emits it for entries and blank areas,
so the app can open context menus aligned with the C++ version.
This commit is contained in:
2026-08-18 17:14:37 +08:00
parent 6f75d3c92a
commit 050d5ba22d
3 changed files with 220 additions and 10 deletions
+38 -7
View File
@@ -216,6 +216,19 @@ pub enum NodeGraphEvent {
/// Click position in graph space.
position: Point<Pixels>,
},
/// The user right-clicked a node. If the node was not part of the
/// selection, the widget made it the sole selection first (so the
/// host's menu actions operate on it — the C++ `NodeView` behavior),
/// emitting [`SelectionChanged`](Self::SelectionChanged) ahead of this
/// event. `position` is in window coordinates, suitable for placing a
/// context menu.
NodeContextMenuRequested {
/// The right-clicked node.
node: NodeId,
/// Mouse position in window coordinates.
position: Point<Pixels>,
},
}
use NodeGraphEvent::*;
@@ -237,6 +250,8 @@ use NodeGraphEvent::*;
/// | 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 |
/// | Right-click node | select it (unless already selected) and request its context menu ([`NodeGraphEvent::NodeContextMenuRequested`]) |
/// | Right-click background | request the background context menu ([`NodeGraphEvent::BackgroundClicked`]) |
/// | Delete / Backspace | [`NodeGraphEvent::DeleteRequested`] for the selection |
///
/// All mouse positions in events are in window space; hit testing and painting
@@ -980,13 +995,29 @@ impl<D: NodeGraphDataSource + 'static> Render for NodeGraphView<D> {
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,
);
match this.hit_test(event.position, cx) {
HitTarget::Background => {
this.on_background_mouse_down(
event.position,
MouseButton::Right,
window,
cx,
);
}
// A right-clicked node becomes the sole selection
// when it is not part of it, then its context menu
// is requested (the C++ `NodeView` behavior).
HitTarget::Node(node) => {
if !this.state.selection().contains(&node) {
this.set_selection_and_emit(BTreeSet::from([node]), cx);
}
cx.emit(NodeContextMenuRequested {
node,
position: event.position,
});
cx.notify();
}
_ => {}
}
}),
)
+132 -1
View File
@@ -70,7 +70,7 @@ use std::sync::{Arc, RwLock};
use crate::{
AnyElement, App, Context, DragMoveEvent, ElementId, Entity, EventEmitter, FocusHandle,
Focusable, Hsla, MouseButton, MouseDownEvent, PinchEvent, Pixels, Point, Render, ScrollDelta,
ScrollWheelEvent, SharedString, Window, div, hsla, prelude::*, px,
ScrollWheelEvent, SharedString, Window, canvas, div, hsla, prelude::*, px,
};
use super::{
@@ -231,6 +231,39 @@ pub enum TimelineEvent {
/// The zoom (pixels per frame) changed via ctrl-scroll or pinch.
/// Persist as view state if desired.
ZoomChanged(f32),
/// The user right-clicked somewhere in the widget. The widget opens no
/// menu itself; `hit` tells the host what was under the pointer so it
/// can assemble the matching context menu at `position`. Not undoable.
ContextMenuRequested {
/// The click position in window coordinates (suitable for placing a
/// popup).
position: Point<Pixels>,
/// What the click hit.
hit: TimelineHit,
},
}
/// What a right-click hit in the timeline widget (the
/// [`TimelineEvent::ContextMenuRequested`] payload).
#[derive(Debug, Clone, PartialEq)]
pub enum TimelineHit {
/// A clip.
Clip(ClipId),
/// The empty track area: the track under the pointer plus the frame at
/// the pointer's x position.
Empty {
/// Index of the track under the pointer.
track: usize,
/// The frame at the pointer's x position.
frame: Frame,
},
/// A track header.
TrackHead(usize),
/// A ruler marker (the marker's frame).
RulerMarker(Frame),
/// The ruler, away from any marker (the frame at the pointer).
Ruler(Frame),
}
/// The video-editing timeline widget.
@@ -265,6 +298,10 @@ pub struct TimelineView<D: TimelineDataSource> {
/// Rich clip content (thumbnails / waveforms), replaced by the host.
decorator: std::sync::Arc<std::sync::RwLock<dyn ClipDecorator>>,
focus_handle: FocusHandle,
/// The clip area's window-space origin, captured on every layout by a
/// canvas child (the right-click hit test converts window positions
/// through it).
clip_area_origin: Point<Pixels>,
}
/// Width of the track-headers column, in pixels. Public so host panels can
@@ -298,6 +335,7 @@ impl<D: TimelineDataSource> TimelineView<D> {
selected_tracks: BTreeSet::new(),
decorator: std::sync::Arc::new(std::sync::RwLock::new(NoopClipDecorator)),
focus_handle,
clip_area_origin: Point::default(),
}
}
@@ -927,6 +965,29 @@ impl<D: TimelineDataSource> Render for TimelineView<D> {
this.seek(frame, cx);
}),
)
.on_mouse_down(
MouseButton::Right,
cx.listener(|this, event: &MouseDownEvent, _window, cx| {
let x = event.position.x - this.clip_area_origin.x;
// A press close to a marker addresses the marker
// (the ruler paints them as thin glyphs); any
// other press addresses the ruler itself.
let marker =
this.source.read(cx).markers().into_iter().find(|marker| {
let marker_x = this.state.point_at_frame(marker.frame);
(f32::from(marker_x - x)).abs() <= SNAP_THRESHOLD_PX
});
let hit = match marker {
Some(marker) => TimelineHit::RulerMarker(marker.frame),
None => TimelineHit::Ruler(this.state.frame_at_point(x)),
};
cx.emit(TimelineEvent::ContextMenuRequested {
position: event.position,
hit,
});
cx.stop_propagation();
}),
)
.on_drag(
Arc::new(RwLock::new(RulerDrag {
old_band: None,
@@ -1016,6 +1077,19 @@ impl<D: TimelineDataSource> Render for TimelineView<D> {
cx.notify();
})
})
.on_mouse_down(
MouseButton::Right,
cx.listener({
let track = row.index;
move |_this, event: &MouseDownEvent, _window, cx| {
cx.emit(TimelineEvent::ContextMenuRequested {
position: event.position,
hit: TimelineHit::TrackHead(track),
});
cx.stop_propagation();
}
}),
)
.child(
TrackHeader::new(row.index, row.name.clone(), row.kind)
.locked(row.locked)
@@ -1046,6 +1120,14 @@ impl<D: TimelineDataSource> Render for TimelineView<D> {
)
}));
// The clip area's canvas child records the element's window-space
// origin into the view (see the canvas below).
let view = cx.entity();
// The per-clip right-click handlers live inside a `move` closure
// (the rows are consumed), so they cannot borrow `cx` for
// `cx.listener`; they go through this weak handle instead.
let weak_view = cx.weak_entity();
let clip_area = div()
.flex_1()
.h_full()
@@ -1085,6 +1167,21 @@ impl<D: TimelineDataSource> Render for TimelineView<D> {
}
cx.notify();
}))
.on_mouse_down(
MouseButton::Right,
cx.listener(|this, event: &MouseDownEvent, _window, cx| {
// A right-click that reaches the clip area itself hit no
// clip (clip handlers stop propagation): report the empty
// track area with the track and frame under the pointer.
let local = event.position - this.clip_area_origin;
let frame = this.state.frame_at_point(local.x);
let track = this.track_at_y(f32::from(local.y), cx);
cx.emit(TimelineEvent::ContextMenuRequested {
position: event.position,
hit: TimelineHit::Empty { track, frame },
});
}),
)
.on_drag(MarqueeDrag, drag_ghost)
.on_drag_move(cx.listener(
|this, event: &DragMoveEvent<Arc<RwLock<ClipDrag>>>, _window, cx| {
@@ -1121,6 +1218,19 @@ impl<D: TimelineDataSource> Render for TimelineView<D> {
this.finish_height_drag(drag, cx);
}),
)
.child(
// Records the clip area's window-space origin on every
// layout (the right-click hit test converts window
// positions through it); paints nothing.
canvas(
move |bounds, _window, cx| {
view.update(cx, |this, _cx| this.clip_area_origin = bounds.origin);
},
|_bounds, (), _window, _cx| {},
)
.absolute()
.size_full(),
)
.children(rows.into_iter().map(move |row| {
let height = row.height;
let row_locked = row.locked;
@@ -1128,6 +1238,9 @@ impl<D: TimelineDataSource> Render for TimelineView<D> {
let row_index = row.index;
let state = &state;
let decorator = &decorator;
// Per-row clone: the per-clip closures below are built once
// per row and move this handle in.
let weak_view = weak_view.clone();
div()
.h(px(height))
.relative()
@@ -1268,6 +1381,24 @@ impl<D: TimelineDataSource> Render for TimelineView<D> {
.h(px((clip_height - 4.0).max(1.0)))
.overflow_hidden()
.id(ElementId::named_usize("timeline-clip", clip.id.0 as usize))
.on_mouse_down(
MouseButton::Right,
{
let view = weak_view.clone();
let id = clip.id;
move |event: &MouseDownEvent, _window, cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |_this, cx| {
cx.emit(TimelineEvent::ContextMenuRequested {
position: event.position,
hit: TimelineHit::Clip(id),
});
cx.stop_propagation();
});
}
}
},
)
.on_drag(
Arc::new(RwLock::new(ClipDrag {
clip: clip.id,
+50 -2
View File
@@ -9,8 +9,8 @@
use gpui::{
App, ClickEvent, Context, ElementId, Entity, EventEmitter, ExternalPaths, FocusHandle,
Focusable, Hsla, Point, Pixels, Render, SharedString, Window, colors::DefaultColors, div,
hsla, img, prelude::*, px,
Focusable, Hsla, MouseButton, MouseDownEvent, Point, Pixels, Render, SharedString, Window,
colors::DefaultColors, div, hsla, img, prelude::*, px,
};
use std::collections::HashSet;
use std::path::PathBuf;
@@ -130,6 +130,18 @@ pub enum ProjectExplorerEvent {
/// The new view mode.
view: ExplorerView,
},
/// An entry (or the empty area) was right-clicked. The widget selects
/// the entry but opens no menu itself; the host assembles and shows the
/// context menu at `position`.
ContextMenuRequested {
/// The explorer's stable id.
control: usize,
/// The right-clicked entry, or `None` when the empty area was
/// clicked.
id: Option<u64>,
/// Mouse position in window coordinates.
position: Point<Pixels>,
},
}
/// Flatten a tree into visible rows, honoring the `expanded` set.
@@ -236,6 +248,20 @@ impl<D: ProjectDataSource> ProjectExplorer<D> {
cx.notify();
}
}
/// Reports a right-click: selects the entry (if any) and emits
/// [`ProjectExplorerEvent::ContextMenuRequested`] for the host's menu.
fn context_menu(&mut self, id: Option<u64>, position: Point<Pixels>, cx: &mut Context<Self>) {
if let Some(id) = id {
self.selected = Some(id);
}
cx.emit(ProjectExplorerEvent::ContextMenuRequested {
control: self.control,
id,
position,
});
cx.notify();
}
}
impl<D: ProjectDataSource> EventEmitter<ProjectExplorerEvent> for ProjectExplorer<D> {}
@@ -338,6 +364,13 @@ impl<D: ProjectDataSource> Render for ProjectExplorer<D> {
}
cx.notify();
}))
.on_mouse_down(
MouseButton::Right,
cx.listener(move |this, event: &MouseDownEvent, _window, cx| {
this.context_menu(Some(entry_id), event.position, cx);
cx.stop_propagation();
}),
)
.child(div().w(px(14.0)).child(if entry.is_dir {
if this_expanded(&expanded, entry.id) {
""
@@ -425,6 +458,13 @@ impl<D: ProjectDataSource> Render for ProjectExplorer<D> {
}
cx.notify();
}))
.on_mouse_down(
MouseButton::Right,
cx.listener(move |this, event: &MouseDownEvent, _window, cx| {
this.context_menu(Some(entry_id), event.position, cx);
cx.stop_propagation();
}),
)
.child(if let Some(thumbnail) = entry.thumbnail.clone() {
// A filesystem path: load through the path resource so
// generated thumbnails resolve without an asset source.
@@ -475,6 +515,14 @@ impl<D: ProjectDataSource> Render for ProjectExplorer<D> {
cx.notify();
}),
)
// A right-click that bubbles up to the container hit no entry
// (entry handlers stop propagation): the empty-area menu.
.on_mouse_down(
MouseButton::Right,
cx.listener(|this, event: &MouseDownEvent, _window, cx| {
this.context_menu(None, event.position, cx);
}),
)
.child(toolbar)
.child(content.flex_1())
}