@@ -16,6 +16,8 @@ use tracing::{debug, instrument};
1616use crate :: { App , ArcLock , RecordingState } ;
1717
1818const RECORDING_CONTROLS_LABEL : & str = "in-progress-recording" ;
19+ #[ cfg( any( target_os = "macos" , test) ) ]
20+ const RECORDING_CONTROLS_BOUNDS_NAME : & str = "recording-controls-interactive-area" ;
1921const RECORDING_CONTROLS_WIDTH : f64 = 320.0 ;
2022const RECORDING_CONTROLS_HEIGHT : f64 = 150.0 ;
2123const RECORDING_CONTROLS_BAR_HEIGHT : f64 = 40.0 ;
@@ -58,25 +60,27 @@ impl FakeWindowListeners {
5860 ( id, token)
5961 }
6062
61- fn finish ( & self , label : & str , id : u64 ) {
63+ fn finish ( & self , label : & str , id : u64 ) -> bool {
6264 let mut guard = self . tokens . lock ( ) . unwrap_or_else ( |e| e. into_inner ( ) ) ;
6365 if let Some ( current) = guard. get ( label)
6466 && current. id == id
6567 {
6668 guard. remove ( label) ;
69+ return true ;
6770 }
71+ false
6872 }
6973
7074 pub fn cancel ( & self , label : & str ) {
71- let mut guard = self . tokens . lock ( ) . unwrap_or_else ( |e| e. into_inner ( ) ) ;
72- if let Some ( entry) = guard. remove ( label) {
75+ let guard = self . tokens . lock ( ) . unwrap_or_else ( |e| e. into_inner ( ) ) ;
76+ if let Some ( entry) = guard. get ( label) {
7377 entry. token . cancel ( ) ;
7478 }
7579 }
7680
7781 pub fn cancel_all ( & self ) {
78- let mut guard = self . tokens . lock ( ) . unwrap_or_else ( |e| e. into_inner ( ) ) ;
79- for ( _ , entry) in guard. drain ( ) {
82+ let guard = self . tokens . lock ( ) . unwrap_or_else ( |e| e. into_inner ( ) ) ;
83+ for entry in guard. values ( ) {
8084 entry. token . cancel ( ) ;
8185 }
8286 }
@@ -259,6 +263,118 @@ fn spawn_recording_controls_sanity_checks(app: AppHandle, window: WebviewWindow)
259263 } ) ;
260264}
261265
266+ #[ cfg( any( target_os = "macos" , test) ) ]
267+ fn ensure_recording_controls_bounds (
268+ bounds : & mut HashMap < String , LogicalBounds > ,
269+ width : f64 ,
270+ height : f64 ,
271+ ) {
272+ bounds
273+ . entry ( RECORDING_CONTROLS_BOUNDS_NAME . to_string ( ) )
274+ . or_insert_with ( || {
275+ LogicalBounds :: new (
276+ scap_targets:: bounds:: LogicalPosition :: new (
277+ RECORDING_CONTROLS_BOTTOM_PADDING ,
278+ height - RECORDING_CONTROLS_BOTTOM_PADDING - RECORDING_CONTROLS_BAR_HEIGHT ,
279+ ) ,
280+ scap_targets:: bounds:: LogicalSize :: new (
281+ width - RECORDING_CONTROLS_BOTTOM_PADDING * 2.0 ,
282+ RECORDING_CONTROLS_BAR_HEIGHT ,
283+ ) ,
284+ )
285+ } ) ;
286+ }
287+
288+ #[ cfg( any( target_os = "macos" , test) ) ]
289+ fn preserve_pointer_gesture ( ignore : bool , pressed : bool , was_ignoring : bool ) -> bool {
290+ ignore && ( !pressed || was_ignoring)
291+ }
292+
293+ #[ cfg( target_os = "macos" ) ]
294+ async fn update_recording_controls_hit_test (
295+ window : & WebviewWindow ,
296+ mut bounds : HashMap < String , LogicalBounds > ,
297+ token : CancellationToken ,
298+ ) -> Option < bool > {
299+ use objc2_app_kit:: { NSEvent , NSWindow } ;
300+ use objc2_foundation:: NSSize ;
301+
302+ let ( tx, rx) = tokio:: sync:: oneshot:: channel ( ) ;
303+ let handle = window. clone ( ) ;
304+ window
305+ . run_on_main_thread ( move || {
306+ if tx. is_closed ( ) || token. is_cancelled ( ) {
307+ return ;
308+ }
309+ let result = objc2:: rc:: autoreleasepool ( |_| {
310+ let ptr = handle. ns_window ( ) . ok ( ) ? as * const NSWindow ;
311+ let native = unsafe { ptr. as_ref ( ) ? } ;
312+ let view = native. contentView ( ) ?;
313+ let frame = native. frame ( ) ;
314+ let scale = native. backingScaleFactor ( ) ;
315+ let small = recording_controls_size_allows_default_interaction (
316+ tauri:: LogicalSize :: new ( frame. size . width , frame. size . height ) . to_physical ( scale) ,
317+ scale,
318+ ) ;
319+ let pressed = unsafe { NSEvent :: pressedMouseButtons ( ) } & 1 != 0 ;
320+ if !small {
321+ native. setIgnoresMouseEvents ( true ) ;
322+ native. setContentSize ( NSSize :: new (
323+ RECORDING_CONTROLS_WIDTH ,
324+ RECORDING_CONTROLS_HEIGHT ,
325+ ) ) ;
326+ return Some ( pressed) ;
327+ }
328+ let point = view. convertPoint_fromView (
329+ unsafe { native. mouseLocationOutsideOfEventStream ( ) } ,
330+ None ,
331+ ) ;
332+ let view_bounds = view. bounds ( ) ;
333+ ensure_recording_controls_bounds (
334+ & mut bounds,
335+ view_bounds. size . width ,
336+ view_bounds. size . height ,
337+ ) ;
338+ let y = if view. isFlipped ( ) {
339+ point. y - view_bounds. origin . y
340+ } else {
341+ view_bounds. origin . y + view_bounds. size . height - point. y
342+ } ;
343+ let ignore = should_ignore_cursor_events (
344+ tauri:: PhysicalPosition :: new ( 0 , 0 ) ,
345+ tauri:: PhysicalPosition :: new (
346+ ( point. x - view_bounds. origin . x ) * scale,
347+ y * scale,
348+ ) ,
349+ scale,
350+ & bounds,
351+ false ,
352+ true ,
353+ ) ;
354+ // Keep the mouse-up routed to this panel when a drag crosses its hit area.
355+ let ignore = preserve_pointer_gesture ( ignore, pressed, unsafe {
356+ native. ignoresMouseEvents ( )
357+ } ) ;
358+ if unsafe { native. ignoresMouseEvents ( ) } != ignore {
359+ native. setIgnoresMouseEvents ( ignore) ;
360+ debug ! (
361+ ignore,
362+ pressed,
363+ bounds = bounds. len( ) ,
364+ "Recording controls hit test changed"
365+ ) ;
366+ }
367+ Some ( pressed)
368+ } ) ;
369+ let _ = tx. send ( result) ;
370+ } )
371+ . ok ( ) ?;
372+ tokio:: time:: timeout ( Duration :: from_millis ( 250 ) , rx)
373+ . await
374+ . ok ( ) ?
375+ . ok ( ) ?
376+ }
377+
262378fn get_display_id_for_cursor ( ) -> Option < DisplayId > {
263379 Display :: get_containing_cursor ( ) . map ( |d| d. id ( ) )
264380}
@@ -391,7 +507,26 @@ pub fn spawn_fake_window_listener(app: AppHandle, window: WebviewWindow) {
391507 break ;
392508 }
393509
394- if is_recording_controls {
510+ #[ cfg( target_os = "macos" ) ]
511+ let controls_pressed = if is_recording_controls {
512+ let bounds = state
513+ . 0
514+ . read ( )
515+ . await
516+ . get ( & label)
517+ . cloned ( )
518+ . unwrap_or_default ( ) ;
519+ match update_recording_controls_hit_test ( & window, bounds, token. clone ( ) ) . await {
520+ Some ( pressed) => pressed,
521+ None => continue ,
522+ }
523+ } else {
524+ false
525+ } ;
526+ #[ cfg( not( target_os = "macos" ) ) ]
527+ let controls_pressed = false ;
528+
529+ if is_recording_controls && !controls_pressed {
395530 let capture_target = app. state :: < ArcLock < App > > ( ) . try_read ( ) . ok ( ) . and_then ( |s| {
396531 match & s. recording_state {
397532 RecordingState :: Pending { target, .. } => Some ( target. clone ( ) ) ,
@@ -444,9 +579,14 @@ pub fn spawn_fake_window_listener(app: AppHandle, window: WebviewWindow) {
444579 }
445580 }
446581
447- let map = state. 0 . read ( ) . await ;
582+ #[ cfg( target_os = "macos" ) ]
583+ if is_recording_controls {
584+ continue ;
585+ }
586+
587+ let windows = state. 0 . read ( ) . await . get ( & label) . cloned ( ) ;
448588
449- let Some ( windows) = map . get ( & label ) else {
589+ let Some ( windows) = windows else {
450590 let ignore = if is_recording_controls {
451591 !prepare_recording_controls_default_interaction ( & window)
452592 } else {
@@ -518,7 +658,7 @@ pub fn spawn_fake_window_listener(app: AppHandle, window: WebviewWindow) {
518658 window_position,
519659 mouse_position,
520660 scale_factor,
521- windows,
661+ & windows,
522662 default_ignore,
523663 allow_default_interaction,
524664 ) ;
@@ -535,15 +675,8 @@ pub fn spawn_fake_window_listener(app: AppHandle, window: WebviewWindow) {
535675 }
536676 }
537677
538- if is_recording_controls {
539- let ignore = !prepare_recording_controls_default_interaction ( & window) ;
540- let _ = window. set_ignore_cursor_events ( ignore) ;
541- }
542-
543- listeners. finish ( & label, listener_id) ;
544-
545- {
546- let mut map = state. 0 . write ( ) . await ;
678+ let mut map = state. 0 . write ( ) . await ;
679+ if listeners. finish ( & label, listener_id) && !app. webview_windows ( ) . contains_key ( & label) {
547680 map. remove ( & label) ;
548681 }
549682 } ) ;
@@ -571,6 +704,65 @@ mod tests {
571704 use super :: * ;
572705 use scap_targets:: bounds:: { LogicalPosition , LogicalSize } ;
573706
707+ #[ test]
708+ fn tooltip_only_bounds_do_not_disable_the_recording_bar ( ) {
709+ let mut registered = HashMap :: from ( [ ( "tooltip" . into ( ) , bounds ( 30.0 , 60.0 , 80.0 , 24.0 ) ) ] ) ;
710+ ensure_recording_controls_bounds ( & mut registered, 320.0 , 150.0 ) ;
711+ for ( x, y, ignore) in [ ( 24.0 , 110.0 , false ) , ( 40.0 , 70.0 , false ) , ( 4.0 , 30.0 , true ) ] {
712+ assert_eq ! (
713+ should_ignore_cursor_events(
714+ tauri:: PhysicalPosition :: new( 0 , 0 ) ,
715+ tauri:: PhysicalPosition :: new( x, y) ,
716+ 1.0 ,
717+ & registered,
718+ false ,
719+ true ,
720+ ) ,
721+ ignore,
722+ ) ;
723+ }
724+ }
725+
726+ #[ test]
727+ fn registered_issue_panel_bounds_are_preserved ( ) {
728+ let issue = bounds ( 12.0 , 40.0 , 296.0 , 98.0 ) ;
729+ let mut registered = HashMap :: from ( [ ( RECORDING_CONTROLS_BOUNDS_NAME . into ( ) , issue) ] ) ;
730+ ensure_recording_controls_bounds ( & mut registered, 320.0 , 150.0 ) ;
731+ let actual = registered[ RECORDING_CONTROLS_BOUNDS_NAME ] ;
732+ assert_eq ! ( actual. position( ) . x( ) , issue. position( ) . x( ) ) ;
733+ assert_eq ! ( actual. position( ) . y( ) , issue. position( ) . y( ) ) ;
734+ assert_eq ! ( actual. size( ) . width( ) , issue. size( ) . width( ) ) ;
735+ assert_eq ! ( actual. size( ) . height( ) , issue. size( ) . height( ) ) ;
736+ }
737+
738+ #[ test]
739+ fn replaced_listener_cannot_finish_the_current_listener ( ) {
740+ let listeners = FakeWindowListeners :: default ( ) ;
741+ let ( old, old_token) = listeners. register ( "controls" . into ( ) ) ;
742+ let ( current, current_token) = listeners. register ( "controls" . into ( ) ) ;
743+ assert ! ( old_token. is_cancelled( ) ) ;
744+ assert ! ( !listeners. finish( "controls" , old) ) ;
745+ assert ! ( !current_token. is_cancelled( ) ) ;
746+ assert ! ( listeners. finish( "controls" , current) ) ;
747+ }
748+
749+ #[ test]
750+ fn cancelled_listener_retains_ownership_until_cleanup ( ) {
751+ let listeners = FakeWindowListeners :: default ( ) ;
752+ let ( id, token) = listeners. register ( "controls" . into ( ) ) ;
753+ listeners. cancel ( "controls" ) ;
754+ assert ! ( token. is_cancelled( ) ) ;
755+ assert ! ( listeners. finish( "controls" , id) ) ;
756+ }
757+
758+ #[ test]
759+ fn pointer_gesture_remains_interactive_until_release ( ) {
760+ assert ! ( !preserve_pointer_gesture( true , true , false ) ) ;
761+ assert ! ( preserve_pointer_gesture( true , false , false ) ) ;
762+ assert ! ( preserve_pointer_gesture( true , true , true ) ) ;
763+ assert ! ( !preserve_pointer_gesture( false , true , true ) ) ;
764+ }
765+
574766 fn bounds ( x : f64 , y : f64 , width : f64 , height : f64 ) -> LogicalBounds {
575767 LogicalBounds :: new ( LogicalPosition :: new ( x, y) , LogicalSize :: new ( width, height) )
576768 }
0 commit comments