Skip to content

Commit d786b2a

Browse files
fix: stabilize recording controls and editor loading (#2274)
2 parents 28a20cd + 02b165f commit d786b2a

13 files changed

Lines changed: 602 additions & 170 deletions

apps/desktop-gpui/src/editor_window.rs

Lines changed: 95 additions & 124 deletions
Large diffs are not rendered by default.

apps/desktop/src-tauri/src/fake_window.rs

Lines changed: 210 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ use tracing::{debug, instrument};
1616
use crate::{App, ArcLock, RecordingState};
1717

1818
const 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";
1921
const RECORDING_CONTROLS_WIDTH: f64 = 320.0;
2022
const RECORDING_CONTROLS_HEIGHT: f64 = 150.0;
2123
const 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+
262378
fn 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
}

apps/desktop/src-tauri/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8633,7 +8633,7 @@ async fn resume_uploads(app: AppHandle, mark_crashed: bool) -> Result<(), String
86338633
Ok(Some(candidate)) => candidate,
86348634
Ok(None) => continue,
86358635
Err(error) => {
8636-
warn!(%error, "Recording upload state could not be read; files retained");
8636+
warn!(%error, recording = %path.display(), "Recording upload state could not be read; files retained");
86378637
continue;
86388638
}
86398639
};

0 commit comments

Comments
 (0)