screenshot.rs

  1//! Example: Off-screen Window Rendering with Screenshots
  2//!
  3//! This example demonstrates how to:
  4//! 1. Create a window positioned off-screen (so it's not visible to the user)
  5//! 2. Render real GPUI content using Metal
  6//! 3. Take screenshots of the window using CGWindowListCreateImage
  7//! 4. Save the screenshots as PNG files
  8//!
  9//! This is useful for automated visual testing where you want real rendering
 10//! but don't want windows appearing on screen.
 11//!
 12//! Usage:
 13//!   cargo run -p gpui --example screenshot
 14//!
 15//! Note: This requires macOS and Screen Recording permissions.
 16//! The first time you run this, macOS will prompt you to grant permission.
 17
 18use gpui::{
 19    App, AppContext, Application, Bounds, Context, Entity, IntoElement, Render, SharedString,
 20    Window, WindowBounds, WindowHandle, WindowOptions, div, point, prelude::*, px, rgb, size,
 21};
 22use raw_window_handle::{HasWindowHandle, RawWindowHandle};
 23use std::path::PathBuf;
 24use std::time::Duration;
 25
 26// ============================================================================
 27// GPUI View to Render
 28// ============================================================================
 29
 30struct ScreenshotDemo {
 31    counter: u32,
 32    message: SharedString,
 33}
 34
 35impl ScreenshotDemo {
 36    fn new() -> Self {
 37        Self {
 38            counter: 0,
 39            message: "Hello, Screenshot!".into(),
 40        }
 41    }
 42
 43    fn increment(&mut self) {
 44        self.counter += 1;
 45        self.message = format!("Counter: {}", self.counter).into();
 46    }
 47}
 48
 49impl Render for ScreenshotDemo {
 50    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
 51        div()
 52            .flex()
 53            .flex_col()
 54            .gap_4()
 55            .bg(rgb(0x1e1e2e)) // Dark background
 56            .size_full()
 57            .justify_center()
 58            .items_center()
 59            .child(
 60                div()
 61                    .text_3xl()
 62                    .text_color(rgb(0xcdd6f4))
 63                    .child(self.message.clone()),
 64            )
 65            .child(
 66                div()
 67                    .flex()
 68                    .gap_3()
 69                    .child(colored_box(rgb(0xf38ba8))) // Red
 70                    .child(colored_box(rgb(0xa6e3a1))) // Green
 71                    .child(colored_box(rgb(0x89b4fa))) // Blue
 72                    .child(colored_box(rgb(0xf9e2af))), // Yellow
 73            )
 74            .child(
 75                div()
 76                    .mt_4()
 77                    .px_4()
 78                    .py_2()
 79                    .bg(rgb(0x313244))
 80                    .rounded_md()
 81                    .text_color(rgb(0xbac2de))
 82                    .child(format!("Frame: {}", self.counter)),
 83            )
 84    }
 85}
 86
 87fn colored_box(color: gpui::Rgba) -> impl IntoElement {
 88    div()
 89        .size_16()
 90        .bg(color)
 91        .rounded_lg()
 92        .shadow_md()
 93        .border_2()
 94        .border_color(rgb(0x45475a))
 95}
 96
 97// ============================================================================
 98// Screenshot Capture (macOS-specific using CGWindowListCreateImage)
 99// ============================================================================
