diff --git a/crates/gpui/src/node_graph/graph_view.rs b/crates/gpui/src/node_graph/graph_view.rs index b342ea84d4..eb9d304cfc 100644 --- a/crates/gpui/src/node_graph/graph_view.rs +++ b/crates/gpui/src/node_graph/graph_view.rs @@ -216,6 +216,19 @@ pub enum NodeGraphEvent { /// Click position in graph space. position: Point, }, + + /// 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, + }, } 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 Render for NodeGraphView { 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(); + } + _ => {} } }), ) diff --git a/crates/gpui/src/timeline/timeline_view.rs b/crates/gpui/src/timeline/timeline_view.rs index aec564d8b7..96c588b0b5 100644 --- a/crates/gpui/src/timeline/timeline_view.rs +++ b/crates/gpui/src/timeline/timeline_view.rs @@ -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, + /// 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 { /// Rich clip content (thumbnails / waveforms), replaced by the host. decorator: std::sync::Arc>, 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, } /// Width of the track-headers column, in pixels. Public so host panels can @@ -298,6 +335,7 @@ impl TimelineView { 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 Render for TimelineView { 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 Render for TimelineView { 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 Render for TimelineView { ) })); + // 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 Render for TimelineView { } 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>>, _window, cx| { @@ -1121,6 +1218,19 @@ impl Render for TimelineView { 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 Render for TimelineView { 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 Render for TimelineView { .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, diff --git a/crates/gpui_widgets/src/project_explorer.rs b/crates/gpui_widgets/src/project_explorer.rs index 892cea6ddf..3fac849c35 100644 --- a/crates/gpui_widgets/src/project_explorer.rs +++ b/crates/gpui_widgets/src/project_explorer.rs @@ -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, + /// Mouse position in window coordinates. + position: Point, + }, } /// Flatten a tree into visible rows, honoring the `expanded` set. @@ -236,6 +248,20 @@ impl ProjectExplorer { 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, position: Point, cx: &mut Context) { + if let Some(id) = id { + self.selected = Some(id); + } + cx.emit(ProjectExplorerEvent::ContextMenuRequested { + control: self.control, + id, + position, + }); + cx.notify(); + } } impl EventEmitter for ProjectExplorer {} @@ -338,6 +364,13 @@ impl Render for ProjectExplorer { } 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 Render for ProjectExplorer { } 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 Render for ProjectExplorer { 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()) }