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}