@@ -3,12 +3,14 @@ use std::{cell::RefCell, rc::Rc};
33use gpui:: {
44 Anchor , AnyElement , App , Context , DismissEvent , Element , ElementId , Entity , Focusable ,
55 GlobalElementId , Hitbox , HitboxBehavior , InspectorElementId , InteractiveElement , IntoElement ,
6- MouseButton , MouseDownEvent , ParentElement , Pixels , Point , StyleRefinement , Styled ,
7- Subscription , Window , anchored, deferred, div, prelude:: FluentBuilder , px,
6+ MouseButton , MouseDownEvent , MouseUpEvent , ParentElement , Pixels , Point , StyleRefinement ,
7+ Styled , Subscription , Window , anchored, deferred, div, prelude:: FluentBuilder , px,
88} ;
99
1010use crate :: menu:: PopupMenu ;
1111
12+ type PopupMenuBuilder = Rc < dyn Fn ( PopupMenu , & mut Window , & mut Context < PopupMenu > ) -> PopupMenu > ;
13+
1214/// A extension trait for adding a context menu to an element.
1315pub trait ContextMenuExt : InteractiveElement + ParentElement + Styled {
1416 /// Add a context menu to the element.
@@ -40,7 +42,7 @@ impl<E: InteractiveElement + ParentElement + Styled> ContextMenuExt for E {}
4042pub struct ContextMenu < E : ParentElement + Styled + Sized > {
4143 id : ElementId ,
4244 element : Option < E > ,
43- menu : Option < Rc < dyn Fn ( PopupMenu , & mut Window , & mut Context < PopupMenu > ) -> PopupMenu > > ,
45+ menu : Option < PopupMenuBuilder > ,
4446 // This is not in use, just for style refinement forwarding.
4547 _ignore_style : StyleRefinement ,
4648 anchor : Anchor ,
@@ -119,6 +121,57 @@ struct ContextMenuSharedState {
119121 _subscription : Option < Subscription > ,
120122}
121123
124+ fn should_open_context_menu ( state : & ContextMenuSharedState , position : Point < Pixels > ) -> bool {
125+ !state. open || state. position != position
126+ }
127+
128+ fn mark_context_menu_open ( state : & mut ContextMenuSharedState , position : Point < Pixels > ) {
129+ state. menu_view = None ;
130+ state. _subscription = None ;
131+ state. position = position;
132+ state. open = true ;
133+ }
134+
135+ fn open_context_menu_at (
136+ shared_state : Rc < RefCell < ContextMenuSharedState > > ,
137+ builder : Option < PopupMenuBuilder > ,
138+ position : Point < Pixels > ,
139+ window : & mut Window ,
140+ cx : & mut App ,
141+ ) {
142+ {
143+ let mut state = shared_state. borrow_mut ( ) ;
144+ if !should_open_context_menu ( & state, position) {
145+ return ;
146+ }
147+ mark_context_menu_open ( & mut state, position) ;
148+ }
149+
150+ window. defer ( cx, move |window, cx| {
151+ let menu = PopupMenu :: build ( window, cx, move |menu, window, cx| {
152+ let Some ( build) = & builder else {
153+ return menu;
154+ } ;
155+ build ( menu, window, cx)
156+ } ) ;
157+
158+ let _subscription = window. subscribe ( & menu, cx, {
159+ let shared_state = shared_state. clone ( ) ;
160+ move |_, _: & DismissEvent , window, _cx| {
161+ shared_state. borrow_mut ( ) . open = false ;
162+ window. refresh ( ) ;
163+ }
164+ } ) ;
165+
166+ {
167+ let mut state = shared_state. borrow_mut ( ) ;
168+ state. menu_view = Some ( menu. clone ( ) ) ;
169+ state. _subscription = Some ( _subscription) ;
170+ window. refresh ( ) ;
171+ }
172+ } ) ;
173+ }
174+
122175pub struct ContextMenuState {
123176 element : Option < AnyElement > ,
124177 shared_state : Rc < RefCell < ContextMenuSharedState > > ,
@@ -270,56 +323,86 @@ impl<E: ParentElement + Styled + IntoElement + 'static> Element for ContextMenu<
270323 |_view, state : & mut ContextMenuState , window, _| {
271324 let shared_state = state. shared_state . clone ( ) ;
272325
273- let hitbox = hitbox. clone ( ) ;
274- // When right mouse click, to build content menu, and show it at the mouse position.
326+ let hitbox_for_down = hitbox. clone ( ) ;
327+ let state_for_down = shared_state. clone ( ) ;
328+ let builder_for_down = builder. clone ( ) ;
329+ // Build the context menu from mouse down on platforms that emit it.
275330 window. on_mouse_event ( move |event : & MouseDownEvent , phase, window, cx| {
276331 if phase. bubble ( )
277332 && event. button == MouseButton :: Right
278- && hitbox . is_hovered ( window)
333+ && hitbox_for_down . is_hovered ( window)
279334 {
280- {
281- let mut shared_state = shared_state. borrow_mut ( ) ;
282- // Clear any existing menu view to allow immediate replacement
283- // Set the new position and open the menu
284- shared_state. menu_view = None ;
285- shared_state. _subscription = None ;
286- shared_state. position = event. position ;
287- shared_state. open = true ;
288- }
289-
290- // Use defer to build the menu in the next frame, avoiding race conditions
291- window. defer ( cx, {
292- let shared_state = shared_state. clone ( ) ;
293- let builder = builder. clone ( ) ;
294- move |window, cx| {
295- let menu = PopupMenu :: build ( window, cx, move |menu, window, cx| {
296- let Some ( build) = & builder else {
297- return menu;
298- } ;
299- build ( menu, window, cx)
300- } ) ;
301-
302- // Set up the subscription for dismiss handling
303- let _subscription = window. subscribe ( & menu, cx, {
304- let shared_state = shared_state. clone ( ) ;
305- move |_, _: & DismissEvent , window, _cx| {
306- shared_state. borrow_mut ( ) . open = false ;
307- window. refresh ( ) ;
308- }
309- } ) ;
310-
311- // Update the shared state with the built menu and subscription
312- {
313- let mut state = shared_state. borrow_mut ( ) ;
314- state. menu_view = Some ( menu. clone ( ) ) ;
315- state. _subscription = Some ( _subscription) ;
316- window. refresh ( ) ;
317- }
318- }
319- } ) ;
335+ open_context_menu_at (
336+ state_for_down. clone ( ) ,
337+ builder_for_down. clone ( ) ,
338+ event. position ,
339+ window,
340+ cx,
341+ ) ;
342+ }
343+ } ) ;
344+
345+ let hitbox_for_up = hitbox. clone ( ) ;
346+ let state_for_up = shared_state. clone ( ) ;
347+ let builder_for_up = builder. clone ( ) ;
348+ // Some platforms, including the OHOS backend, surface context-menu
349+ // clicks on button release instead of button press.
350+ window. on_mouse_event ( move |event : & MouseUpEvent , phase, window, cx| {
351+ if phase. bubble ( )
352+ && event. button == MouseButton :: Right
353+ && hitbox_for_up. is_hovered ( window)
354+ {
355+ open_context_menu_at (
356+ state_for_up. clone ( ) ,
357+ builder_for_up. clone ( ) ,
358+ event. position ,
359+ window,
360+ cx,
361+ ) ;
320362 }
321363 } ) ;
322364 } ,
323365 ) ;
324366 }
325367}
368+
369+ #[ cfg( test) ]
370+ mod tests {
371+ use super :: * ;
372+ use gpui:: px;
373+
374+ fn test_state ( position : Point < Pixels > , open : bool ) -> ContextMenuSharedState {
375+ ContextMenuSharedState {
376+ menu_view : None ,
377+ open,
378+ position,
379+ _subscription : None ,
380+ }
381+ }
382+
383+ fn point ( x : f32 , y : f32 ) -> Point < Pixels > {
384+ Point { x : px ( x) , y : px ( y) }
385+ }
386+
387+ #[ test]
388+ fn context_menu_suppresses_same_click_release_after_press ( ) {
389+ let position = point ( 10.0 , 20.0 ) ;
390+ let mut state = test_state ( position, false ) ;
391+
392+ assert ! ( should_open_context_menu( & state, position) ) ;
393+ mark_context_menu_open ( & mut state, position) ;
394+
395+ assert ! ( !should_open_context_menu( & state, position) ) ;
396+ }
397+
398+ #[ test]
399+ fn context_menu_allows_release_only_or_new_position ( ) {
400+ let position = point ( 10.0 , 20.0 ) ;
401+ let new_position = point ( 20.0 , 20.0 ) ;
402+ let open_state = test_state ( position, true ) ;
403+ let closed_state = test_state ( position, false ) ;
404+
405+ assert ! ( should_open_context_menu( & closed_state, position) ) ;
406+ assert ! ( should_open_context_menu( & open_state, new_position) ) ;
407+ }
408+ }
0 commit comments