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:
@@ -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();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user