100
101#[cfg(target_os = "macos")]
102mod screenshot {
103    use std::path::Path;
104
105    // FFI declarations for CoreGraphics
106    #[link(name = "CoreGraphics", kind = "framework")]
107    unsafe extern "C" {
108        fn CGWindowListCreateImage(
109            rect: CGRect,
110            list_option: u32,
111            window_id: u32,
112            image_option: u32,
113        ) -> CGImageRef;
114
115        fn CGImageGetWidth(image: CGImageRef) -> usize;
116        fn CGImageGetHeight(image: CGImageRef) -> usize;
117        fn CGImageGetDataProvider(image: CGImageRef) -> CGDataProviderRef;
118        fn CGImageRelease(image: CGImageRef);
119        fn CGDataProviderCopyData(provider: CGDataProviderRef) -> CFDataRef;
120    }
121
122    #[link(name = "CoreFoundation", kind = "framework")]
123    unsafe extern "C" {
124        fn CFDataGetLength(data: CFDataRef) -> isize;
125        fn CFDataGetBytePtr(data: CFDataRef) -> *const u8;
126        fn CFRelease(cf: *const std::ffi::c_void);
127    }
128
129    type CGImageRef = *mut std::ffi::c_void;
130    type CGDataProviderRef = *mut std::ffi::c_void;
131    type CFDataRef = *mut std::ffi::c_void;
132
133    #[repr(C)]
134    #[derive(Copy, Clone)]
135    struct CGPoint {
136        x: f64,
137        y: f64,
138    }
139
140    #[repr(C)]
141    #[derive(Copy, Clone)]
142    struct CGSize {
143        width: f64,
144        height: f64,
145    }
146
147    #[repr(C)]
148    #[derive(Copy, Clone)]
149    struct CGRect {
150        origin: CGPoint,
151        size: CGSize,
152    }
153
154    impl CGRect {
155        fn null() -> Self {
156            CGRect {
157                origin: CGPoint {
158                    x: f64::INFINITY,
159                    y: f64::INFINITY,
160                },
161                size: CGSize {
162                    width: 0.0,
163                    height: 0.0,
164                },
165            }
166        }
167    }
168
169    #[allow(non_upper_case_globals)]
170    const kCGWindowListOptionIncludingWindow: u32 = 1 << 3;
171    #[allow(non_upper_case_globals)]
172    const kCGWindowImageBoundsIgnoreFraming: u32 = 1 << 0;
173
174    /// Captures a screenshot of the specified window and saves it as a PNG.
175    pub fn capture_window_to_png(
176        window_number: i64,
177        output_path: &Path,
178    ) -> Result<(), Box<dyn std::error::Error>> {
179        use std::fs::File;
180        use std::io::BufWriter;
181
182        // Capture the window
183        let image = unsafe {
184            CGWindowListCreateImage(
185                CGRect::null(),
186                kCGWindowListOptionIncludingWindow,
187                window_number as u32,
188                kCGWindowImageBoundsIgnoreFraming,
189            )
190        };
191
192        if image.is_null() {
193            return Err("Failed to capture window - image is null. \
194                        Make sure Screen Recording permission is granted in \
195                        System Preferences > Privacy & Security > Screen Recording."
196                .into());
197        }
198
199        // Get image dimensions
200        let width = unsafe { CGImageGetWidth(image) };
201        let height = unsafe { CGImageGetHeight(image) };
202
203        if width == 0 || height == 0 {
204            unsafe { CGImageRelease(image) };
205            return Err("Captured image has zero dimensions".into());
206        }
207
208        // Get the image data
209        let data_provider = unsafe { CGImageGetDataProvider(image) };
210        if data_provider.is_null() {
211            unsafe { CGImageRelease(image) };
212            return Err("Failed to get image data provider".into());
213        }
214
215        let data = unsafe { CGDataProviderCopyData(data_provider) };
216        if data.is_null() {
217            unsafe { CGImageRelease(image) };
218            return Err("Failed to copy image data".into());
219        }
220
221        let length = unsafe { CFDataGetLength(data) } as usize;
222        let ptr = unsafe { CFDataGetBytePtr(data) };
223        let bytes = unsafe { std::slice::from_raw_parts(ptr, length) };
224
225        // The image is in BGRA format, convert to RGBA for PNG
226        let mut rgba_bytes = Vec::with_capacity(length);
227        for chunk in bytes.chunks(4) {
228            if chunk.len() == 4 {
229                rgba_bytes.push(chunk[2]); // R (was B)
230                rgba_bytes.push(chunk[1]); // G
231                rgba_bytes.push(chunk[0]); // B (was R)
232                rgba_bytes.push(chunk[3]); // A
233            }
234        }
235
236        // Write PNG file
237        let file = File::create(output_path)?;
238        let w = BufWriter::new(file);
239        let mut encoder = png::Encoder::new(w, width as u32, height as u32);
240        encoder.set_color(png::ColorType::Rgba);
241        encoder.set_depth(png::BitDepth::Eight);
242        let mut writer = encoder.write_header()?;
243        writer.write_image_data(&rgba_bytes)?;
244
245        // Cleanup
246        unsafe {
247            CFRelease(data as *const _);
248            CGImageRelease(image);
249        }
250
251        println!(
252            "Screenshot saved to {} ({}x{})",
253            output_path.display(),
254            width,
255            height
256        );
257        Ok(())
258    }
259}
260
261#[cfg(not(target_os = "macos"))]
262mod screenshot {
263    use std::path::Path;
264
265    pub fn capture_window_to_png(
266        _window_number: i64,
267        _output_path: &Path,
268    ) -> Result<(), Box<dyn std::error::Error>> {
269        Err("Screenshot capture is only supported on macOS".into())
270    }
271}
272
273// ============================================================================
274// Main Application
275// ============================================================================
276
277fn main() {
278    env_logger::init();
279
280    Application::new().run(|cx: &mut App| {
281        // Position the window FAR off-screen so it's not visible
282        // but macOS still renders it (unlike minimized/hidden windows)
283        let off_screen_origin = point(px(-10000.0), px(-10000.0));
284        let window_size = size(px(800.0), px(600.0));
285
286        let bounds = Bounds {
287            origin: off_screen_origin,
288            size: window_size,
289        };
290
291        println!("Creating off-screen window at {:?}", bounds);
292        println!("(The window is positioned off-screen but is still being rendered by macOS)");
293
294        // Open the window
295        let window_handle: WindowHandle<ScreenshotDemo> = cx
296            .open_window(
297                WindowOptions {
298                    window_bounds: Some(WindowBounds::Windowed(bounds)),
299                    focus: false, // Don't steal focus
300                    show: true,   // Must be true for rendering to occur
301                    ..Default::default()
302                },
303                |_, cx| cx.new(|_| ScreenshotDemo::new()),
304            )
305            .expect("Failed to open window");
306
307        // Get the entity for later updates
308        let view_entity: Entity<ScreenshotDemo> =
309            window_handle.entity(cx).expect("Failed to get root entity");
310
311        // Get output directory
312        let output_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
313
314        // Schedule screenshot captures after allowing time for rendering
315        cx.spawn(async move |cx| {
316            // Wait for the window to fully render
317            smol::Timer::after(Duration::from_millis(500)).await;
318
319            // Get the window number for screenshots
320            let window_number = cx
321                .update(|app: &mut App| get_window_number_from_handle(&window_handle, app))
322                .ok()
323                .flatten();
324
325            let Some(window_number) = window_number else {
326                eprintln!("Could not get window number. Are you running on macOS?");
327                let _ = cx.update(|app: &mut App| app.quit());
328                return;
329            };
330
331            println!("Window number: {}", window_number);
332
333            // Take screenshot 1
334            let output_path = output_dir.join("screenshot_1.png");
335            match screenshot::capture_window_to_png(window_number, &output_path) {
336                Ok(()) => println!("✓ Captured screenshot_1.png"),
337                Err(e) => eprintln!("✗ Failed to capture screenshot_1.png: {}", e),
338            }
339
340            // Update the view (update the entity directly, not through window_handle.update)
341            let _ = cx.update_entity(&view_entity, |view: &mut ScreenshotDemo, ecx| {
342                view.increment();
343                view.increment();
344                view.increment();
345                ecx.notify(); // Trigger a re-render
346            });
347
348            // Wait for re-render
349            smol::Timer::after(Duration::from_millis(200)).await;
350
351            // Take screenshot 2
352            let output_path = output_dir.join("screenshot_2.png");
353            match screenshot::capture_window_to_png(window_number, &output_path) {
354                Ok(()) => println!("✓ Captured screenshot_2.png"),
355                Err(e) => eprintln!("✗ Failed to capture screenshot_2.png: {}", e),
356            }
357
358            // Update again
359            let _ = cx.update_entity(&view_entity, |view: &mut ScreenshotDemo, ecx| {
360                for _ in 0..7 {
361                    view.increment();
362                }
363                ecx.notify(); // Trigger a re-render
364            });
365
366            // Wait for re-render
367            smol::Timer::after(Duration::from_millis(200)).await;
368
369            // Take screenshot 3
370            let output_path = output_dir.join("screenshot_3.png");
371            match screenshot::capture_window_to_png(window_number, &output_path) {
372                Ok(()) => println!("✓ Captured screenshot_3.png"),
373                Err(e) => eprintln!("✗ Failed to capture screenshot_3.png: {}", e),
374            }
375
376            println!("\nAll screenshots captured!");
377            println!(
378                "Check {} for screenshot_1.png, screenshot_2.png, screenshot_3.png",
379                output_dir.display()
380            );
381
382            // Quit after screenshots are taken
383            smol::Timer::after(Duration::from_millis(500)).await;
384            let _ = cx.update(|app: &mut App| app.quit());
385        })
386        .detach();
387    });
388}
389
390/// Extract the window number from a GPUI WindowHandle using raw_window_handle
391#[cfg(target_os = "macos")]
392fn get_window_number_from_handle<V: 'static + Render>(
393    window_handle: &WindowHandle<V>,
394    cx: &mut App,
395) -> Option<i64> {
396    use objc::{msg_send, sel, sel_impl};
397
398    window_handle
399        .update(cx, |_root: &mut V, window: &mut Window, _cx| {
400            let handle = window.window_handle().ok()?;
401            match handle.as_raw() {
402                RawWindowHandle::AppKit(appkit_handle) => {
403                    let ns_view = appkit_handle.ns_view.as_ptr();
404                    unsafe {
405                        let ns_window: *mut std::ffi::c_void =
406                            msg_send![ns_view as cocoa::base::id, window];
407                        if ns_window.is_null() {
408                            return None;
409                        }
410                        let window_number: i64 =
411                            msg_send![ns_window as cocoa::base::id, windowNumber];
412                        Some(window_number)
413                    }
414                }
415                _ => None,
416            }
417        })
418        .ok()
419        .flatten()
420}
421
422#[cfg(not(target_os = "macos"))]
423fn get_window_number_from_handle<V: 'static + Render>(
424    _window_handle: &WindowHandle<V>,
425    _cx: &mut App,
426) -> Option<i64> {
427    None
428}