diff --git a/.github/workflows/picker-smoke.yml b/.github/workflows/picker-smoke.yml index c47a2d8eb5a..58bdd3eba8d 100644 --- a/.github/workflows/picker-smoke.yml +++ b/.github/workflows/picker-smoke.yml @@ -11,6 +11,8 @@ on: - "apps/desktop-gpui/src/main_window.rs" - "apps/desktop-gpui/src/foreign_window.rs" - "apps/desktop-gpui/src/window_arrange.rs" + - "apps/desktop-gpui/src/overlay_backdrop.rs" + - "apps/desktop-gpui/src/ui/glass.rs" - "apps/desktop-gpui/src/platform.rs" - "apps/desktop-gpui/src/platform/**" - "scripts/picker-smoke/**" diff --git a/apps/desktop-gpui/assets/icons/hint-arrow-down.svg b/apps/desktop-gpui/assets/icons/hint-arrow-down.svg new file mode 100644 index 00000000000..1fa74b41f1d --- /dev/null +++ b/apps/desktop-gpui/assets/icons/hint-arrow-down.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/assets/icons/hint-arrow-left.svg b/apps/desktop-gpui/assets/icons/hint-arrow-left.svg new file mode 100644 index 00000000000..f6ef0d5f75f --- /dev/null +++ b/apps/desktop-gpui/assets/icons/hint-arrow-left.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/assets/icons/hint-arrow-right.svg b/apps/desktop-gpui/assets/icons/hint-arrow-right.svg new file mode 100644 index 00000000000..27bcf6b552c --- /dev/null +++ b/apps/desktop-gpui/assets/icons/hint-arrow-right.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/assets/icons/hint-arrow-up.svg b/apps/desktop-gpui/assets/icons/hint-arrow-up.svg new file mode 100644 index 00000000000..29afd2b2059 --- /dev/null +++ b/apps/desktop-gpui/assets/icons/hint-arrow-up.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index 8ee10de1d0b..86b6f1ad779 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -4001,6 +4001,13 @@ fn begin_recording_after_preflight(config: StartConfig, cx: &mut App) { let mut excluded: Vec = config.excluded_windows.clone(); excluded.extend(open_controls(&config, session.clone(), cx)); + // Focus marks the window it records with a ring for the whole recording. + if let ScreenCaptureTarget::Window { id } = &config.target + && crate::store::GeneralSettings::load().window_capture_style + == crate::store::WindowCaptureStyle::FocusedWindow + { + excluded.extend(crate::focus_border::open(id, cx)); + } // The camera bubble is excluded from studio captures (the camera is its own // track, composited in the editor) but *included* in instant captures -- // `filter_for_instant_mode` in the Tauri app strips the camera exclusion @@ -4720,6 +4727,64 @@ fn park_camera_in_window(target: &HoveredWindow, cx: &mut App) -> bool { true } +/// The picker moved the picked window from `before` to `after` (Focus +/// centring it, or the size menu): a camera bubble sitting on the window goes +/// with it, keeping its place relative to the window's bottom-right corner, +/// where a park puts it, and staying inside the window. +pub fn carry_camera_with_window(before: &HoveredWindow, after: &HoveredWindow, cx: &mut App) { + let origin = |display: &DisplayId| { + let bounds = scap_targets::Display::from_id(display)? + .raw_handle() + .logical_bounds()?; + Some((bounds.position().x() as f32, bounds.position().y() as f32)) + }; + let (Some(from), Some(to)) = (origin(&before.display_id), origin(&after.display_id)) else { + return; + }; + let Some(camera) = camera_snapshot(cx) else { + return; + }; + let old = AreaRect { + x: before.bounds.x + from.0, + y: before.bounds.y + from.1, + ..before.bounds + }; + let new = AreaRect { + x: after.bounds.x + to.0, + y: after.bounds.y + to.1, + ..after.bounds + }; + let bubble = camera.bounds; + let centre = (bubble.x + bubble.width / 2., bubble.y + bubble.height / 2.); + let on_window = centre.0 >= old.x + && centre.0 <= old.x + old.width + && centre.1 >= old.y + && centre.1 <= old.y + old.height; + if !on_window || old == new { + return; + } + let right_gap = old.x + old.width - (bubble.x + bubble.width); + let bottom_gap = old.y + old.height - (bubble.y + bubble.height); + let (min_x, min_y) = (new.x + CAMERA_PARK_PADDING, new.y + CAMERA_PARK_PADDING); + let max_x = (new.x + new.width - bubble.width - CAMERA_PARK_PADDING).max(min_x); + let max_y = (new.y + new.height - bubble.height - CAMERA_PARK_PADDING).max(min_y); + let x = (new.x + new.width - bubble.width - right_gap).clamp(min_x, max_x); + let y = (new.y + new.height - bubble.height - bottom_gap).clamp(min_y, max_y); + cx.global_mut::().camera_park.last_window = Some(after.id.clone()); + queue_camera_placement( + CameraPlacement { + camera: camera.handle, + bounds: AreaRect { + x: x.round(), + y: y.round(), + ..bubble + }, + size_override: CameraSizeOverride::Preserve, + }, + cx, + ); +} + fn camera_area_bounds( display_origin: (f32, f32), crop: AreaRect, @@ -7162,6 +7227,7 @@ pub fn refresh_library_after_delete(cx: &mut App) { } fn close_controls(session: &Entity, cx: &mut App) { + crate::focus_border::close(cx); if let Some(handle) = cx.global_mut::().controls.take() { handle .update(cx, |_, window, _| window.remove_window()) diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 638020d001e..746fccfef48 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -73,6 +73,10 @@ const ICONS: &[(&str, &[u8])] = assets!("icons": "folder.svg", "gauge.svg", "gear.svg", + "hint-arrow-down.svg", + "hint-arrow-left.svg", + "hint-arrow-right.svg", + "hint-arrow-up.svg", // The config sidebar's field glyphs. Four are Cap's own // (`packages/ui-solid/icons/{padding,corners,shadow,bg-blur}.svg`); the // rest of the background section's are Lucide, drawn here from the same @@ -344,6 +348,7 @@ mod tests { include_str!("ui/selection_header.rs"), include_str!("ui/radio_cards.rs"), include_str!("ui/windows_caption.rs"), + include_str!("ui/hint.rs"), // The onboarding window's welcome cards and permissions surface; the // per-permission row glyphs are named on `OSPermission::icon`. include_str!("onboarding_window.rs"), diff --git a/apps/desktop-gpui/src/focus_border.rs b/apps/desktop-gpui/src/focus_border.rs new file mode 100644 index 00000000000..e8c6bf89179 --- /dev/null +++ b/apps/desktop-gpui/src/focus_border.rs @@ -0,0 +1,236 @@ +//! The Focus ring: a soft outline just outside the window Focus records. +//! +//! The picker draws it around the picked window while Focus is on, and a +//! recording keeps it up in a window of its own: a transparent panel over the +//! recorded window's display that ignores the mouse and stays out of +//! captures, following the window if it moves. It marks which window is +//! being recorded without sitting on any of its pixels. + +use std::time::Duration; + +use gpui::{ + App, AppContext as _, Context, Global, IntoElement, ParentElement as _, Render, Styled as _, + Task, Window, WindowBounds, WindowHandle, WindowKind, WindowOptions, div, point, px, size, +}; +use scap_targets::{DisplayId, WindowId}; + +use crate::{ + platform, + target_overlay::{AreaRect, HoveredWindow}, + theme::Theme, +}; + +/// Recording keeps the ring up only where its window can let clicks through +/// to the app being recorded. Windows and X11 still need that, so there the +/// ring shows in the picker alone. +pub const SUPPORTED: bool = cfg!(target_os = "macos"); + +/// How far outside the window the ring sits, so it never covers the window. +const OUTSET: f32 = 4.; +const WIDTH: f32 = 2.; +/// How often a recording rereads where its window is. +const FOLLOW: Duration = Duration::from_millis(120); + +/// The corner radius of other apps' windows, which the ring follows. +const WINDOW_RADIUS: f32 = if cfg!(target_os = "macos") { + 16. +} else if cfg!(windows) { + 8. +} else { + 10. +}; + +/// The ring around `window`, positioned in the same space as `window`: a +/// crisp line with two fainter ones outside it for a glow. Borders only, since +/// gpui paints a box shadow under the whole element and would tint the window. +pub fn ring(window: AreaRect, theme: &Theme) -> impl IntoElement { + let color = gpui::Hsla::from(theme.blue_9); + let band = |outset: f32, alpha: f32| { + div() + .absolute() + .left(px(window.x - outset)) + .top(px(window.y - outset)) + .w(px(window.width + 2. * outset)) + .h(px(window.height + 2. * outset)) + .rounded(px(WINDOW_RADIUS + outset)) + .border(px(WIDTH)) + .border_color(color.opacity(alpha)) + }; + div() + .absolute() + .size_full() + .child(band(OUTSET + 2. * WIDTH, 0.1)) + .child(band(OUTSET + WIDTH, 0.28)) + .child(band(OUTSET, 0.9)) +} + +struct FocusBorderWindow(WindowHandle); +impl Global for FocusBorderWindow {} + +pub struct FocusBorder { + window: WindowId, + display: DisplayId, + bounds: Option, + theme: Theme, + _follow: Task<()>, +} + +impl FocusBorder { + fn new(target: HoveredWindow, window: &mut Window, cx: &mut Context) -> Self { + crate::theme::bind_window(window, cx); + let follow = cx.spawn(async move |this, cx| { + loop { + cx.background_executor().timer(FOLLOW).await; + let Ok(id) = this.read_with(cx, |this, _| this.window.clone()) else { + return; + }; + let lookup = id.clone(); + let found = cx + .background_executor() + .spawn(async move { + scap_targets::Window::from_id(&lookup) + .as_ref() + .and_then(HoveredWindow::from_window) + }) + .await; + let updated = this.update(cx, |this, cx| { + if found + .as_ref() + .is_some_and(|found| found.display_id != this.display) + { + return true; + } + let bounds = found.map(|found| found.bounds); + if this.bounds != bounds { + this.bounds = bounds; + cx.notify(); + } + false + }); + match updated { + Ok(false) => {} + // The window went to another display: the ring's panel + // covers one display, so a new one opens over the other. + Ok(true) => { + cx.update(|cx| cx.defer(move |cx| reopen(&id, cx))); + return; + } + Err(_) => return, + } + } + }); + Self { + window: target.id, + display: target.display_id, + bounds: Some(target.bounds), + theme: Theme::for_window(window, cx, false), + _follow: follow, + } + } +} + +impl Render for FocusBorder { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + .size_full() + .relative() + .children(self.bounds.map(|bounds| ring(bounds, &self.theme))) + } +} + +fn reopen(target: &WindowId, cx: &mut App) { + if cx.has_global::() { + open(target, cx); + } +} + +/// Puts the ring around `target` for the length of a recording. The native +/// id of the ring's own window comes back, for the capture to leave out. +pub fn open(target: &WindowId, cx: &mut App) -> Option { + if !SUPPORTED { + return None; + } + close(cx); + let found = scap_targets::Window::from_id(target)?; + let target = HoveredWindow::from_window(&found)?; + let display = found.display()?; + let logical = display.raw_handle().logical_bounds()?; + let (x, y) = (logical.position().x(), logical.position().y()); + let (width, height) = (logical.size().width(), logical.size().height()); + + let handle = cx + .open_window( + WindowOptions { + // Moved onto its display right after opening, as the picker's + // overlays are: gpui's origin math cannot say "cover this display". + window_bounds: Some(WindowBounds::Windowed(gpui::Bounds { + origin: point(px(0.), px(0.)), + size: size(px(width as f32), px(height as f32)), + })), + titlebar: None, + kind: WindowKind::PopUp, + focus: false, + show: false, + is_resizable: false, + is_minimizable: false, + window_background: gpui::WindowBackgroundAppearance::Transparent, + ..Default::default() + }, + move |window, cx| cx.new(|cx| FocusBorder::new(target, window, cx)), + ) + .inspect_err(|error| tracing::warn!("focus ring window failed to open: {error:#}")) + .ok()?; + cx.set_global(FocusBorderWindow(handle)); + + let (native, number) = handle + .update(cx, |_, window, _| { + ( + platform::native_window(window), + platform::window_number(window), + ) + }) + .ok()?; + let native = native?; + // Out of every capture, and clicks go straight through to the window it + // marks. Without both the ring would be in the video, or in the way. + let hidden = platform::set_window_capture_hidden(&native, true); + let click_through = platform::set_window_click_through(&native, true); + if !click_through { + tracing::warn!( + hidden, + "focus ring could not let clicks through; not showing it" + ); + close(cx); + return None; + } + // AppKit re-enters gpui as it orders the panel in, so that runs from a + // fresh turn with nothing borrowed (see `platform::place_overlay_panel`). + cx.spawn(async move |cx| { + let open = cx.update(|cx| { + cx.try_global::() + .is_some_and(|current| current.0 == handle) + }); + if open { + platform::place_overlay_panel( + &native, + x, + y, + width, + height, + platform::target_overlay_level() - 1, + ); + } + }) + .detach(); + number?.to_string().parse().ok() +} + +pub fn close(cx: &mut App) { + if let Some(FocusBorderWindow(handle)) = cx.try_global::() { + let handle = *handle; + cx.remove_global::(); + handle + .update(cx, |_, window, _| window.remove_window()) + .ok(); + } +} diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 3ede16b4231..b0a3ef392f3 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -42,6 +42,7 @@ mod editor_tabs; mod editor_timeline; mod editor_window; mod feeds; +mod focus_border; mod hotkeys; mod i18n; mod import; @@ -63,6 +64,7 @@ mod mode_select_window; mod onboarding_audio; mod onboarding_doodles; mod onboarding_window; +mod overlay_backdrop; mod permission_guide; mod permissions; mod permissions_ui; diff --git a/apps/desktop-gpui/src/overlay_backdrop.rs b/apps/desktop-gpui/src/overlay_backdrop.rs new file mode 100644 index 00000000000..b896b3981bf --- /dev/null +++ b/apps/desktop-gpui/src/overlay_backdrop.rs @@ -0,0 +1,272 @@ +//! The desktop under a picker overlay, for the overlay's glass to read. +//! +//! The overlay is a transparent window, so its glass panes would otherwise +//! read only the overlay's own dimming. The overlay asks the window server +//! to leave it out of screen captures for a moment (`NSWindowSharingNone` +//! on macOS, `WDA_EXCLUDEFROMCAPTURE` on Windows: neither changes what is on +//! screen), captures its display, and paints that capture beneath its panes +//! through [`crate::ui::glass::Backdrop`]. +//! +//! Linux has no way to leave a window out of a capture short of unmapping +//! it, so its panes keep the plain fallback fill. + +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, + time::Duration, +}; + +use gpui::{AsyncApp, RenderImage, Window}; +use scap_targets::DisplayId; + +/// A capture older than this is retaken the next time the overlay draws. +pub const STALE_AFTER: Duration = Duration::from_secs(3); + +/// How long the panels must hold still before a stale capture is retaken. +pub const SETTLE: Duration = Duration::from_millis(400); + +/// The longest a capture may take before the panes give up on it. +const TIMEOUT: Duration = Duration::from_millis(1500); + +/// How long the main thread runs between leaving the overlay out of captures +/// and capturing. The window server only learns of the change once the run +/// loop turns over: a capture started straight away held the overlay every +/// time on macOS 27, and painted its dimming back under itself. One frame was +/// enough there; this leaves margin. +const EXCLUDE_SETTLE: Duration = Duration::from_millis(40); + +pub const SUPPORTED: bool = cfg!(any(target_os = "macos", windows)); + +/// Set by the picker probe to measure the picker without captures. +static DISABLED: AtomicBool = AtomicBool::new(false); + +/// How long the last capture took, start to GPU-ready image, for the probe. +static LAST_CAPTURE_MS: AtomicU64 = AtomicU64::new(0); + +pub fn enabled() -> bool { + SUPPORTED && !DISABLED.load(Ordering::Relaxed) +} + +#[cfg(debug_assertions)] +pub fn set_disabled(disabled: bool) { + DISABLED.store(disabled, Ordering::Relaxed); +} + +#[cfg(debug_assertions)] +pub fn last_capture_ms() -> u64 { + LAST_CAPTURE_MS.load(Ordering::Relaxed) +} + +#[cfg(target_os = "macos")] +const CAPTURE_HIDDEN: usize = 0; +#[cfg(windows)] +const CAPTURE_HIDDEN: usize = + windows_sys::Win32::UI::WindowsAndMessaging::WDA_EXCLUDEFROMCAPTURE as usize; + +/// Leave `window` out of screen captures, or put it back. True when the +/// window server now leaves it out: a Windows remote session keeps windows +/// in captures, and then a capture would hold the overlay itself. +#[cfg(any(target_os = "macos", windows))] +fn set_capture_hidden(window: &crate::platform::NativeWindow, hidden: bool) -> bool { + let reported = crate::platform::set_window_capture_hidden(window, hidden); + !hidden || reported == CAPTURE_HIDDEN +} + +/// Capture `display` with `window` (the overlay on it) left out, at the +/// display's full resolution, ready for the GPU. `None` when the platform +/// cannot leave the overlay out or the capture fails. +/// +/// `above` are Cap windows that sit over the overlay (the camera bubble): +/// they are left out too, or the capture would keep them where they were +/// and show them there after they move. One already left out (a Studio +/// camera is) stays that way. +pub async fn capture( + window: Option, + above: Vec, + display: DisplayId, + cx: &mut AsyncApp, +) -> Option> { + #[cfg(any(target_os = "macos", windows))] + { + let window = window?; + // The overlay must be hidden from captures before one starts and + // shown again whatever happens to it, so both run here on the main + // thread with no gpui borrow held: AppKit calls back into gpui. + if !set_capture_hidden(&window, true) { + set_capture_hidden(&window, false); + tracing::debug!("overlay backdrop: this session keeps windows in captures"); + return None; + } + let above: Vec<_> = above + .into_iter() + .filter(|window| !crate::platform::window_capture_hidden(window)) + .collect(); + for window in &above { + set_capture_hidden(window, true); + } + cx.background_executor().timer(EXCLUDE_SETTLE).await; + let started = std::time::Instant::now(); + let image = capture_bgra(display, cx).await; + set_capture_hidden(&window, false); + for window in &above { + set_capture_hidden(window, false); + } + let image = Arc::new(RenderImage::new(smallvec::smallvec![image::Frame::new( + image? + )])); + let elapsed_ms = started.elapsed().as_millis() as u64; + LAST_CAPTURE_MS.store(elapsed_ms, Ordering::Relaxed); + tracing::debug!(elapsed_ms, "overlay backdrop captured"); + Some(image) + } + #[cfg(not(any(target_os = "macos", windows)))] + { + let _ = (window, above, display, cx); + None + } +} + +/// The display's pixels in gpui's BGRA order. macOS hands them over in +/// that order already; elsewhere the shared screenshot path is RGB. +#[cfg(target_os = "macos")] +async fn capture_bgra(display: DisplayId, cx: &mut AsyncApp) -> Option { + let captured = cx + .background_executor() + .spawn(async move { mac::display_bgra(&display) }); + let timeout = cx.background_executor().timer(TIMEOUT); + match futures_util::future::select(std::pin::pin!(captured), std::pin::pin!(timeout)).await { + futures_util::future::Either::Left((image, _)) => image, + futures_util::future::Either::Right(_) => { + tracing::debug!("overlay backdrop: capture timed out"); + None + } + } +} + +#[cfg(windows)] +async fn capture_bgra(display: DisplayId, cx: &mut AsyncApp) -> Option { + let captured = gpui_tokio::Tokio::spawn(cx, async move { + tokio::time::timeout( + TIMEOUT, + cap_recording::screenshot::capture_screenshot( + cap_recording::sources::screen_capture::ScreenCaptureTarget::Display { + id: display, + }, + ), + ) + .await + }) + .await; + let image = match captured { + Ok(Ok(Ok(image))) => image, + Ok(Ok(Err(error))) => { + tracing::debug!("overlay backdrop: capture failed: {error:#}"); + return None; + } + Ok(Err(_)) => { + tracing::debug!("overlay backdrop: capture timed out"); + return None; + } + Err(error) => { + tracing::debug!(%error, "overlay backdrop: capture task failed"); + return None; + } + }; + cx.background_executor() + .spawn(async move { + let mut image = image.into_rgba8(); + cap_gpui_kernels::frame::swap_red_blue(&mut image); + image + }) + .await + .into() +} + +#[cfg(target_os = "macos")] +mod mac { + use std::ffi::c_void; + + use core_foundation::base::TCFType; + use core_foundation::data::{CFData, CFDataRef}; + use scap_targets::DisplayId; + + type CGImageRef = *const c_void; + + #[link(name = "CoreGraphics", kind = "framework")] + unsafe extern "C" { + fn CGDisplayCreateImage(display: u32) -> CGImageRef; + fn CGImageGetWidth(image: CGImageRef) -> usize; + fn CGImageGetHeight(image: CGImageRef) -> usize; + fn CGImageGetBytesPerRow(image: CGImageRef) -> usize; + fn CGImageGetBitsPerPixel(image: CGImageRef) -> usize; + fn CGImageGetDataProvider(image: CGImageRef) -> *const c_void; + fn CGDataProviderCopyData(provider: *const c_void) -> CFDataRef; + fn CGImageRelease(image: CGImageRef); + } + + /// Display images are 32-bit little-endian with the alpha byte skipped, + /// so in memory they are BGRX: one copy per row, and alpha set opaque. + pub fn display_bgra(display: &DisplayId) -> Option { + let display = scap_targets::Display::from_id(display)?; + let image = unsafe { CGDisplayCreateImage(display.raw_handle().inner().id) }; + if image.is_null() { + return None; + } + let pixels = unsafe { + let (width, height) = (CGImageGetWidth(image), CGImageGetHeight(image)); + let stride = CGImageGetBytesPerRow(image); + let data = (CGImageGetBitsPerPixel(image) == 32) + .then(|| CGDataProviderCopyData(CGImageGetDataProvider(image))) + .filter(|data| !data.is_null()) + .map(|data| CFData::wrap_under_create_rule(data)); + CGImageRelease(image); + let data = data?; + let bytes = data.bytes(); + if width == 0 || height == 0 || stride < width * 4 || bytes.len() < stride * height { + return None; + } + let mut pixels = Vec::with_capacity(width * height * 4); + for row in bytes.chunks_exact(stride).take(height) { + pixels.extend_from_slice(&row[..width * 4]); + } + for pixel in pixels.chunks_exact_mut(4) { + pixel[3] = 255; + } + image::RgbaImage::from_raw(width as u32, height as u32, pixels) + }; + pixels + } +} + +/// Whether a capture of `size` pixels can stand in for an overlay drawn at +/// `logical` points: the same shape, within a pixel's rounding. +pub fn fits(size: (u32, u32), logical: (f32, f32)) -> bool { + if size.0 == 0 || size.1 == 0 || logical.0 <= 0. || logical.1 <= 0. { + return false; + } + let scale_x = size.0 as f32 / logical.0; + let scale_y = size.1 as f32 / logical.1; + (scale_x / scale_y - 1.).abs() < 0.01 +} + +/// The overlay's own window, for [`capture`]. +pub fn native(window: &Window) -> Option { + crate::platform::native_window(window) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_capture_fits_an_overlay_of_the_same_shape() { + assert!(fits((3024, 1964), (1512., 982.))); + assert!(fits((1920, 1080), (1920., 1080.))); + assert!(fits((3841, 2160), (1920., 1080.))); + assert!(!fits((1920, 1200), (1920., 1080.))); + assert!(!fits((0, 1080), (1920., 1080.))); + assert!(!fits((1920, 1080), (0., 1080.))); + } +} diff --git a/apps/desktop-gpui/src/picker_probe.rs b/apps/desktop-gpui/src/picker_probe.rs index 77150e955b1..fe639bc23ef 100644 --- a/apps/desktop-gpui/src/picker_probe.rs +++ b/apps/desktop-gpui/src/picker_probe.rs @@ -18,11 +18,47 @@ pub fn run(main: WindowHandle, cx: &mut App) { .and_then(|delay| delay.parse().ok()) .unwrap_or(2000); sleep(cx, delay).await; + // `CAP_PICKER_PROBE_HOLD=window` (or `display`, `area`) arms that + // picker and leaves it up, for a script to drive the pointer over it. + if let Ok(hold) = std::env::var("CAP_PICKER_PROBE_HOLD") { + // `main` leaves the main window as it opened; `settings:` opens + // settings page n, for screenshots of the rest of the app. + if hold == "main" { + return; + } + if let Some(page) = hold.strip_prefix("settings:") { + let page = page.parse::().unwrap_or(0); + let page = crate::settings_window::Page::ALL + .get(page) + .copied() + .unwrap_or(crate::settings_window::Page::General); + cx.update(|cx| app_windows::open_settings(page, cx)); + return; + } + let kind = match hold.as_str() { + "display" => TargetType::Display, + "area" => TargetType::Area, + _ => TargetType::Window, + }; + main.update(cx, |view, _, cx| view.arm_overlay(kind, cx)) + .ok(); + sleep(cx, 800).await; + cx.update(|cx| { + let overlays = cx.global::().overlays.clone(); + for (_, overlay) in overlays { + overlay + .update(cx, |_, window, _| window.activate_window()) + .ok(); + } + }); + return; + } cx.update(app_windows::open_camera_window); sleep(cx, 800).await; let mut modes = Vec::new(); let mut window_arrangement = None; + let mut perf = None; for (label, kind) in [ ("display", TargetType::Display), ("window", TargetType::Window), @@ -69,11 +105,15 @@ pub fn run(main: WindowHandle, cx: &mut App) { sleep(cx, 800).await; let mut mode = cx.update(|cx| probe_mode(label, cx)); mode["overlayInactiveBeforeActivation"] = inactive_before.into(); + let screen = screenshot(&output, label, cx).await; + mode["backdrop"] = probe_backdrop(&output, label, screen.as_ref(), cx).await; modes.push(mode); - screenshot(&output, label, cx).await; if kind == TargetType::Window { window_arrangement = Some(probe_window_arrangement(&output, cx).await); } + if kind == TargetType::Area { + perf = Some(probe_perf(cx).await); + } cx.update(app_windows::dismiss_target_overlays); sleep(cx, 400).await; } @@ -82,12 +122,14 @@ pub fn run(main: WindowHandle, cx: &mut App) { .as_ref() .is_none_or(|report: &serde_json::Value| report["passed"].as_bool() != Some(false)); let passed = arrangement_passed - && modes - .iter() - .all(|mode| mode["cameraAboveOverlays"].as_bool() == Some(true)); + && modes.iter().all(|mode| { + mode["cameraAboveOverlays"].as_bool() == Some(true) + && mode["backdrop"]["passed"].as_bool() != Some(false) + }); let report = serde_json::json!({ "modes": modes, "windowArrangement": window_arrangement, + "perf": perf, }); // The verdict leads so `run.sh` can read it without parsing JSON: // nested checks carry `passed` keys of their own. @@ -242,10 +284,12 @@ async fn probe_window_arrangement( }) } -async fn screenshot(output: &std::ffi::OsStr, label: &str, cx: &mut AsyncApp) { - let Some(directory) = std::path::Path::new(output).parent() else { - return; - }; +async fn screenshot( + output: &std::ffi::OsStr, + label: &str, + cx: &mut AsyncApp, +) -> Option { + let directory = std::path::Path::new(output).parent()?; let path = directory.join(format!("{label}.png")); let target = cap_recording::sources::screen_capture::ScreenCaptureTarget::Display { id: scap_targets::Display::primary().id(), @@ -259,10 +303,89 @@ async fn screenshot(output: &std::ffi::OsStr, label: &str, cx: &mut AsyncApp) { if let Err(error) = image.save(&path) { tracing::warn!(%error, "picker probe: could not save {label} screenshot"); } + Some(image) + } + Ok(Err(error)) => { + tracing::warn!("picker probe: {label} screenshot failed: {error:#}"); + None + } + Err(error) => { + tracing::warn!(%error, "picker probe: {label} screenshot task failed"); + None + } + } +} + +/// The mean per-channel difference between two captures of one display. +fn mean_difference( + backdrop: &[u8], + width: u32, + height: u32, + screen: &image::RgbImage, +) -> Option { + if (screen.width(), screen.height()) != (width, height) { + return None; + } + let (mut sum, mut count) = (0u64, 0u64); + for (bgra, rgb) in backdrop.chunks_exact(4).zip(screen.pixels()) { + for (a, b) in [bgra[2], bgra[1], bgra[0]].into_iter().zip(rgb.0) { + sum += u64::from(a.abs_diff(b)); + count += 1; + } + } + (count > 0).then(|| sum as f64 / count as f64) +} + +/// Check the capture under the overlay's glass is of the desktop and not of +/// the overlay: the overlay dims and tints the whole display, so a capture +/// that left it out differs clearly from a screenshot that has it. +async fn probe_backdrop( + output: &std::ffi::OsStr, + label: &str, + screen: Option<&image::DynamicImage>, + cx: &mut AsyncApp, +) -> serde_json::Value { + if !crate::overlay_backdrop::SUPPORTED { + return serde_json::json!({ "skipped": "no capture without the overlay here" }); + } + // The overlay over the display `screen` shows, so the two compare the + // same desktop. + let primary = scap_targets::Display::primary().id(); + let image = cx.update(|cx| { + let overlay = cx + .global::() + .overlays + .iter() + .find(|(display, _)| *display == primary) + .map(|(_, overlay)| *overlay)?; + overlay + .update(cx, |view, _, _| view.backdrop_image()) + .ok() + .flatten() + }); + let Some(image) = image else { + return serde_json::json!({ "present": false, "passed": false }); + }; + let size = image.size(0); + let (width, height) = (size.width.0 as u32, size.height.0 as u32); + let bgra = image.as_bytes(0).unwrap_or_default(); + if let Some(directory) = std::path::Path::new(output).parent() { + let rgba: Vec = bgra + .chunks_exact(4) + .flat_map(|px| [px[2], px[1], px[0], px[3]]) + .collect(); + if let Some(image) = image::RgbaImage::from_raw(width, height, rgba) { + let _ = image.save(directory.join(format!("{label}-backdrop.png"))); } - Ok(Err(error)) => tracing::warn!("picker probe: {label} screenshot failed: {error:#}"), - Err(error) => tracing::warn!(%error, "picker probe: {label} screenshot task failed"), } + let difference = + screen.and_then(|screen| mean_difference(bgra, width, height, &screen.to_rgb8())); + serde_json::json!({ + "present": true, + "size": [width, height], + "meanDifference": difference, + "passed": difference.is_some_and(|difference| difference > 6.), + }) } async fn sleep(cx: &mut AsyncApp, millis: u64) { @@ -451,3 +574,100 @@ fn own_window_stacking(ours: &[isize]) -> Vec { } stacking } + +#[cfg(unix)] +fn cpu_secs() -> f64 { + let mut usage = std::mem::MaybeUninit::::zeroed(); + unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) }; + let usage = unsafe { usage.assume_init() }; + let secs = |time: libc::timeval| time.tv_sec as f64 + time.tv_usec as f64 / 1_000_000.0; + secs(usage.ru_utime) + secs(usage.ru_stime) +} + +#[cfg(windows)] +fn cpu_secs() -> f64 { + use windows_sys::Win32::{ + Foundation::FILETIME, + System::Threading::{GetCurrentProcess, GetProcessTimes}, + }; + + let empty = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let (mut creation, mut exit, mut kernel, mut user) = (empty, empty, empty, empty); + unsafe { + GetProcessTimes( + GetCurrentProcess(), + &mut creation, + &mut exit, + &mut kernel, + &mut user, + ) + }; + let secs = |time: FILETIME| { + ((u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime)) as f64 + / 10_000_000.0 + }; + secs(kernel) + secs(user) +} + +fn cpu_percent_since(cpu: f64, wall: std::time::Instant) -> f64 { + (cpu_secs() - cpu) / wall.elapsed().as_secs_f64() * 100. +} + +/// Slide the drawn area back and forth for `millis`, the way a drag moves +/// the picker's panels, and return the process's CPU use meanwhile, as a +/// percentage of one core. +async fn drag_cpu( + overlays: &[( + scap_targets::DisplayId, + WindowHandle, + )], + base: AreaRect, + millis: u64, + cx: &mut AsyncApp, +) -> f64 { + let (cpu, wall) = (cpu_secs(), std::time::Instant::now()); + let mut step = 0u32; + while wall.elapsed() < Duration::from_millis(millis) { + let offset = 240. * (step as f32 * 0.05).sin(); + cx.update(|cx| { + for (_, overlay) in overlays { + overlay + .update(cx, |view, _, cx| view.probe_offset_area(base, offset, cx)) + .ok(); + } + }); + sleep(cx, 8).await; + step += 1; + } + cpu_percent_since(cpu, wall) +} + +/// What the picker costs: the process's CPU use with the area picker left +/// alone, and while its panels move, with and without the desktop capture +/// under their glass, measured back to back in one process. +async fn probe_perf(cx: &mut AsyncApp) -> serde_json::Value { + let overlays = cx.update(|cx| cx.global::().overlays.clone()); + let base = AreaRect { + x: 200., + y: 200., + width: 640., + height: 360., + }; + let (cpu, wall) = (cpu_secs(), std::time::Instant::now()); + sleep(cx, 3000).await; + let idle = cpu_percent_since(cpu, wall); + let dragging = drag_cpu(&overlays, base, 6000, cx).await; + let capture_ms = crate::overlay_backdrop::last_capture_ms(); + crate::overlay_backdrop::set_disabled(true); + let dragging_without_backdrop = drag_cpu(&overlays, base, 6000, cx).await; + crate::overlay_backdrop::set_disabled(false); + serde_json::json!({ + "idleCpuPercent": idle, + "dragCpuPercent": dragging, + "dragCpuPercentWithoutBackdrop": dragging_without_backdrop, + "backdropCaptureMs": capture_ms, + }) +} diff --git a/apps/desktop-gpui/src/platform.rs b/apps/desktop-gpui/src/platform.rs index e57f143a56d..3866f052b66 100644 --- a/apps/desktop-gpui/src/platform.rs +++ b/apps/desktop-gpui/src/platform.rs @@ -1836,6 +1836,13 @@ mod mac { /// `setSharingType: None`/`ReadOnly`, so that is what this is. /// /// Returns the value AppKit reports back, for the probe log. + /// Whether the window is already left out of screen captures. + pub fn window_capture_hidden(native: &NativeWindow) -> bool { + use objc2::msg_send; + let sharing: usize = unsafe { msg_send![&*native.0, sharingType] }; + sharing == NS_WINDOW_SHARING_NONE + } + pub fn set_window_capture_hidden(native: &NativeWindow, hidden: bool) -> usize { use objc2::msg_send; let sharing = if hidden { @@ -3028,6 +3035,9 @@ mod stub { pub fn set_window_alpha(_native: &NativeWindow, _alpha: f64) -> f64 { 1. } + pub fn window_capture_hidden(_native: &NativeWindow) -> bool { + false + } pub fn set_window_capture_hidden(_native: &NativeWindow, _hidden: bool) -> usize { 0 } diff --git a/apps/desktop-gpui/src/platform/windows.rs b/apps/desktop-gpui/src/platform/windows.rs index 5a340c73f8b..bdc3c97932f 100644 --- a/apps/desktop-gpui/src/platform/windows.rs +++ b/apps/desktop-gpui/src/platform/windows.rs @@ -151,6 +151,13 @@ pub fn set_window_alpha(_native: &NativeWindow, _alpha: f64) -> f64 { 1. } +/// Whether the window is already left out of screen captures. +pub fn window_capture_hidden(native: &NativeWindow) -> bool { + let mut current = WDA_NONE; + let read = unsafe { GetWindowDisplayAffinity(native.hwnd(), &mut current) != 0 }; + read && current == WDA_EXCLUDEFROMCAPTURE +} + pub fn set_window_capture_hidden(native: &NativeWindow, hidden: bool) -> usize { let streamed = hidden .then(capture_exclusion::streamed_display_reason) diff --git a/apps/desktop-gpui/src/target_overlay.rs b/apps/desktop-gpui/src/target_overlay.rs index 87e43a103e2..c875b83e068 100644 --- a/apps/desktop-gpui/src/target_overlay.rs +++ b/apps/desktop-gpui/src/target_overlay.rs @@ -46,6 +46,7 @@ use crate::{ main_window::{Mode, TargetType}, sign_in::SignIn, theme::Theme, + ui::Hint, }; /// How often the cursor probe runs. The Tauri loop sleeps 50ms between @@ -623,6 +624,16 @@ fn more_area_ratios() -> Vec<(u32, u32)> { const AREA_RESHAPE: Duration = Duration::from_millis(240); +/// What the picker's glass sits on when there is no capture of the desktop +/// beneath it: the panel fill the picker had before it had glass. +fn fallback_fill(theme: &Theme) -> Hsla { + if theme.is_dark() { + Theme::with_alpha(theme.gray_2, 0.82) + } else { + Theme::with_alpha(theme.gray_1, 0.82) + } +} + fn area_fill(display: (f32, f32), ratio: Option) -> AreaRect { let (width, height) = match ratio { Some(ratio) if display.0 / display.1 > ratio => (display.1 * ratio, display.1), @@ -968,6 +979,17 @@ pub struct OverlayWindow { window_reshape: Option<(AreaRect, Instant)>, /// Where the window highlight was last drawn, for a move to start from. window_drawn: Cell>, + hints: crate::ui::Hints, + /// The desktop under this overlay, for its glass panes to read. + backdrop: crate::ui::glass::Backdrop, + /// When the backdrop was captured, and whether a capture is running. + backdrop_at: Option, + backdrop_capturing: bool, + /// The area as the last render drew it, and when it last changed: a + /// capture waits for the panels to stop moving. + backdrop_crop: Option, + crop_moved_at: Option, + backdrop_wake_pending: bool, inline_camera: Option>, inline_camera_subscription: Option, empty_camera_state: crate::store::CameraWindowState, @@ -1125,6 +1147,13 @@ impl OverlayWindow { window_menu: None, window_reshape: None, window_drawn: Cell::new(None), + hints: crate::ui::Hints::default(), + backdrop: crate::ui::glass::Backdrop::new(fallback_fill(&theme)), + backdrop_at: None, + backdrop_capturing: false, + backdrop_crop: None, + crop_moved_at: None, + backdrop_wake_pending: false, inline_camera: None, inline_camera_subscription: None, empty_camera_state: crate::store::load().camera_window.unwrap_or_default(), @@ -1280,6 +1309,145 @@ impl OverlayWindow { fn sync_appearance(&mut self, window: &Window, cx: &gpui::App) { self.theme.refresh(window, cx, false); + self.backdrop.set_fallback(fallback_fill(&self.theme)); + } + + /// Keep the capture under this overlay's glass current while the overlay + /// shows controls, and let it go when it does not. A capture is taken + /// only when the overlay draws, so a picker left alone takes none. + fn sync_backdrop(&mut self, window: &mut Window, cx: &mut Context) { + let showing = crate::overlay_backdrop::enabled() + && !self.recording_area + && self.select.read(cx).mode.is_some() + && self.is_active_display(window, cx); + if !showing { + if self.backdrop.has_image() { + self.backdrop.set_image(None, gpui::Bounds::default()); + self.backdrop_at = None; + } + return; + } + if self.backdrop_crop != self.crop { + self.backdrop_crop = self.crop; + self.crop_moved_at = Some(Instant::now()); + } + // A drawn area moves the camera into it, so a capture from before the + // area last changed is stale however recent it is. + let moved_since = self + .crop_moved_at + .zip(self.backdrop_at) + .is_some_and(|(moved, captured)| moved > captured); + if self.backdrop_capturing + || (!moved_since + && self + .backdrop_at + .is_some_and(|at| at.elapsed() < crate::overlay_backdrop::STALE_AFTER)) + { + return; + } + // While the panels move with a dragged area the old capture holds: + // retake it once they settle rather than mid-drag. + let settling = self.backdrop.has_image() + && self + .crop_moved_at + .is_some_and(|at| at.elapsed() < crate::overlay_backdrop::SETTLE); + if settling { + if !self.backdrop_wake_pending { + self.backdrop_wake_pending = true; + cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(crate::overlay_backdrop::SETTLE) + .await; + this.update(cx, |this, cx| { + this.backdrop_wake_pending = false; + cx.notify(); + }) + .ok(); + }) + .detach(); + } + return; + } + self.backdrop_capturing = true; + let native = crate::overlay_backdrop::native(window); + let display = self.display_id.clone(); + let started = Instant::now(); + cx.spawn(async move |this, cx| { + let camera = cx.update(|cx| { + cx.global::() + .camera + .and_then(|camera| { + camera + .update(cx, |_, window, _| crate::overlay_backdrop::native(window)) + .ok() + .flatten() + }) + }); + let image = + crate::overlay_backdrop::capture(native, camera.into_iter().collect(), display, cx) + .await; + this.update(cx, |this, cx| { + this.backdrop_capturing = false; + this.backdrop_at = Some(started); + let size = this.display_size; + let image = image.filter(|image| { + let pixels = image.size(0); + crate::overlay_backdrop::fits( + (pixels.width.0 as u32, pixels.height.0 as u32), + size, + ) + }); + this.backdrop.set_image( + image, + gpui::Bounds::new( + gpui::point(px(0.), px(0.)), + gpui::size(px(size.0), px(size.1)), + ), + ); + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + #[cfg(debug_assertions)] + pub(crate) fn backdrop_image(&self) -> Option> { + self.backdrop.image() + } + + /// Move the drawn area `offset` points right, the way a drag would, for + /// the picker probe's timing. + #[cfg(debug_assertions)] + pub(crate) fn probe_offset_area( + &mut self, + base: AreaRect, + offset: f32, + cx: &mut Context, + ) { + self.crop = Some( + AreaRect { + x: base.x + offset, + ..base + } + .clamped(self.display_size), + ); + cx.notify(); + } + + /// Retake the backdrop once a moved window has settled. + fn retake_backdrop_soon(&mut self, cx: &mut Context) { + cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(350)) + .await; + this.update(cx, |this, cx| { + this.backdrop_at = None; + cx.notify(); + }) + .ok(); + }) + .detach(); } fn sync_inline_camera(&mut self, window: &mut Window, cx: &mut Context) { @@ -1335,6 +1503,7 @@ impl Render for OverlayWindow { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.sync_appearance(window, cx); self.sync_inline_camera(window, cx); + self.sync_backdrop(window, cx); let viewport = window.viewport_size(); let display_size = overlay_viewport_size( (f32::from(viewport.width), f32::from(viewport.height)), @@ -1349,6 +1518,7 @@ impl Render for OverlayWindow { } let mode = self.select.read(cx).mode; self.drawn_microphone_fill.set(None); + self.hints.begin_frame(); let root = div() .id("overlay-root") @@ -1431,7 +1601,8 @@ impl Render for OverlayWindow { .locale_font() // `body { font-weight: 500 }` (`ui-solid/src/main.css:189-192`). .font_weight(FontWeight::MEDIUM) - .text_color(gpui::white()); + .text_color(gpui::white()) + .child(self.backdrop.layer()); let root = if self.recording_area { root.child(self.render_area_variant(window, cx)) @@ -1446,11 +1617,21 @@ impl Render for OverlayWindow { }; // Painted after the variant so the dropdown sits above the start // cluster it drops from. + // A hint gives way to any open menu, which is what it would explain. + let menu_open = self.mode_menu.is_some() + || self.countdown_menu.is_some() + || self.aspect_menu.is_some() + || self.window_menu.is_some() + || self.device_menu.is_some(); + let hint = (!menu_open) + .then(|| self.hints.render(window, &self.theme, cx.reduce_motion())) + .flatten(); root.children(self.render_mode_menu(cx)) .children(self.render_countdown_menu(cx)) .children(self.render_aspect_menu(cx)) .children(self.render_window_menu(cx)) .children(self.render_device_menu(cx)) + .children(hint) .into_any_element() } } @@ -1886,6 +2067,7 @@ impl OverlayWindow { cx: &mut Context, ) -> gpui::AnyElement { let select = self.select.read(cx); + let focus = select.focus_window && crate::foreign_window::can_arrange(); let active = select .active_window() .filter(|window| window.display_id == self.display_id) @@ -1932,6 +2114,9 @@ impl OverlayWindow { // leaves the camera bubble parked in the picked window. cx.defer(app_windows::release_camera_park); })) + .when(focus, |variant| { + variant.child(crate::focus_border::ring(bounds, &self.theme)) + }) .child( div() .absolute() @@ -1995,31 +2180,31 @@ impl OverlayWindow { label.push_str(&format!(" · {w}:{h}")); } let arrangeable = crate::foreign_window::can_arrange(); - let tooltip = if arrangeable { - "Change the window's size" + let hint = if arrangeable { + Hint::new("Change window size") + .body("Resize the window to a ratio or an exact size before you record.") } else if cfg!(target_os = "macos") { - "Turn on Accessibility for Cap to resize other apps' windows" + Hint::new("Resizing needs Accessibility").body( + "Turn on Accessibility for Cap in System Settings to resize other apps' windows.", + ) } else { - "Resizing other apps' windows isn't possible on Wayland" + Hint::new("Resizing isn't available") + .body("Wayland doesn't let apps resize other apps' windows.") }; - div() - .id("window-size") + let chip = self.glass_capsule(28.).id("window-size"); + self.hinted(chip, "window-size", hint, cx) .mb(px(8.)) - .h(px(28.)) .px(px(10.)) .flex() .items_center() .gap(px(6.)) - .rounded_full() .text_size(px(12.)) .text_color(theme.gray_12) - .bg(gpui::hsla(0., 0., 0., 0.28)) - .tooltip(move |_, cx| crate::ui::Tooltip::new(&theme, tooltip).view(cx)) .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .child(label) .when(arrangeable, |chip| { chip.cursor_pointer() - .hover(|style| style.bg(gpui::hsla(0., 0., 0., 0.4))) + .hover(move |style| style.bg(Theme::with_alpha(theme.gray_12, 0.08))) .child( svg() .path("icons/chevron-down.svg") @@ -2046,42 +2231,97 @@ impl OverlayWindow { .main .read(cx) .is_ok_and(|view| view.camera_selection().is_some()); - let tooltip = if cfg!(any(target_os = "macos", windows)) + let title = if focus { + "Turn off Focus mode" + } else { + "Enable Focus mode" + }; + let body = if cfg!(any(target_os = "macos", windows)) && self.select.read(cx).recording_mode == Mode::Instant && camera { - "Centre this window. With a camera, Instant mode still records the screen region, so the camera stays in the video" + "Centres this window. With a camera, Instant still records the screen region so the camera stays in the video." } else { - "Centre this window and record only it, nothing in front of it" + "Centres this window and records only it. Anything that moves in front of it stays 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 accent = Hsla::from(theme.blue_9); Some( - div() - .id("window-focus") - .mb(px(8.)) - .h(px(32.)) - .pl(px(12.)) + 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.)) - .rounded_full() - .bg(gpui::hsla(0., 0., 0., 0.28)) - .tooltip(move |_, cx| crate::ui::Tooltip::new(&theme, tooltip).view(cx)) + .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() - .text_size(px(13.)) - .text_color(theme.gray_12) - .child("Focus"), + .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( - crate::ui::Toggle::glass(&theme, "window-focus-toggle", focus).on_click( - cx.listener(|this, _, _, cx| { + div() + .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( + 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); - }), - ), + })), ), ) } @@ -2284,10 +2524,14 @@ impl OverlayWindow { return; } if let Some(fresh) = fresh { + // The camera bubble rides along, as it does to a newly + // picked window. + cx.update(|cx| app_windows::carry_camera_with_window(&window, &fresh, cx)); select.update(cx, |select, cx| select.set_pinned(fresh, cx)); } this.update(cx, |this, cx| { this.window_reshape = Some((from, Instant::now())); + this.retake_backdrop_soon(cx); cx.notify(); }) .ok(); @@ -2746,7 +2990,7 @@ impl OverlayWindow { None => "Draw an area".to_string(), }; let divider = || div().h(px(24.)).w(px(1.)).flex_shrink_0().bg(theme.gray_5); - let icon_button = |id: &'static str, icon: &'static str, tooltip: &'static str| { + let icon_button = |id: &'static str, icon: &'static str| { div() .id(id) .size(px(36.)) @@ -2758,7 +3002,6 @@ impl OverlayWindow { .cursor_pointer() .group(id) .hover(move |style| style.bg(Theme::with_alpha(theme.gray_12, 0.08))) - .tooltip(move |_, cx| crate::ui::Tooltip::new(&theme, tooltip).view(cx)) .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) // An svg takes its own text colour, not its parent's. .child( @@ -2843,23 +3086,41 @@ impl OverlayWindow { .children(chips), ) .child( - icon_button("area-ratio-more", "icons/ratio.svg", "More aspect ratios") - .when(in_more, |button| { - button.bg(Theme::with_alpha(theme.gray_12, 0.12)) - }) - .on_click(cx.listener(|this, event: &gpui::ClickEvent, _, cx| { - cx.stop_propagation(); - this.open_aspect_menu(event.position(), cx); - })), + self.hinted( + icon_button("area-ratio-more", "icons/ratio.svg") + .when(in_more, |button| { + button.bg(Theme::with_alpha(theme.gray_12, 0.12)) + }) + .on_click(cx.listener(|this, event: &gpui::ClickEvent, _, cx| { + cx.stop_propagation(); + this.open_aspect_menu(event.position(), cx); + })), + "area-ratio-more", + Hint::new("More aspect ratios") + .body("Lock the area to a ratio such as 4:3, 9:16 or 21:9."), + cx, + ), ) .child(divider()) .child( - icon_button("area-reset", "icons/rotate-ccw.svg", "Reset selection") - .on_click(cx.listener(|this, _, _, cx| this.reset_area(cx))), + self.hinted( + icon_button("area-reset", "icons/rotate-ccw.svg") + .on_click(cx.listener(|this, _, _, cx| this.reset_area(cx))), + "area-reset", + Hint::new("Reset selection") + .body("Clear the area so you can draw a new one."), + cx, + ), ) .child( - icon_button("area-fill", "icons/maximize.svg", "Fill display") - .on_click(cx.listener(|this, _, _, cx| this.fill_area(cx))), + self.hinted( + icon_button("area-fill", "icons/maximize.svg") + .on_click(cx.listener(|this, _, _, cx| this.fill_area(cx))), + "area-fill", + Hint::new("Fill display") + .body("Grow the area to cover the display, keeping its ratio."), + cx, + ), ), ) } @@ -3034,32 +3295,40 @@ impl OverlayWindow { }) } - /// `LIQUID_GLASS_SURFACE_CLASS`: `rounded-2xl border border-gray-12/10 - /// bg-gray-1/82 shadow-xl shadow-black/20 dark:border-white/10 - /// dark:bg-gray-2/82`. The `backdrop-blur-xl` is dropped -- this gpui rev - /// has no per-element backdrop blur (same gap the recording overlay has). + /// The picker's panels: Regular glass over the desktop beneath. fn glass_surface(&self) -> gpui::Div { - let theme = self.theme; - div() - .rounded(px(16.)) - .border_1() - .border_color(if theme.is_dark() { - gpui::hsla(0., 0., 1., 0.1) - } else { - Theme::with_alpha(theme.gray_12, 0.1) - }) - .bg(if theme.is_dark() { - Theme::with_alpha(theme.gray_2, 0.82) - } else { - Theme::with_alpha(theme.gray_1, 0.82) - }) - .shadow(vec![gpui::BoxShadow { - color: gpui::hsla(0., 0., 0., 0.2), - offset: gpui::point(px(0.), px(8.)), - blur_radius: px(10.), - spread_radius: px(-6.), - inset: false, - }]) + crate::ui::glass::pane_over( + crate::ui::glass::GlassKind::Regular, + self.theme.is_dark(), + 16., + &self.backdrop, + ) + } + + /// A round Clear glass button, for controls floating on a panel. + fn glass_button(&self, size: f32) -> gpui::Div { + crate::ui::glass::pane_over( + crate::ui::glass::GlassKind::Clear, + self.theme.is_dark(), + size / 2., + &self.backdrop, + ) + .size(px(size)) + .flex_shrink_0() + .flex() + .justify_center() + .items_center() + } + + /// A Clear glass capsule `height` tall, for the chips over a window. + fn glass_capsule(&self, height: f32) -> gpui::Div { + crate::ui::glass::pane_over( + crate::ui::glass::GlassKind::Clear, + self.theme.is_dark(), + height / 2., + &self.backdrop, + ) + .h(px(height)) } /// `RecordingControls`: the close button, the gradient start pill with its @@ -3095,17 +3364,14 @@ impl OverlayWindow { .child(self.render_close_button(cx)) .child(self.render_start_button(target, disabled, mode, cx)) .child( - div() - .id("overlay-countdown") - .flex() - .justify_center() - .items_center() - .size(px(36.)) - .flex_shrink_0() - .rounded_full() - .border_1() - .border_color(theme.gray_5) - .bg(theme.gray_6) + self.hinted( + self.glass_button(36.).id("overlay-countdown"), + "overlay-countdown", + Hint::new("Recording countdown").body( + "Wait a few seconds after you press Start before recording begins.", + ), + cx, + ) .child( svg() .path("icons/gear.svg") @@ -3113,9 +3379,6 @@ impl OverlayWindow { .text_color(theme.gray_12), ) .hover(|style| style.opacity(0.8)) - .tooltip(move |_, cx| { - crate::ui::Tooltip::new(&theme, "Recording countdown").view(cx) - }) .on_mouse_down(MouseButton::Left, |_, _, cx| { cx.stop_propagation(); }) @@ -3156,27 +3419,29 @@ impl OverlayWindow { } /// `size-9 rounded-full bg-gray-12` with the inverted X glyph. + /// `element` with `hint` beside it while the pointer rests on it. + fn hinted(&self, element: E, id: &'static str, hint: Hint, cx: &mut Context) -> E + where + E: gpui::StatefulInteractiveElement + gpui::ParentElement, + { + element + .child(self.hints.anchor(id, hint)) + .on_hover(cx.listener(move |this, hovered: &bool, _, cx| { + this.hints.hover(id, *hovered, cx, |this| &mut this.hints); + })) + } + fn render_close_button(&self, cx: &mut Context) -> impl IntoElement { let theme = self.theme; - div() + self.glass_button(36.) .id("overlay-close") - .flex() - .justify_center() - .items_center() - .size(px(36.)) - .flex_shrink_0() - .rounded_full() - .bg(theme.gray_12) .hover(|style| style.opacity(0.8)) .child( svg() .path("icons/x.svg") - // `invert dark:invert-0` on a black glyph: white on the - // light theme's dark circle, black on the dark theme's - // light one. .size(px(12.)) - .text_color(theme.gray_1), + .text_color(theme.gray_12), ) .on_click(cx.listener(|this, _, _window, cx| { cx.stop_propagation(); @@ -3259,7 +3524,6 @@ impl OverlayWindow { .when(!disabled, |this| { this.hover(|style| style.bg(gpui::hsla(0., 0., 1., 0.1))) }) - .tooltip(move |_, cx| crate::ui::Tooltip::new(&theme, label).view(cx)) .child( svg() .path(icon) @@ -3309,8 +3573,14 @@ impl OverlayWindow { // hitbox, so the stop has to happen here, not on a click // handler. Active even while start is disabled, same as the // source (the disabled styling only wraps the leading half). - div() - .id("overlay-mode-picker") + self.hinted( + div().id("overlay-mode-picker"), + "overlay-mode-picker", + Hint::new("Change recording mode").body( + "Instant gives you a link to share the moment you stop. Studio records in full quality for editing.", + ), + cx, + ) .on_mouse_down( MouseButton::Left, cx.listener(|this, event: &gpui::MouseDownEvent, _window, cx| { @@ -3346,9 +3616,6 @@ impl OverlayWindow { .border_l_1() .border_color(gpui::hsla(0., 0., 1., 0.2)) .bg(gpui::hsla(0., 0., 1., 0.05)) - .tooltip(move |_, cx| { - crate::ui::Tooltip::new(&theme, "Choose recording mode").view(cx) - }) .child( svg() .path("icons/caret-down.svg") diff --git a/apps/desktop-gpui/src/ui/glass.rs b/apps/desktop-gpui/src/ui/glass.rs index 6194a92b0a8..6155b71bcd7 100644 --- a/apps/desktop-gpui/src/ui/glass.rs +++ b/apps/desktop-gpui/src/ui/glass.rs @@ -7,10 +7,17 @@ //! window: on a transparent window, paint the backdrop it should show (the //! picker paints the captured desktop) before the pane. -use std::sync::OnceLock; +use std::{ + cell::RefCell, + rc::Rc, + sync::{Arc, OnceLock}, +}; use cap_glass::model::{GlassMaterial, PaneStyle}; -use gpui::{Bounds, Corners, Div, ParentElement, Pixels, Styled, Window, canvas, div, px}; +use gpui::{ + Bounds, ContentMask, Corners, Div, Hsla, IntoElement, ParentElement, Pixels, RenderImage, + Styled, Window, canvas, div, fill, px, +}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum GlassKind { @@ -105,24 +112,47 @@ pub fn paint( ); } +/// How far past its bounds a pane's glass reads what lies beneath it: the +/// blur's reach (three sigma), the rim's lensing, and a pixel of slack. +fn sample_reach(style: &PaneStyle) -> f32 { + style.blur * 3. + style.rim_bend.abs() + style.lens_rim + 4. +} + /// A rounded glass pane: a `Div` whose first child is the glass layer, so /// everything added after it paints on the glass. `radius` is clamped to the /// pane like CSS clamps `border-radius`. pub fn pane(kind: GlassKind, dark: bool, radius: f32) -> Div { - pane_with(kind, dark, radius, None) + pane_with(kind, dark, radius, None, None) } /// [`pane`] with a tint. pub fn pane_tinted(kind: GlassKind, dark: bool, radius: f32, tint: Tint) -> Div { - pane_with(kind, dark, radius, Some(tint)) + pane_with(kind, dark, radius, None, Some(tint)) } -fn pane_with(kind: GlassKind, dark: bool, radius: f32, tint: Option) -> Div { +/// [`pane`] on a transparent window, reading `backdrop` instead of the +/// window's own (mostly empty) pixels. +pub fn pane_over(kind: GlassKind, dark: bool, radius: f32, backdrop: &Backdrop) -> Div { + pane_with(kind, dark, radius, Some(backdrop.clone()), None) +} + +fn pane_with( + kind: GlassKind, + dark: bool, + radius: f32, + backdrop: Option, + tint: Option, +) -> Div { let material = *material(kind, dark); div().relative().rounded(px(radius)).child( canvas( |_, _, _| {}, - move |bounds, _, window, _| paint(window, bounds, radius, &material, tint), + move |bounds, _, window, _| { + if let Some(backdrop) = &backdrop { + backdrop.prepare(window, bounds, radius, &material); + } + paint(window, bounds, radius, &material, tint); + }, ) .absolute() .top_0() @@ -130,3 +160,153 @@ fn pane_with(kind: GlassKind, dark: bool, radius: f32, tint: Option) -> Di .size_full(), ) } + +/// What a transparent window's glass panes show through them: a capture of +/// the desktop beneath the window, painted under the panes before anything +/// else the window draws, so whatever the window paints over the desktop +/// (dimming, highlights) lands on it exactly as it lands on the real one. +/// +/// The window cannot know where its panes go until layout, and the capture +/// has to be painted before everything else, so [`Backdrop::layer`] paints +/// under the panes where the previous frame put them, a little wider so a +/// moving pane stays covered, and asks for another frame when one moved +/// outside that. +/// +/// Without a capture (none yet, or none possible) panes sit on `fallback`. +#[derive(Clone)] +pub struct Backdrop(Rc>); + +struct BackdropState { + image: Option>, + /// Where `image` goes, in window coordinates. + image_bounds: Bounds, + fallback: Hsla, + /// What the panes asked for while painting this frame. + asked: Vec>, + /// What the layer painted at the start of this frame. + painted: Vec>, + /// Images replaced or dropped, for the window's atlas to free. + retired: Vec>, +} + +/// Extra room around each pane's sampled region, so a pane that moves a +/// little between frames (a dragged area's controls) stays on the capture. +/// Kept small: outside a pane the capture stands in for the live desktop. +const SLACK: f32 = 24.; + +impl Backdrop { + pub fn new(fallback: Hsla) -> Self { + Self(Rc::new(RefCell::new(BackdropState { + image: None, + image_bounds: Bounds::default(), + fallback, + asked: Vec::new(), + painted: Vec::new(), + retired: Vec::new(), + }))) + } + + pub fn set_fallback(&self, fallback: Hsla) { + self.0.borrow_mut().fallback = fallback; + } + + pub fn has_image(&self) -> bool { + self.0.borrow().image.is_some() + } + + pub fn image(&self) -> Option> { + self.0.borrow().image.clone() + } + + pub fn set_image(&self, image: Option>, bounds: Bounds) { + let mut state = self.0.borrow_mut(); + if let Some(old) = std::mem::replace(&mut state.image, image) { + state.retired.push(old); + } + state.image_bounds = bounds; + } + + /// The window's first child: paints the capture under last frame's panes. + pub fn layer(&self) -> impl IntoElement { + let backdrop = self.clone(); + canvas( + |_, _, _| {}, + move |_, _, window, _| { + let mut state = backdrop.0.borrow_mut(); + for image in state.retired.drain(..) { + let _ = window.drop_image(image); + } + let asked = std::mem::take(&mut state.asked); + state.painted = asked; + let Some(image) = state.image.clone() else { + return; + }; + let image_bounds = state.image_bounds; + for region in &state.painted { + let Some(visible) = clip(region.dilate(px(SLACK)), image_bounds) else { + continue; + }; + window.with_content_mask(Some(ContentMask { bounds: visible }), |window| { + let _ = window.paint_image_fitted( + visible, + image_bounds, + Corners::default(), + image.clone(), + 0, + false, + ); + }); + } + }, + ) + .absolute() + .top_0() + .left_0() + .size_full() + } + + /// A pane at `bounds` is about to paint its glass: record what it reads, + /// and give it something to read if the capture is not under it. + fn prepare( + &self, + window: &mut Window, + bounds: Bounds, + radius: f32, + material: &GlassMaterial, + ) { + let (width, height) = (f32::from(bounds.size.width), f32::from(bounds.size.height)); + if width <= 0. || height <= 0. { + return; + } + let reach = sample_reach(&material.resolve(width, height)); + let region = bounds.dilate(px(reach)); + let mut state = self.0.borrow_mut(); + state.asked.push(region); + let covered = state.image.is_some() + && state + .painted + .iter() + .any(|painted| contains(painted.dilate(px(SLACK)), region)); + if covered { + return; + } + if state.image.is_some() { + // Moved off last frame's capture: correct on the next one. + window.request_animation_frame(); + } + let radius = radius.min(width / 2.).min(height / 2.); + window.paint_quad(fill(bounds, state.fallback).corner_radii(px(radius))); + } +} + +fn contains(outer: Bounds, inner: Bounds) -> bool { + outer.origin.x <= inner.origin.x + && outer.origin.y <= inner.origin.y + && outer.bottom_right().x >= inner.bottom_right().x + && outer.bottom_right().y >= inner.bottom_right().y +} + +fn clip(region: Bounds, to: Bounds) -> Option> { + let clipped = region.intersect(&to); + (clipped.size.width > px(0.) && clipped.size.height > px(0.)).then_some(clipped) +} diff --git a/apps/desktop-gpui/src/ui/glass_switch.rs b/apps/desktop-gpui/src/ui/glass_switch.rs new file mode 100644 index 00000000000..7c83058581a --- /dev/null +++ b/apps/desktop-gpui/src/ui/glass_switch.rs @@ -0,0 +1,191 @@ +//! The glass switch: the system switch's shape, measured from Apple's own +//! (`scripts/glass-reference`, the `controls` captures): a 54 x 24 track, a +//! 34 x 22 capsule knob inset a point, the accent colour when on. Pressed, +//! the knob swells into a Clear glass lens over the track, as the system's +//! does. + +use std::time::{Duration, Instant}; + +use gpui::{ + App, ClickEvent, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, + RenderOnce, StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder as _, px, +}; + +use super::glass::{self, GlassKind}; +use crate::theme::Theme; + +const TRACK: (f32, f32) = (54., 24.); +const KNOB: (f32, f32) = (34., 22.); +const KNOB_INSET: f32 = 1.; +/// The lens a pressed knob becomes, overhanging the track. +const LENS: (f32, f32) = (40., 28.); +const TRAVEL: Duration = Duration::from_millis(220); + +#[derive(Default)] +struct SwitchState { + pressed: bool, + /// The position last drawn and when it changed, for the knob's slide. + drawn: Option, + changed_at: Option, +} + +#[derive(IntoElement)] +pub struct Switch { + id: ElementId, + checked: bool, + dark: bool, + on_click: Option>, +} + +impl Switch { + pub fn new(id: impl Into, checked: bool, dark: bool) -> Self { + Self { + id: id.into(), + checked, + dark, + on_click: None, + } + } + + pub fn on_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_click = Some(Box::new(handler)); + self + } +} + +/// How far along its slide the knob is, 0 at the old end and 1 at the new. +fn travel(changed_at: Option, now: Instant) -> f32 { + let Some(changed_at) = changed_at else { + return 1.; + }; + let t = (now.saturating_duration_since(changed_at).as_secs_f32() / TRAVEL.as_secs_f32()) + .clamp(0., 1.); + 1. - (1. - t).powi(3) +} + +impl RenderOnce for Switch { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let Switch { + id, + checked, + dark, + on_click, + } = self; + let state = window.use_keyed_state(id.clone(), cx, |_, _| SwitchState::default()); + let now = Instant::now(); + let (pressed, progress) = state.update(cx, |state, _| { + if state.drawn.is_some_and(|drawn| drawn != checked) { + state.changed_at = Some(now); + } + state.drawn = Some(checked); + (state.pressed, travel(state.changed_at, now)) + }); + if progress < 1. { + window.request_animation_frame(); + } + + let off = KNOB_INSET; + let on = TRACK.0 - KNOB.0 - KNOB_INSET; + let (from, to) = if checked { (off, on) } else { (on, off) }; + let knob_x = from + (to - from) * progress; + let track_fill = if checked { + gpui::rgb(Theme::SETTINGS_ACCENT).into() + } else if dark { + gpui::hsla(240. / 360., 0.03, 0.49, 0.36) + } else { + gpui::hsla(0., 0., 0., 0.105) + }; + + let knob = if pressed { + let (width, height) = LENS; + glass::pane(GlassKind::Clear, dark, height / 2.) + .absolute() + .left(px(knob_x + (KNOB.0 - width) / 2.)) + .top(px((TRACK.1 - height) / 2.)) + .w(px(width)) + .h(px(height)) + } else { + div() + .absolute() + .left(px(knob_x)) + .top(px((TRACK.1 - KNOB.1) / 2.)) + .w(px(KNOB.0)) + .h(px(KNOB.1)) + .rounded(px(KNOB.1 / 2.)) + .bg(gpui::white()) + .border_1() + .border_color(gpui::hsla(0., 0., 0., 0.06)) + .shadow(vec![gpui::BoxShadow { + color: gpui::hsla(0., 0., 0., 0.22), + offset: gpui::point(px(0.), px(1.)), + blur_radius: px(3.), + spread_radius: px(0.), + inset: false, + }]) + }; + + let press = state.clone(); + let release = state.clone(); + let release_outside = state; + div() + .id(id) + .role(gpui::Role::Switch) + .aria_toggled(if checked { + gpui::Toggled::True + } else { + gpui::Toggled::False + }) + .relative() + .flex_shrink_0() + .w(px(TRACK.0)) + .h(px(TRACK.1)) + .rounded(px(TRACK.1 / 2.)) + .bg(track_fill) + .cursor_pointer() + .child(knob) + .on_mouse_down(MouseButton::Left, move |_, _, cx| { + press.update(cx, |state, cx| { + state.pressed = true; + cx.notify(); + }); + }) + .on_mouse_up(MouseButton::Left, move |_, _, cx| { + release.update(cx, |state, cx| { + state.pressed = false; + cx.notify(); + }); + }) + .on_mouse_up_out(MouseButton::Left, move |_, _, cx| { + release_outside.update(cx, |state, cx| { + state.pressed = false; + cx.notify(); + }); + }) + .when_some(on_click, |this, handler| { + this.on_click(move |event, window, cx| handler(event, window, cx)) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_knob_slides_from_end_to_end_and_settles() { + let start = Instant::now(); + assert_eq!(travel(None, start), 1.); + assert_eq!(travel(Some(start), start), 0.); + let mut last = 0.; + for ms in [20, 60, 110, 160, 219] { + let t = travel(Some(start), start + Duration::from_millis(ms)); + assert!(t > last && t < 1., "{ms}ms -> {t}"); + last = t; + } + assert_eq!(travel(Some(start), start + TRAVEL), 1.); + assert_eq!(travel(Some(start), start + TRAVEL * 2), 1.); + } +} diff --git a/apps/desktop-gpui/src/ui/hint.rs b/apps/desktop-gpui/src/ui/hint.rs new file mode 100644 index 00000000000..70c77762302 --- /dev/null +++ b/apps/desktop-gpui/src/ui/hint.rs @@ -0,0 +1,644 @@ +//! `Hint`: a title and a line of explanation in a bubble anchored to the +//! control it describes. +//! +//! gpui's `.tooltip(..)` pins its bubble to the pointer, so over a small +//! control the bubble covers the cursor and whatever it points at. A hint sits +//! beside its control instead, with an arrow pointing back at it, on whichever +//! side keeps it clear of the cursor and inside the window. It fades in +//! sliding out of the control, and fades back into it. +//! +//! A view owns one [`Hints`]. Each control adds [`Hints::anchor`] as a child, +//! which records where the control was painted and what its hint says, and +//! reports hover through [`Hints::hover`]. The view calls +//! [`Hints::begin_frame`] at the top of `render` and paints [`Hints::render`] +//! last, above everything else. + +use std::{ + cell::{Cell, RefCell}, + collections::HashMap, + rc::Rc, + time::Duration, +}; + +use gpui::{ + Animation, AnimationExt as _, AnyElement, Bounds, BoxShadow, Context, FontWeight, Hsla, + IntoElement, ParentElement as _, Pixels, Point, SharedString, Size, Styled as _, TextRun, + Window, canvas, div, point, px, size, svg, +}; + +use crate::theme::Theme; + +/// How long the pointer rests on a control before its hint appears. Moving +/// straight on to another control shows that one at once, as macOS does. +pub const SHOW_DELAY: Duration = Duration::from_millis(450); +const FADE_IN: Duration = Duration::from_millis(200); +const FADE_OUT: Duration = Duration::from_millis(130); + +/// Between the control and the arrow's tip. +const GAP: f32 = 6.; +/// How far the arrow stands off the bubble. +const ARROW: f32 = 7.; +const ARROW_BASE: f32 = 14.; +/// How far the bubble travels as it fades in. +const SLIDE: f32 = 6.; +const RADIUS: f32 = 10.; +const PAD_X: f32 = 12.; +const PAD_Y: f32 = 8.; +const TEXT_WIDTH: f32 = 236.; +const TITLE_SIZE: f32 = 13.; +const TITLE_LINE: f32 = 18.; +const BODY_SIZE: f32 = 12.; +const BODY_LINE: f32 = 16.; +const BODY_GAP: f32 = 2.; +/// Kept clear of the window's edges. +const MARGIN: f32 = 8.; +/// The pointer's arrow, measured from its hotspot: it hangs down and to the +/// right, so a bubble below or right of a control can still land on it. +const CURSOR: (f32, f32, f32, f32) = (-2., -2., 16., 24.); + +#[derive(Debug, Clone, PartialEq)] +pub struct Hint { + pub title: SharedString, + pub body: Option, + /// The side to try first, for a control whose neighbours a hint above + /// would cover. + pub side: Option, +} + +impl Hint { + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + body: None, + side: None, + } + } + + pub fn side(mut self, side: Side) -> Self { + self.side = Some(side); + self + } + + pub fn body(mut self, body: impl Into) -> Self { + self.body = Some(body.into()); + self + } +} + +/// Which side of its control a hint sits on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Side { + Above, + Below, + Right, + Left, +} + +impl Side { + /// Towards the control, which is where the bubble slides out from. + fn towards_control(self) -> (f32, f32) { + match self { + Side::Above => (0., 1.), + Side::Below => (0., -1.), + Side::Right => (-1., 0.), + Side::Left => (1., 0.), + } + } + + fn arrow_icon(self) -> &'static str { + match self { + Side::Above => "icons/hint-arrow-down.svg", + Side::Below => "icons/hint-arrow-up.svg", + Side::Right => "icons/hint-arrow-left.svg", + Side::Left => "icons/hint-arrow-right.svg", + } + } +} + +/// Where a hint goes: the bubble's bounds, the side it took, and how far +/// along the bubble's facing edge the arrow's centre sits. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Placement { + pub side: Side, + pub bubble: Bounds, + pub arrow_at: Pixels, +} + +/// 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. +pub fn place( + control: Bounds, + bubble: Size, + cursor: Point, + window: Size, + prefer: Option, +) -> 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)); + let (left, top) = (f32::from(control.origin.x), f32::from(control.origin.y)); + let (right, bottom) = ( + left + f32::from(control.size.width), + top + f32::from(control.size.height), + ); + let (mid_x, mid_y) = ((left + right) / 2., (top + bottom) / 2.); + let clamp_x = |x: f32| x.clamp(MARGIN, (win_w - MARGIN - w).max(MARGIN)); + let clamp_y = |y: f32| y.clamp(MARGIN, (win_h - MARGIN - h).max(MARGIN)); + + let candidate = |side: Side| -> (f32, f32) { + match side { + Side::Above => (clamp_x(mid_x - w / 2.), top - GAP - ARROW - h), + Side::Below => (clamp_x(mid_x - w / 2.), bottom + GAP + ARROW), + Side::Right => (right + GAP + ARROW, clamp_y(mid_y - h / 2.)), + Side::Left => (left - GAP - ARROW - w, clamp_y(mid_y - h / 2.)), + } + }; + 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| { + let (x, y) = candidate(side); + let inside = x >= MARGIN - 0.5 + && y >= MARGIN - 0.5 + && x + w <= win_w - MARGIN + 0.5 + && y + h <= win_h - MARGIN + 0.5; + // The arrow counts too: it reaches back to the control. + let (x0, y0, x1, y1) = match side { + Side::Above => (x, y, x + w, top), + Side::Below => (x, bottom, x + w, y + h), + 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 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))) + .unwrap_or(Side::Above); + let (mut x, mut y) = candidate(side); + if side == Side::Above && !works(side) { + y = y.max(MARGIN); + x = clamp_x(x); + } + let reach = RADIUS + ARROW_BASE / 2.; + let arrow_at = match side { + Side::Above | Side::Below => (mid_x - x).clamp(reach, w - reach), + Side::Right | Side::Left => (mid_y - y).clamp(reach, h - reach), + }; + Placement { + side, + bubble: Bounds::new(point(px(x), px(y)), size(px(w), px(h))), + arrow_at: px(arrow_at), + } +} + +struct Anchor { + bounds: Bounds, + hint: Hint, + frame: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Phase { + /// The pointer is resting on the control; the hint is not up yet. + Waiting, + Shown, + Leaving, +} + +struct Active { + id: &'static str, + phase: Phase, + /// Counts showings, so each one plays its own fade. + showing: u64, + /// Taken over straight from another hint, so it appears at once. + instant: bool, +} + +#[derive(Default)] +pub struct Hints { + anchors: Rc>>, + frame: u64, + active: Option, + /// Bumped by every hover change; a timer only acts if none came since. + serial: u64, + showings: u64, + side: Cell>, +} + +impl Hints { + pub fn begin_frame(&mut self) { + self.frame += 1; + } + + /// An invisible child of the control that records where it was painted + /// and what its hint says. The control lays it out over itself. + pub fn anchor(&self, id: &'static str, hint: Hint) -> impl IntoElement { + let anchors = self.anchors.clone(); + let frame = self.frame; + canvas( + move |bounds, _, _| { + anchors.borrow_mut().insert( + id, + Anchor { + bounds, + hint, + 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( + &mut self, + id: &'static str, + hovered: bool, + cx: &mut Context, + hints: fn(&mut V) -> &mut Hints, + ) { + self.serial += 1; + let serial = self.serial; + if hovered { + let up = self + .active + .as_ref() + .is_some_and(|active| active.phase != Phase::Waiting); + if up { + self.show(id, true); + } else { + self.active = Some(Active { + id, + phase: Phase::Waiting, + showing: 0, + instant: false, + }); + later(SHOW_DELAY, serial, cx, hints, move |hints| { + hints.show(id, false) + }); + } + cx.notify(); + return; + } + let Some(active) = self.active.as_mut().filter(|active| active.id == id) else { + return; + }; + match active.phase { + Phase::Waiting => self.active = None, + Phase::Shown => { + active.phase = Phase::Leaving; + later(FADE_OUT, serial, cx, hints, |hints| hints.active = None); + } + Phase::Leaving => {} + } + cx.notify(); + } + + /// Takes the hint down at once: its control was clicked. + pub fn dismiss(&mut self) { + self.serial += 1; + self.active = None; + } + + fn show(&mut self, id: &'static str, instant: bool) { + self.showings += 1; + self.side.set(None); + self.active = Some(Active { + id, + phase: Phase::Shown, + showing: self.showings, + instant, + }); + } + + /// The hint that is up, painted over the whole window. `None` when there + /// is none, or its control was not painted last frame. + pub fn render( + &self, + window: &mut Window, + theme: &Theme, + reduce_motion: bool, + ) -> Option { + let active = self + .active + .as_ref() + .filter(|active| active.phase != Phase::Waiting)?; + let (bounds, hint) = { + let anchors = self.anchors.borrow(); + let anchor = anchors + .get(active.id) + .filter(|anchor| anchor.frame + 1 >= self.frame)?; + (anchor.bounds, anchor.hint.clone()) + }; + + let dark = theme.is_dark(); + let (fill, edge, title_color, body_color): (Hsla, Hsla, Hsla, Hsla) = if dark { + ( + gpui::hsla(240. / 360., 0.04, 0.13, 1.), + gpui::hsla(0., 0., 1., 0.12), + gpui::hsla(0., 0., 1., 0.96), + gpui::hsla(0., 0., 1., 0.62), + ) + } else { + ( + gpui::hsla(0., 0., 1., 1.), + gpui::hsla(0., 0., 0., 0.09), + gpui::hsla(0., 0., 0., 0.88), + gpui::hsla(0., 0., 0., 0.56), + ) + }; + + let text = measure(window, &hint); + let bubble = size( + px((text.0 + 2. * PAD_X + 2.).ceil()), + px((text.1 + 2. * PAD_Y + 2.).ceil()), + ); + let placed = place( + bounds, + bubble, + window.mouse_position(), + window.viewport_size(), + self.side.get().or(hint.side), + ); + self.side.set(Some(placed.side)); + + let (w, h) = ( + f32::from(placed.bubble.size.width), + f32::from(placed.bubble.size.height), + ); + let arrow_at = f32::from(placed.arrow_at); + // The arrow overlaps the bubble's border by a pixel so the two read + // as one shape; the edge-coloured arrow behind it draws its outline. + let arrow = |grow: f32, color: Hsla| { + let (base, depth) = (ARROW_BASE + grow * 2., ARROW + grow); + let (aw, ah) = match placed.side { + Side::Above | Side::Below => (base, depth), + Side::Right | Side::Left => (depth, base), + }; + let (left, top) = match placed.side { + Side::Above => (arrow_at - aw / 2., h - 1.), + Side::Below => (arrow_at - aw / 2., 1. - ah), + Side::Right => (1. - aw, arrow_at - ah / 2.), + Side::Left => (w - 1., arrow_at - ah / 2.), + }; + svg() + .path(placed.side.arrow_icon()) + .absolute() + .left(px(left)) + .top(px(top)) + .w(px(aw)) + .h(px(ah)) + .text_color(color) + }; + + let shadow = vec![ + BoxShadow { + color: gpui::hsla(0., 0., 0., if dark { 0.45 } else { 0.16 }), + offset: point(px(0.), px(8.)), + blur_radius: px(24.), + spread_radius: px(0.), + inset: false, + }, + BoxShadow { + color: gpui::hsla(0., 0., 0., if dark { 0.3 } else { 0.08 }), + offset: point(px(0.), px(1.)), + blur_radius: px(3.), + spread_radius: px(0.), + inset: false, + }, + ]; + + let body = div() + .size_full() + .rounded(px(RADIUS)) + .border_1() + .border_color(edge) + .bg(fill) + .shadow(shadow) + .px(px(PAD_X)) + .py(px(PAD_Y)) + .flex() + .flex_col() + .gap(px(BODY_GAP)) + .child( + div() + .w(px(text.0 + 1.)) + .text_size(px(TITLE_SIZE)) + .line_height(px(TITLE_LINE)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(title_color) + .child(hint.title.clone()), + ) + .children(hint.body.clone().map(|body| { + div() + .w(px(text.0 + 1.)) + .text_size(px(BODY_SIZE)) + .line_height(px(BODY_LINE)) + .font_weight(FontWeight::NORMAL) + .text_color(body_color) + .child(body) + })); + + let leaving = active.phase == Phase::Leaving; + let (x, y) = ( + f32::from(placed.bubble.origin.x), + f32::from(placed.bubble.origin.y), + ); + let (dx, dy) = placed.side.towards_control(); + let slide = if reduce_motion { 0. } else { SLIDE }; + let id = SharedString::from(format!( + "hint-{}-{}", + active.showing, + if leaving { "out" } else { "in" } + )); + let animation = if leaving { + Animation::new(FADE_OUT).with_easing(gpui::ease_in_out) + } else { + Animation::new(FADE_IN).with_easing(gpui::ease_out_quint()) + }; + let bubble = div() + .absolute() + .left(px(x)) + .top(px(y)) + .w(px(w)) + .h(px(h)) + .child(arrow(1., edge)) + .child(body) + .child(arrow(0., fill)); + // Moving from one control straight to the next swaps the hint at + // once, as macOS does; fading it in again would blink. + if active.instant && !leaving { + return Some(bubble.into_any_element()); + } + Some( + bubble + .with_animation(id, animation, move |element, delta| { + let shown = if leaving { 1. - delta } else { delta }; + let offset = (1. - shown) * slide; + element + .left(px(x + dx * offset)) + .top(px(y + dy * offset)) + .opacity(shown) + }) + .into_any_element(), + ) + } +} + +/// The text block's size: the title and body wrapped at [`TEXT_WIDTH`], as +/// wide as the longest line. +fn measure(window: &Window, hint: &Hint) -> (f32, f32) { + let shape = |text: &SharedString, font_size: f32, weight: FontWeight| { + let mut font = gpui::font(crate::theme::UI_FONT); + font.weight = weight; + let run = TextRun { + len: text.len(), + font, + color: gpui::black(), + background_color: None, + underline: None, + strikethrough: None, + }; + window + .text_system() + .shape_text( + text.clone(), + px(font_size), + &[run], + Some(px(TEXT_WIDTH)), + None, + ) + .map(|lines| { + lines.iter().fold((0f32, 0usize), |(width, count), line| { + ( + width.max(f32::from(line.width())), + count + line.wrap_boundaries().len() + 1, + ) + }) + }) + .unwrap_or((TEXT_WIDTH, 1)) + }; + let (title_w, title_lines) = shape(&hint.title, TITLE_SIZE, FontWeight::SEMIBOLD); + let (mut width, mut height) = (title_w, title_lines as f32 * TITLE_LINE); + if let Some(body) = &hint.body { + let (body_w, body_lines) = shape(body, BODY_SIZE, FontWeight::NORMAL); + width = width.max(body_w); + height += BODY_GAP + body_lines as f32 * BODY_LINE; + } + (width.ceil(), height) +} + +fn later( + delay: Duration, + serial: u64, + cx: &mut Context, + hints: fn(&mut V) -> &mut Hints, + then: impl FnOnce(&mut Hints) + 'static, +) { + cx.spawn(async move |view, cx| { + cx.background_executor().timer(delay).await; + view.update(cx, |view, cx| { + let state = hints(view); + if state.serial == serial { + then(state); + cx.notify(); + } + }) + .ok(); + }) + .detach(); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rect(x: f32, y: f32, w: f32, h: f32) -> Bounds { + Bounds::new(point(px(x), px(y)), size(px(w), px(h))) + } + + fn window() -> Size { + size(px(1440.), px(900.)) + } + + #[test] + fn sits_above_and_centred_on_its_control() { + let control = rect(600., 700., 120., 32.); + let placed = place( + control, + size(px(200.), px(50.)), + point(px(610.), px(716.)), + window(), + None, + ); + assert_eq!(placed.side, Side::Above); + assert_eq!( + placed.bubble.origin, + point(px(560.), px(700. - GAP - ARROW - 50.)) + ); + assert_eq!(placed.arrow_at, px(100.)); + } + + #[test] + fn flips_below_at_the_top_of_the_window() { + let control = rect(600., 20., 120., 32.); + let placed = place( + control, + size(px(200.), px(50.)), + point(px(610.), px(26.)), + window(), + None, + ); + assert_eq!(placed.side, Side::Below); + } + + #[test] + fn never_lands_on_the_pointer() { + // At the top of the window, with the pointer at the control's bottom + // 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); + assert_ne!(placed.side, Side::Below); + assert!(!placed.bubble.contains(&cursor)); + } + + #[test] + fn stays_inside_the_window_with_the_arrow_on_the_control() { + let control = rect(4., 700., 40., 32.); + let placed = place( + control, + size(px(200.), px(50.)), + point(px(10.), px(710.)), + window(), + None, + ); + assert_eq!(placed.side, Side::Above); + assert_eq!(placed.bubble.origin.x, px(MARGIN)); + // The arrow points at the control's centre, short of the corner. + assert_eq!(placed.arrow_at, px(RADIUS + ARROW_BASE / 2.)); + } + + #[test] + fn keeps_its_side_while_that_still_works() { + let control = rect(600., 400., 120., 32.); + let placed = place( + control, + size(px(200.), px(50.)), + point(px(610.), px(406.)), + window(), + Some(Side::Left), + ); + assert_eq!(placed.side, Side::Left); + } +} diff --git a/apps/desktop-gpui/src/ui/mod.rs b/apps/desktop-gpui/src/ui/mod.rs index 8473ee64ae7..478a6b0ae08 100644 --- a/apps/desktop-gpui/src/ui/mod.rs +++ b/apps/desktop-gpui/src/ui/mod.rs @@ -46,6 +46,8 @@ mod color_picker; mod editor_button; mod field; pub mod glass; +mod glass_switch; +mod hint; mod kbd; pub mod keyboard_nav; mod menu; @@ -88,6 +90,8 @@ pub use editor_button::{EditorButton, EditorButtonVariant}; #[allow(unused_imports)] pub use field::{Field, Subfield}; #[allow(unused_imports)] +pub use hint::{Hint, Hints, Side as HintSide}; + pub use kbd::{KbdChip, KbdSize, kbd_symbol}; #[allow(unused_imports)] pub use menu::{Menu, MenuItem, MenuKey, MenuState}; @@ -123,6 +127,7 @@ pub use text_input::{ text_input_has_focus, }; #[allow(unused_imports)] +pub use glass_switch::Switch as GlassSwitch; pub use toggle::{Toggle, ToggleSize}; #[allow(unused_imports)] pub use tooltip::{TOOLTIP_SHOW_DELAY, Tooltip, TooltipStyle}; diff --git a/scripts/glass-reference/gallery.swift b/scripts/glass-reference/gallery.swift new file mode 100644 index 00000000000..36056c4f6a2 --- /dev/null +++ b/scripts/glass-reference/gallery.swift @@ -0,0 +1,231 @@ +// Captures macOS's own controls, in a key window, as the reference for Cap's +// app-wide glass styling: every standard control, a sidebar window, a grouped +// settings form and an open pop-up menu, in light and dark. +// +// swiftc -O gallery.swift -o gallery && ./gallery +// +// Needs Screen Recording for the process that runs it. + +import AppKit +import SwiftUI + +let out = URL(fileURLWithPath: CommandLine.arguments.dropFirst().first ?? "gallery") +try FileManager.default.createDirectory(at: out, withIntermediateDirectories: true) + +struct Controls: View { + @State var on = true + @State var off = false + @State var check = true + @State var level = 0.4 + @State var segment = 1 + @State var choice = "Studio" + @State var text = "My recording" + @State var search = "" + @State var radio = 0 + @State var stepper = 3 + + var body: some View { + VStack(alignment: .leading, spacing: 22) { + HStack(spacing: 14) { + Button("Glass") {}.buttonStyle(.glass) + Button("Prominent") {}.buttonStyle(.glassProminent) + Button("Bordered") {}.buttonStyle(.bordered) + Button("Bordered Prominent") {}.buttonStyle(.borderedProminent) + Button("Plain") {}.buttonStyle(.plain) + Button("Disabled") {}.buttonStyle(.glass).disabled(true) + } + HStack(spacing: 14) { + Button("Small") {}.buttonStyle(.glass).controlSize(.small) + Button("Regular") {}.buttonStyle(.glass) + Button("Large") {}.buttonStyle(.glass).controlSize(.large) + Button("Extra Large") {}.buttonStyle(.glassProminent).controlSize(.extraLarge) + Button {} label: { Image(systemName: "xmark") }.buttonStyle(.glass).buttonBorderShape(.circle) + Button {} label: { Image(systemName: "gearshape") }.buttonStyle(.glass).buttonBorderShape(.circle).controlSize(.large) + Button {} label: { Label("Record", systemImage: "record.circle") }.buttonStyle(.glassProminent).tint(.red) + } + HStack(spacing: 18) { + Picker("Mode", selection: $choice) { + Text("Instant").tag("Instant") + Text("Studio").tag("Studio") + Text("Screenshot").tag("Screenshot") + } + .pickerStyle(.menu) + .frame(width: 180) + Picker("", selection: $segment) { + Text("Free").tag(0) + Text("16:9").tag(1) + Text("4:3").tag(2) + Text("1:1").tag(3) + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 220) + Menu("Options") { + Button("Rename") {} + Button("Duplicate") {} + } + .frame(width: 110) + } + HStack(spacing: 18) { + Toggle("", isOn: $on).toggleStyle(.switch).labelsHidden() + Toggle("", isOn: $off).toggleStyle(.switch).labelsHidden() + Toggle("Checkbox", isOn: $check).toggleStyle(.checkbox) + Picker("", selection: $radio) { + Text("One").tag(0) + Text("Two").tag(1) + } + .pickerStyle(.radioGroup) + .horizontalRadioGroupLayout() + .labelsHidden() + Stepper("Count \(stepper)", value: $stepper) + Slider(value: $level).frame(width: 220) + } + HStack(spacing: 18) { + TextField("Title", text: $text).textFieldStyle(.roundedBorder).frame(width: 220) + TextField("Search", text: $search).textFieldStyle(.roundedBorder).frame(width: 180) + ProgressView(value: 0.6).frame(width: 160) + ProgressView().controlSize(.small) + } + GroupBox("Group box") { + Text("Content inside a group box").frame(maxWidth: .infinity, alignment: .leading) + } + .frame(width: 420) + } + .padding(28) + } +} + +struct Sidebar: View { + @State var selection: String? = "Recordings" + var body: some View { + NavigationSplitView { + List(selection: $selection) { + Section("Library") { + Label("Recordings", systemImage: "film").tag("Recordings") + Label("Screenshots", systemImage: "camera").tag("Screenshots") + Label("Shared", systemImage: "link").tag("Shared") + } + Section("Settings") { + Label("General", systemImage: "gearshape").tag("General") + Label("Shortcuts", systemImage: "keyboard").tag("Shortcuts") + } + } + .navigationSplitViewColumnWidth(200) + } detail: { + ScrollView { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 180), spacing: 16)], spacing: 16) { + ForEach(0..<6) { index in + VStack(alignment: .leading, spacing: 6) { + RoundedRectangle(cornerRadius: 10) + .fill(Color(hue: Double(index) / 6, saturation: 0.35, brightness: 0.85)) + .aspectRatio(16 / 10, contentMode: .fit) + Text("Recording \(index + 1)").font(.headline) + Text("2 min · Today").font(.caption).foregroundStyle(.secondary) + } + } + } + .padding(20) + } + .toolbar { + ToolbarItem { Button {} label: { Image(systemName: "plus") } } + ToolbarItem { Button {} label: { Image(systemName: "square.and.arrow.up") } } + ToolbarItem { Picker("", selection: .constant(0)) { Image(systemName: "square.grid.2x2").tag(0); Image(systemName: "list.bullet").tag(1) }.pickerStyle(.segmented) } + } + .navigationTitle("Recordings") + } + } +} + +struct Settings: View { + @State var launch = true + @State var sound = false + @State var quality = "High" + @State var countdown = 3.0 + var body: some View { + Form { + Section("General") { + Toggle("Open at login", isOn: $launch) + Toggle("Play sounds", isOn: $sound) + Picker("Export quality", selection: $quality) { + Text("High").tag("High") + Text("Medium").tag("Medium") + } + } + Section("Recording") { + Slider(value: $countdown, in: 0...10) { Text("Countdown") } + LabeledContent("Folder") { Button("Choose…") {} } + } + } + .formStyle(.grouped) + } +} + +func window(_ title: String, _ view: V, size: NSSize, toolbar: Bool = false) -> NSWindow { + let window = NSWindow( + contentRect: NSRect(origin: NSPoint(x: 120, y: 120), size: size), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, defer: false) + window.title = title + window.contentView = NSHostingView(rootView: view) + if toolbar { window.toolbar = NSToolbar() } + window.isReleasedWhenClosed = false + return window +} + +func pump(_ seconds: TimeInterval) { + RunLoop.current.run(until: Date().addingTimeInterval(seconds)) +} + +func capture(_ window: NSWindow, _ name: String) { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") + task.arguments = ["-x", "-o", "-l\(window.windowNumber)", out.appendingPathComponent("\(name).png").path] + try? task.run() + task.waitUntilExit() +} + +func captureScreen(_ name: String) { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") + task.arguments = ["-x", out.appendingPathComponent("\(name).png").path] + try? task.run() + task.waitUntilExit() +} + +let app = NSApplication.shared +app.setActivationPolicy(.regular) +app.finishLaunching() +app.activate(ignoringOtherApps: true) +pump(2) + +for (appearance, suffix) in [(NSAppearance.Name.aqua, "light"), (.darkAqua, "dark")] { + app.appearance = NSAppearance(named: appearance) + for (name, w) in [ + ("controls", window("Controls", Controls(), size: NSSize(width: 980, height: 430))), + ("sidebar", window("Recordings", Sidebar(), size: NSSize(width: 900, height: 560))), + ("settings", window("Settings", Settings(), size: NSSize(width: 560, height: 460))), + ] { + w.center() + w.makeKeyAndOrderFront(nil) + app.activate(ignoringOtherApps: true) + pump(2.5) + capture(w, "\(name)-\(suffix)") + if name == "controls" { + // An open menu: a pop-up NSMenu over the window, captured with the screen. + let menu = NSMenu() + menu.autoenablesItems = false + for title in ["Instant", "Studio", "Screenshot"] { menu.addItem(withTitle: title, action: nil, keyEquivalent: "") } + menu.addItem(.separator()) + menu.addItem(withTitle: "Recording settings…", action: nil, keyEquivalent: ",") + menu.items[1].state = .on + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { + captureScreen("menu-\(suffix)") + menu.cancelTracking() + } + menu.popUp(positioning: menu.items[1], at: NSPoint(x: 60, y: 300), in: w.contentView) + pump(0.5) + } + w.orderOut(nil) + } +} +print("wrote \(out.path)")