diff --git a/apps/desktop-gpui/src/picker_probe.rs b/apps/desktop-gpui/src/picker_probe.rs index fe639bc23e..66f9aa3aef 100644 --- a/apps/desktop-gpui/src/picker_probe.rs +++ b/apps/desktop-gpui/src/picker_probe.rs @@ -210,6 +210,16 @@ async fn probe_window_arrangement( .map(|bounds| (bounds.size().width() as f32, bounds.size().height() as f32)); let before = Some(picked.bounds); + // Start from Focus off whatever the saved settings say: turning Center + // off later under a saved Focus would put back the pre-resize frame. + overlay + .update(cx, |view, _, cx| { + view.set_focus_center(false, cx); + view.set_focus(false, cx) + }) + .ok(); + sleep(cx, 500).await; + // `window_arrange::RATIOS` lists 16:9 second. overlay .update(cx, |view, _, cx| view.choose_window_size(1, cx)) @@ -229,10 +239,24 @@ async fn probe_window_arrangement( }; screenshot(output, "window-16x9", cx).await; + // Focus alone records the window where it is: only Center on screen + // moves it. overlay .update(cx, |view, _, cx| view.set_focus(true, cx)) .ok(); sleep(cx, 1500).await; + let unmoved = read_window(&picked.id); + let stayed = unmoved.zip(shaped).is_some_and(|(now, then)| { + (now.x - then.x).abs() < 6. + && (now.y - then.y).abs() < 6. + && (now.width - then.width).abs() < 6. + && (now.height - then.height).abs() < 6. + }); + + overlay + .update(cx, |view, _, cx| view.set_focus_center(true, cx)) + .ok(); + sleep(cx, 1500).await; let focused = read_window(&picked.id); let centred = focused.zip(display_size).is_some_and(|(rect, (width, _))| { (rect.x + rect.width / 2. - width / 2.).abs() < width * 0.04 @@ -256,7 +280,10 @@ async fn probe_window_arrangement( // Dismissed with the window still moved: only the dismissal can put it // back. overlay - .update(cx, |view, _, cx| view.set_focus(true, cx)) + .update(cx, |view, _, cx| { + view.set_focus_center(true, cx); + view.set_focus(true, cx) + }) .ok(); sleep(cx, 1500).await; let moved = !near(read_window(&picked.id), before); @@ -267,12 +294,15 @@ async fn probe_window_arrangement( serde_json::json!({ "passed": ratio_ok && inside(shaped) + && stayed && centred && inside(focused) && unfocused_ok && restored_ok, "before": rect_json(before), "sixteenByNine": rect_json(shaped), + "focusWithoutCenter": rect_json(unmoved), + "focusWithoutCenterStayed": stayed, "ratioOk": ratio_ok, "focused": rect_json(focused), "centred": centred, diff --git a/apps/desktop-gpui/src/store.rs b/apps/desktop-gpui/src/store.rs index f5024bc239..4c5cb4d3e1 100644 --- a/apps/desktop-gpui/src/store.rs +++ b/apps/desktop-gpui/src/store.rs @@ -1039,6 +1039,7 @@ pub struct GeneralSettings { pub recording_countdown: Option, pub main_window_recording_start_behaviour: MainWindowStartBehaviour, pub window_capture_style: WindowCaptureStyle, + pub focus_center: bool, pub post_studio_recording_behaviour: PostStudioBehaviour, pub post_screenshot_behaviour: PostScreenshotBehaviour, pub post_deletion_behaviour: PostDeletionBehaviour, @@ -1448,6 +1449,7 @@ impl GeneralSettings { "mainWindowRecordingStartBehaviour", ), window_capture_style: enum_at(general, "windowCaptureStyle"), + focus_center: bool_at(general, "focusCenter", false), post_studio_recording_behaviour: enum_at(general, "postStudioRecordingBehaviour"), post_screenshot_behaviour: enum_at(general, "postScreenshotBehaviour"), post_deletion_behaviour: enum_at(general, "postDeletionBehaviour"), diff --git a/apps/desktop-gpui/src/target_overlay.rs b/apps/desktop-gpui/src/target_overlay.rs index d9f7902b93..889c699ad7 100644 --- a/apps/desktop-gpui/src/target_overlay.rs +++ b/apps/desktop-gpui/src/target_overlay.rs @@ -308,6 +308,7 @@ pub struct TargetSelect { /// Focus, shared by every display's overlay because the recording reads /// the one setting it mirrors. pub focus_window: bool, + pub focus_center: bool, _poll: Option>, } @@ -324,9 +325,13 @@ enum Arrangement { PutBack(ArrangedWindow), } +/// Focus records a window by itself, which only macOS and Windows capture. +const FOCUS_SUPPORTED: bool = cfg!(any(target_os = "macos", windows)); + fn saved_focus() -> bool { - crate::store::GeneralSettings::load().window_capture_style - == crate::store::WindowCaptureStyle::FocusedWindow + FOCUS_SUPPORTED + && crate::store::GeneralSettings::load().window_capture_style + == crate::store::WindowCaptureStyle::FocusedWindow } struct TargetSelectGlobal(Entity); @@ -344,6 +349,7 @@ impl TargetSelect { icons: HashMap::new(), arranged: HashMap::new(), focus_window: saved_focus(), + focus_center: crate::store::GeneralSettings::load().focus_center, _poll: None, }); cx.set_global(TargetSelectGlobal(select.clone())); @@ -369,6 +375,7 @@ impl TargetSelect { // them back nor recorded them. crate::foreign_window::put_back_all(self.take_arranged(), cx.background_executor()); self.focus_window = saved_focus(); + self.focus_center = crate::store::GeneralSettings::load().focus_center; } if self.mode == mode { cx.notify(); @@ -2224,11 +2231,12 @@ impl OverlayWindow { } fn render_focus_toggle(&self, cx: &mut Context) -> Option { - if !crate::foreign_window::can_arrange() { + if !FOCUS_SUPPORTED || !crate::foreign_window::can_arrange() { return None; } let theme = self.theme; - let focus = self.select.read(cx).focus_window; + let select = self.select.read(cx); + let (focus, center) = (select.focus_window, select.focus_center); // Instant records the camera bubble off the screen, so with a camera // it keeps the screen region (see `instant_capture_window`). let camera = cx @@ -2241,93 +2249,179 @@ impl OverlayWindow { } else { "Enable Focus mode" }; - let body = if cfg!(any(target_os = "macos", windows)) - && self.select.read(cx).recording_mode == Mode::Instant - && camera - { - "Centres this window. With a camera, Instant still records the screen region so the camera stays in the video." + let explanation = if select.recording_mode == Mode::Instant && camera { + "With a camera, Instant still records the screen region so the camera stays in the video." } else { - "Centres this window and records only it. Anything that moves in front of it stays out of the video." + "Only what's inside this window is recorded. Other windows, even ones on top of it, and the rest of your screen stay out of the video." }; - // Regular, as the controls below it are: it carries text. let card = self.glass_surface().id("window-focus"); - let hint = Hint::new(title).body(body).side(crate::ui::HintSide::Below); + let hint = Hint::new(title) + .body(explanation) + .side(crate::ui::HintSide::Left); let accent = Hsla::from(theme.blue_9); - Some( - self.hinted(card, "window-focus", hint, cx) - .mb(px(10.)) - .h(px(52.)) - .pl(px(10.)) - .pr(px(6.)) - .flex() - .items_center() - .gap(px(10.)) - .cursor_pointer() - .when(focus, |card| { - card.border_1().border_color(accent.opacity(0.7)) - }) - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - // The whole card is the switch's label, as a macOS form row is. - .on_click(cx.listener(|this, _, _, cx| { - cx.stop_propagation(); - let focus = this.select.read(cx).focus_window; - this.set_focus(!focus, cx); - })) - .child( - div() - .size(px(32.)) - .flex_shrink_0() - .flex() - .items_center() - .justify_center() - .rounded(px(9.)) - .bg(if focus { - accent - } else { - Theme::with_alpha(theme.gray_12, 0.08) - }) - .child(svg().path("icons/box-select.svg").size(px(17.)).text_color( - if focus { + // The hint belongs to the Focus row alone: Center on screen explains + // itself under its title. + let header = self + .hinted( + div().id("window-focus-header").relative(), + "window-focus", + hint, + cx, + ) + .h(px(52.)) + .pl(px(10.)) + .pr(px(6.)) + .flex() + .items_center() + .gap(px(10.)) + .cursor_pointer() + // The whole row is the switch's label, as a macOS form row is. + .on_click(cx.listener(|this, _, _, cx| { + cx.stop_propagation(); + let focus = this.select.read(cx).focus_window; + this.set_focus(!focus, cx); + })) + .child( + div() + .size(px(32.)) + .flex_shrink_0() + .flex() + .items_center() + .justify_center() + .rounded(px(9.)) + .bg(if focus { + accent + } else { + Theme::with_alpha(theme.gray_12, 0.08) + }) + .child( + svg() + .path("icons/box-select.svg") + .size(px(17.)) + .text_color(if focus { gpui::white().into() } else { Hsla::from(theme.gray_12) - }, - )), + }), + ), + ) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .pr(px(14.)) + .child( + div() + .text_size(px(13.)) + .line_height(px(17.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.gray_12) + .child("Focus mode"), + ) + .child( + div() + .text_size(px(11.5)) + .line_height(px(15.)) + .font_weight(FontWeight::NORMAL) + .text_color(theme.gray_11) + .child(if focus { + "Recording only this window" + } else { + "Record only this window" + }), + ), + ) + .child( + div().flex_shrink_0().child( + crate::ui::GlassSwitch::new("window-focus-toggle", focus, theme.is_dark()) + .on_click(cx.listener(|this, _, _, cx| { + cx.stop_propagation(); + let focus = this.select.read(cx).focus_window; + this.set_focus(!focus, cx); + })), + ), + ); + let details = focus.then(|| { + div() + .flex() + .flex_col() + .gap(px(8.)) + .px(px(12.)) + .pt(px(2.)) + .pb(px(10.)) + .child( + div() + .text_size(px(11.5)) + .line_height(px(15.)) + .text_color(theme.gray_11) + .child(explanation), ) + .child(div().h(px(1.)).bg(Theme::with_alpha(theme.gray_12, 0.08))) .child( div() + .id("window-focus-center") .flex() - .flex_col() - .pr(px(14.)) + .items_center() + .gap(px(10.)) + .cursor_pointer() + .on_click(cx.listener(|this, _, _, cx| { + cx.stop_propagation(); + let center = this.select.read(cx).focus_center; + this.set_focus_center(!center, cx); + })) .child( div() - .text_size(px(13.)) - .line_height(px(17.)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(theme.gray_12) - .child("Focus mode"), + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child( + div() + .text_size(px(12.5)) + .line_height(px(16.)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme.gray_12) + .child("Center on screen"), + ) + .child( + div() + .text_size(px(11.)) + .line_height(px(14.)) + .text_color(theme.gray_11) + .child("Move the window to the middle when you pick it"), + ), ) .child( - div() - .text_size(px(11.5)) - .line_height(px(15.)) - .font_weight(FontWeight::NORMAL) - .text_color(theme.gray_11) - .child(if focus { - "Recording only this window" - } else { - "Record only this window" - }), + div().flex_shrink_0().child( + crate::ui::GlassSwitch::new( + "window-focus-center-toggle", + center, + theme.is_dark(), + ) + .on_click(cx.listener( + |this, _, _, cx| { + cx.stop_propagation(); + let center = this.select.read(cx).focus_center; + this.set_focus_center(!center, cx); + }, + )), + ), ), ) - .child( - crate::ui::GlassSwitch::new("window-focus-toggle", focus, theme.is_dark()) - .on_click(cx.listener(|this, _, _, cx| { - cx.stop_propagation(); - let focus = this.select.read(cx).focus_window; - this.set_focus(!focus, cx); - })), - ), + }); + Some( + card.mb(px(10.)) + .w(px(300.)) + .flex() + .flex_col() + .when(focus, |card| { + card.border_1().border_color(accent.opacity(0.7)) + }) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child(header) + .children(details), ) } @@ -2427,8 +2521,6 @@ impl OverlayWindow { } } - /// Focus on: centre the window, bring it forward, record it by itself. - /// Focus off: put it back where the picker found it, record the region. pub(crate) fn set_focus(&mut self, focus: bool, cx: &mut Context) { self.select.update(cx, |select, cx| { select.focus_window = focus; @@ -2451,8 +2543,12 @@ impl OverlayWindow { return; }; if focus { - let centred = crate::window_arrange::centre(window, self.controls_work_area); - self.arrange_active_window(Arrangement::Frame(centred), true, cx); + if self.select.read(cx).focus_center { + let centred = crate::window_arrange::centre(window, self.controls_work_area); + self.arrange_active_window(Arrangement::Frame(centred), true, cx); + } else { + cx.notify(); + } } else { let original = self .select @@ -2468,6 +2564,39 @@ impl OverlayWindow { } } + pub(crate) fn set_focus_center(&mut self, center: bool, cx: &mut Context) { + self.select.update(cx, |select, cx| { + select.focus_center = center; + cx.notify(); + }); + if !crate::store::set_store_setting( + crate::store::GENERAL_SETTINGS, + "focusCenter", + serde_json::Value::Bool(center), + ) { + tracing::warn!("could not save Focus centering"); + } + if !self.select.read(cx).focus_window { + return; + } + let Some(window) = self.active_window_logical(cx) else { + return; + }; + if center { + let centred = crate::window_arrange::centre(window, self.controls_work_area); + self.arrange_active_window(Arrangement::Frame(centred), true, cx); + } else { + let original = self + .select + .read(cx) + .active_window() + .and_then(|active| self.select.read(cx).arranged_original(&active.id).cloned()); + if let Some(original) = original { + self.arrange_active_window(Arrangement::PutBack(original), false, cx); + } + } + } + /// Move the picked window (`Frame` in this overlay's logical points, or /// back where the picker found it), then follow wherever its app /// actually put it. @@ -2490,6 +2619,7 @@ impl OverlayWindow { }); let capture_scale = self.capture_scale; let (id, display) = (window.id.clone(), window.display_id.clone()); + let before = window.bounds; let requested = id.clone(); let select = self.select.clone(); cx.spawn(async move |this, cx| { @@ -2503,11 +2633,23 @@ impl OverlayWindow { if raise { crate::foreign_window::raise(&id); } - let window = scap_targets::Window::from_id(&id); - ( - arranged, - window.as_ref().and_then(HoveredWindow::from_window), - ) + // The window list lags the move by a few frames: wait for the + // new frame to show up, or the camera would see no move. + let read = || { + scap_targets::Window::from_id(&id) + .as_ref() + .and_then(HoveredWindow::from_window) + }; + let mut fresh = read(); + let deadline = Instant::now() + Duration::from_millis(500); + while arranged + && fresh.as_ref().is_some_and(|fresh| fresh.bounds == before) + && Instant::now() < deadline + { + std::thread::sleep(Duration::from_millis(16)); + fresh = read(); + } + (arranged, fresh) }) .await .unwrap_or((false, None)); @@ -3439,19 +3581,35 @@ impl OverlayWindow { fn render_close_button(&self, cx: &mut Context) -> impl IntoElement { let theme = self.theme; - self.glass_button(36.) - .id("overlay-close") - .hover(|style| style.opacity(0.8)) - .child( - svg() - .path("icons/x.svg") - .size(px(12.)) - .text_color(theme.gray_12), - ) - .on_click(cx.listener(|this, _, _window, cx| { - cx.stop_propagation(); - this.dismiss(cx); - })) + // Red-tinted glass, so cancelling reads apart from the controls. + let tint = gpui::Rgba { + a: 0.55, + ..theme.red_9 + }; + crate::ui::glass::pane_over_tinted( + crate::ui::glass::GlassKind::Clear, + theme.is_dark(), + 18., + &self.backdrop, + tint, + ) + .size(px(36.)) + .flex_shrink_0() + .flex() + .justify_center() + .items_center() + .id("overlay-close") + .hover(|style| style.opacity(0.8)) + .child( + svg() + .path("icons/x.svg") + .size(px(12.)) + .text_color(gpui::white()), + ) + .on_click(cx.listener(|this, _, _window, cx| { + cx.stop_propagation(); + this.dismiss(cx); + })) } /// The start affordance: `h-11 rounded-full bg-linear-to-r from-blue-10 to @@ -3492,6 +3650,8 @@ impl OverlayWindow { div() .id("overlay-start") + .relative() + .child(self.hints.keep_clear("overlay-start")) .flex() .flex_1() .min_w_0() diff --git a/apps/desktop-gpui/src/ui/glass.rs b/apps/desktop-gpui/src/ui/glass.rs index 1b7910e6c8..d240a64410 100644 --- a/apps/desktop-gpui/src/ui/glass.rs +++ b/apps/desktop-gpui/src/ui/glass.rs @@ -138,6 +138,17 @@ pub fn pane_over(kind: GlassKind, dark: bool, radius: f32, backdrop: &Backdrop) pane_with(kind, dark, radius, Some(backdrop.clone()), None, true) } +/// [`pane_over`] with a tint. +pub fn pane_over_tinted( + kind: GlassKind, + dark: bool, + radius: f32, + backdrop: &Backdrop, + tint: Tint, +) -> Div { + pane_with(kind, dark, radius, Some(backdrop.clone()), Some(tint), true) +} + fn pane_with( kind: GlassKind, dark: bool, diff --git a/apps/desktop-gpui/src/ui/hint.rs b/apps/desktop-gpui/src/ui/hint.rs index 70c7776230..6a47ef97c3 100644 --- a/apps/desktop-gpui/src/ui/hint.rs +++ b/apps/desktop-gpui/src/ui/hint.rs @@ -126,14 +126,16 @@ pub struct Placement { /// Places a `bubble`-sized hint beside `control` in a `window`-sized space: /// above if it fits, then below, right and left, skipping any side that would -/// land on the pointer at `cursor`. `prefer` keeps a hint that is already -/// showing on its side while that still works, so it never jumps. +/// land on the pointer at `cursor` or on a control in `keep_clear` (the Start +/// button). `prefer` keeps a hint that is already showing on its side while +/// that still works, so it never jumps. pub fn place( control: Bounds, bubble: Size, cursor: Point, window: Size, prefer: Option, + keep_clear: &[Bounds], ) -> Placement { let (w, h) = (f32::from(bubble.width), f32::from(bubble.height)); let (win_w, win_h) = (f32::from(window.width), f32::from(window.height)); @@ -156,7 +158,9 @@ pub fn place( }; let (cx, cy) = (f32::from(cursor.x), f32::from(cursor.y)); let pointer = (cx + CURSOR.0, cy + CURSOR.1, cx + CURSOR.2, cy + CURSOR.3); - let works = |side: Side| { + // Whether the hint on `side` stays in the window, clear of the pointer, + // and clear of every control in `keep_clear`. + let fits = |side: Side| { let (x, y) = candidate(side); let inside = x >= MARGIN - 0.5 && y >= MARGIN - 0.5 @@ -169,14 +173,31 @@ pub fn place( Side::Right => (right, y, x + w, y + h), Side::Left => (x, y, left, y + h), }; - let covers_pointer = pointer.0 < x1 && pointer.2 > x0 && pointer.1 < y1 && pointer.3 > y0; - inside && !covers_pointer + let covers = |a: (f32, f32, f32, f32)| a.0 < x1 && a.2 > x0 && a.1 < y1 && a.3 > y0; + let covers_pointer = covers(pointer); + let covers_kept = keep_clear.iter().any(|kept| { + let (kx, ky) = (f32::from(kept.origin.x), f32::from(kept.origin.y)); + covers(( + kx, + ky, + kx + f32::from(kept.size.width), + ky + f32::from(kept.size.height), + )) + }); + (inside, covers_pointer, covers_kept) }; + let works = |side: Side| fits(side) == (true, false, false); let order = [Side::Above, Side::Below, Side::Right, Side::Left]; let side = prefer .filter(|side| works(*side)) .or_else(|| order.into_iter().find(|side| works(*side))) + // Next best: on the pointer, but never over a kept-clear control. + .or_else(|| { + order + .into_iter() + .find(|side| matches!(fits(*side), (true, _, false))) + }) .unwrap_or(Side::Above); let (mut x, mut y) = candidate(side); if side == Side::Above && !works(side) { @@ -221,6 +242,8 @@ struct Active { #[derive(Default)] pub struct Hints { anchors: Rc>>, + /// Controls no hint may cover, with the frame each was last painted in. + keep_clear: Rc, u64)>>>, frame: u64, active: Option, /// Bumped by every hover change; a timer only acts if none came since. @@ -258,6 +281,22 @@ impl Hints { .size_full() } + /// An invisible child of a control no hint may cover, laid out over it. + pub fn keep_clear(&self, id: &'static str) -> impl IntoElement { + let keep_clear = self.keep_clear.clone(); + let frame = self.frame; + canvas( + move |bounds, _, _| { + keep_clear.borrow_mut().insert(id, (bounds, frame)); + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full() + } + /// The pointer entered or left control `id`. `hints` finds this /// [`Hints`] in the view, for the timers. pub fn hover( @@ -363,12 +402,20 @@ impl Hints { px((text.0 + 2. * PAD_X + 2.).ceil()), px((text.1 + 2. * PAD_Y + 2.).ceil()), ); + let keep_clear: Vec> = self + .keep_clear + .borrow() + .values() + .filter(|(_, frame)| frame + 1 >= self.frame) + .map(|(bounds, _)| *bounds) + .collect(); let placed = place( bounds, bubble, window.mouse_position(), window.viewport_size(), self.side.get().or(hint.side), + &keep_clear, ); self.side.set(Some(placed.side)); @@ -580,6 +627,7 @@ mod tests { point(px(610.), px(716.)), window(), None, + &[], ); assert_eq!(placed.side, Side::Above); assert_eq!( @@ -598,6 +646,7 @@ mod tests { point(px(610.), px(26.)), window(), None, + &[], ); assert_eq!(placed.side, Side::Below); } @@ -608,7 +657,14 @@ mod tests { // edge: below would sit on the pointer's arrow, so it goes beside. let control = rect(600., 20., 120., 32.); let cursor = point(px(660.), px(50.)); - let placed = place(control, size(px(200.), px(50.)), cursor, window(), None); + let placed = place( + control, + size(px(200.), px(50.)), + cursor, + window(), + None, + &[], + ); assert_ne!(placed.side, Side::Below); assert!(!placed.bubble.contains(&cursor)); } @@ -622,6 +678,7 @@ mod tests { point(px(10.), px(710.)), window(), None, + &[], ); assert_eq!(placed.side, Side::Above); assert_eq!(placed.bubble.origin.x, px(MARGIN)); @@ -638,7 +695,31 @@ mod tests { point(px(610.), px(406.)), window(), Some(Side::Left), + &[], ); assert_eq!(placed.side, Side::Left); } + + #[test] + fn never_covers_a_kept_clear_control() { + // A card with the Start button just below it: the hint asked to sit + // below goes beside the card instead of over the button. + let card = rect(600., 500., 300., 52.); + let start = rect(620., 562., 260., 44.); + let placed = place( + card, + size(px(220.), px(70.)), + point(px(700.), px(526.)), + window(), + Some(Side::Below), + &[start], + ); + assert_ne!(placed.side, Side::Below); + let bubble = placed.bubble; + let overlaps = bubble.origin.x < start.origin.x + start.size.width + && bubble.origin.x + bubble.size.width > start.origin.x + && bubble.origin.y < start.origin.y + start.size.height + && bubble.origin.y + bubble.size.height > start.origin.y; + assert!(!overlaps); + } }