1//! Headless Metal platform for visual tests without AppKit dependencies.
2//!
3//! This module provides a platform implementation that can render GPUI scenes
4//! to images using Metal, without requiring a window, display, or the main thread.
5//!
6//! # Thread Safety
7//!
8//! Unlike `VisualTestAppContext`, this platform does not use AppKit and can
9//! safely run on any thread, including Rust test worker threads.
10
11#![cfg(all(target_os = "macos", any(test, feature = "test-support")))]
12
13use crate::{
14 AnyWindowHandle, App, AppCell, AppContext, AssetSource, AtlasKey, AtlasTextureId, AtlasTile,
15 BackgroundExecutor, Bounds, ClipboardItem, CursorStyle, DevicePixels, DispatchEventResult,
16 DummyKeyboardMapper, Entity, ForegroundExecutor, GpuSpecs, Keymap, NoopTextSystem, Pixels,
17 Platform, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler,
18 PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PlatformWindow, Point,
19 PromptButton, PromptLevel, Render, RequestFrameOptions, Scene, Size, Task, TestDispatcher,
20 TestDisplay, TextSystem, TileId, Window, WindowAppearance, WindowBackgroundAppearance,
21 WindowBounds, WindowControlArea, WindowHandle, WindowOptions, WindowParams, app::GpuiMode,
22};
23use anyhow::Result;
24use collections::HashMap;
25use futures::channel::oneshot;
26use image::RgbaImage;
27use parking_lot::Mutex;
28use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
29use std::{
30 cell::RefCell,
31 path::{Path, PathBuf},
32 rc::{Rc, Weak},
33 sync::{self, Arc},
34 time::Duration,
35};
36
37use super::mac::metal_renderer::{InstanceBufferPool, MetalRenderer};
38
39#[cfg(feature = "font-kit")]
40use super::mac::MacTextSystem;
41
42// ─────────────────────────────────────────────────────────────────────────────
43// Headless Metal Platform
44// ─────────────────────────────────────────────────────────────────────────────
45
46/// A platform that uses Metal for rendering without any AppKit dependencies.
47///
48/// This allows visual tests to run on any thread, not just the main thread.
49pub struct HeadlessMetalPlatform {
50 dispatcher: TestDispatcher,
51 background_executor: BackgroundExecutor,
52 foreground_executor: ForegroundExecutor,
53 text_system: Arc<dyn PlatformTextSystem>,
54 active_display: Rc<dyn PlatformDisplay>,
55 active_cursor: Mutex<CursorStyle>,
56 clipboard: Mutex<Option<ClipboardItem>>,
57 find_pasteboard: Mutex<Option<ClipboardItem>>,
58 renderer_context: Arc<Mutex<InstanceBufferPool>>,
59 weak: RefCell<Weak<Self>>,
60}
61
62impl HeadlessMetalPlatform {
63 /// Creates a new HeadlessMetalPlatform with the given random seed.
64 pub fn new(seed: u64) -> Rc<Self> {
65 let dispatcher = TestDispatcher::new(seed);
66 let arc_dispatcher = Arc::new(dispatcher.clone());
67
68 let background_executor = BackgroundExecutor::new(arc_dispatcher.clone());
69 let foreground_executor = ForegroundExecutor::new(arc_dispatcher);
70
71 #[cfg(feature = "font-kit")]
72 let text_system: Arc<dyn PlatformTextSystem> = Arc::new(MacTextSystem::new());
73
74 #[cfg(not(feature = "font-kit"))]
75 let text_system: Arc<dyn PlatformTextSystem> = Arc::new(NoopTextSystem::new());
76 let active_display = Rc::new(TestDisplay::new());
77 let renderer_context = Arc::new(Mutex::new(InstanceBufferPool::default()));
78
79 Rc::new_cyclic(|weak| Self {
80 dispatcher,
81 background_executor,
82 foreground_executor,
83 text_system,
84 active_display,
85 active_cursor: Mutex::new(CursorStyle::Arrow),
86 clipboard: Mutex::new(None),
87 find_pasteboard: Mutex::new(None),
88 renderer_context,
89 weak: RefCell::new(weak.clone()),
90 })
91 }
92
93 /// Returns a reference to the TestDispatcher for controlling task scheduling.
94 pub fn dispatcher(&self) -> &TestDispatcher {
95 &self.dispatcher
96 }
97
98 /// Runs all pending tasks until there's nothing left to do.
99 pub fn run_until_parked(&self) {
100 self.dispatcher.run_until_parked();
101 }
102
103 /// Advances the simulated clock by the given duration.
104 pub fn advance_clock(&self, duration: Duration) {
105 self.dispatcher.advance_clock(duration);
106 }
107}
108
109// ─────────────────────────────────────────────────────────────────────────────
110// Headless Metal App Context
111// ─────────────────────────────────────────────────────────────────────────────
112
113/// App context for headless Metal rendering tests.
114///
115/// Unlike `VisualTestAppContext`, this can run on any thread because it doesn't
116/// use AppKit. It provides real Metal rendering for accurate screenshots.
117pub struct HeadlessMetalAppContext {
118 /// The underlying app cell
119 pub app: Rc<AppCell>,
120 /// The background executor for running async tasks
121 pub background_executor: BackgroundExecutor,
122 /// The foreground executor for running tasks on the main thread
123 pub foreground_executor: ForegroundExecutor,
124 /// The test dispatcher for deterministic task scheduling
125 dispatcher: TestDispatcher,
126 platform: Rc<HeadlessMetalPlatform>,
127 text_system: Arc<TextSystem>,
128}
129
130impl HeadlessMetalAppContext {
131 /// Creates a new headless Metal app context.
132 pub fn new() -> Self {
133 Self::with_asset_source(Arc::new(()))
134 }
135
136 /// Creates a new headless Metal app context with a custom asset source.
137 pub fn with_asset_source(asset_source: Arc<dyn AssetSource>) -> Self {
138 let seed = std::env::var("SEED")
139 .ok()
140 .and_then(|s| s.parse().ok())
141 .unwrap_or(0);
142
143 let platform = HeadlessMetalPlatform::new(seed);
144 let dispatcher = platform.dispatcher().clone();
145 let background_executor = platform.background_executor();
146 let foreground_executor = platform.foreground_executor();
147 let text_system = Arc::new(TextSystem::new(platform.text_system()));
148
149 let http_client = http_client::FakeHttpClient::with_404_response();
150 let mut app = App::new_app(platform.clone(), asset_source, http_client);
151 app.borrow_mut().mode = GpuiMode::test();
152
153 Self {
154 app,
155 background_executor,
156 foreground_executor,
157 dispatcher,
158 platform,
159 text_system,
160 }
161 }
162
163 /// Opens a window for headless rendering.
164 pub fn open_window<V: Render + 'static>(
165 &mut self,
166 size: Size<Pixels>,
167 build_root: impl FnOnce(&mut Window, &mut App) -> Entity<V>,
168 ) -> Result<WindowHandle<V>> {
169 use crate::{point, px};
170
171 let bounds = Bounds {
172 origin: point(px(0.0), px(0.0)),
173 size,
174 };
175
176 let mut cx = self.app.borrow_mut();
177 cx.open_window(
178 WindowOptions {
179 window_bounds: Some(WindowBounds::Windowed(bounds)),
180 focus: false,
181 show: false,
182 ..Default::default()
183 },
184 build_root,
185 )
186 }
187
188 /// Runs all pending tasks until parked.
189 pub fn run_until_parked(&self) {
190 self.dispatcher.run_until_parked();
191 }
192
193 /// Advances the simulated clock.
194 pub fn advance_clock(&self, duration: Duration) {
195 self.dispatcher.advance_clock(duration);
196 }
197
198 /// Enables parking mode, allowing blocking on real I/O (e.g., async asset loading).
199 ///
200 /// When parking is allowed, `block_on` calls will actually wait for I/O completion
201 /// instead of panicking. This is useful for visual tests that need to load embedded
202 /// assets like images.
203 pub fn allow_parking(&self) {
204 self.dispatcher.allow_parking();
205 }
206
207 /// Disables parking mode, returning to deterministic test execution.
208 ///
209 /// Call this after assets have loaded to avoid 100ms sleep intervals when
210 /// `run_until_parked()` finds no work to do.
211 pub fn forbid_parking(&self) {
212 self.dispatcher.forbid_parking();
213 }
214
215 /// Updates app state.
216 pub fn update<R>(&mut self, f: impl FnOnce(&mut App) -> R) -> R {
217 let mut app = self.app.borrow_mut();
218 f(&mut app)
219 }
220
221 /// Updates a window and calls draw to render.
222 pub fn update_window<R>(
223 &mut self,
224 window: AnyWindowHandle,
225 f: impl FnOnce(crate::AnyView, &mut Window, &mut App) -> R,
226 ) -> Result<R> {
227 let mut app = self.app.borrow_mut();
228 app.update_window(window, f)
229 }
230
231 /// Captures a screenshot from a window.
232 pub fn capture_screenshot(&mut self, window: AnyWindowHandle) -> Result<RgbaImage> {
233 let mut app = self.app.borrow_mut();
234 app.update_window(window, |_, window, _| window.render_to_image())
235 .map_err(|e| anyhow::anyhow!("Failed to update window: {:?}", e))?
236 }
237
238 /// Returns the text system.
239 pub fn text_system(&self) -> &Arc<TextSystem> {
240 &self.text_system
241 }
242
243 /// Returns the background executor.
244 pub fn background_executor(&self) -> &BackgroundExecutor {
245 &self.background_executor
246 }
247
248 /// Returns the foreground executor.
249 pub fn foreground_executor(&self) -> &ForegroundExecutor {
250 &self.foreground_executor
251 }
252}
253
254impl Default for HeadlessMetalAppContext {
255 fn default() -> Self {
256 Self::new()
257 }
258}
259
260struct HeadlessKeyboardLayout;
261
262impl PlatformKeyboardLayout for HeadlessKeyboardLayout {
263 fn id(&self) -> &str {
264 "headless.keyboard"
265 }
266
267 fn name(&self) -> &str {
268 "Headless Keyboard"
269 }
270}
271
272impl Platform for HeadlessMetalPlatform {
273 fn background_executor(&self) -> BackgroundExecutor {
274 self.background_executor.clone()
275 }
276
277 fn foreground_executor(&self) -> ForegroundExecutor {
278 self.foreground_executor.clone()
279 }
280
281 fn text_system(&self) -> Arc<dyn PlatformTextSystem> {
282 self.text_system.clone()
283 }
284
285 fn keyboard_layout(&self) -> Box<dyn PlatformKeyboardLayout> {
286 Box::new(HeadlessKeyboardLayout)
287 }
288
289 fn keyboard_mapper(&self) -> Rc<dyn PlatformKeyboardMapper> {
290 Rc::new(DummyKeyboardMapper {})
291 }
292
293 fn on_keyboard_layout_change(&self, _callback: Box<dyn FnMut()>) {}
294
295 fn run(&self, _on_finish_launching: Box<dyn 'static + FnOnce()>) {
296 panic!("HeadlessMetalPlatform::run should not be called - use run_until_parked() instead")
297 }
298
299 fn quit(&self) {}
300
301 fn restart(&self, _binary_path: Option<PathBuf>) {}
302
303 fn activate(&self, _ignoring_other_apps: bool) {}
304
305 fn hide(&self) {}
306
307 fn hide_other_apps(&self) {}
308
309 fn unhide_other_apps(&self) {}
310
311 fn displays(&self) -> Vec<Rc<dyn PlatformDisplay>> {
312 vec![self.active_display.clone()]
313 }
314
315 fn primary_display(&self) -> Option<Rc<dyn PlatformDisplay>> {
316 Some(self.active_display.clone())
317 }
318
319 #[cfg(feature = "screen-capture")]
320 fn is_screen_capture_supported(&self) -> bool {
321 false
322 }
323
324 #[cfg(feature = "screen-capture")]
325 fn screen_capture_sources(
326 &self,
327 ) -> oneshot::Receiver<Result<Vec<Rc<dyn crate::ScreenCaptureSource>>>> {
328 let (tx, rx) = oneshot::channel();
329 tx.send(Ok(Vec::new())).ok();
330 rx
331 }
332
333 fn active_window(&self) -> Option<AnyWindowHandle> {
334 None
335 }
336
337 fn open_window(
338 &self,
339 handle: AnyWindowHandle,
340 options: WindowParams,
341 ) -> Result<Box<dyn PlatformWindow>> {
342 let window = HeadlessMetalWindow::new(
343 handle,
344 options,
345 self.weak.borrow().clone(),
346 self.active_display.clone(),
347 self.renderer_context.clone(),
348 );
349 Ok(Box::new(window))
350 }
351
352 fn window_appearance(&self) -> WindowAppearance {
353 WindowAppearance::Light
354 }
355
356 fn open_url(&self, _url: &str) {}
357
358 fn on_open_urls(&self, _callback: Box<dyn FnMut(Vec<String>)>) {}
359
360 fn register_url_scheme(&self, _url: &str) -> Task<Result<()>> {
361 Task::ready(Ok(()))
362 }
363
364 fn prompt_for_paths(
365 &self,
366 _options: crate::PathPromptOptions,
367 ) -> oneshot::Receiver<Result<Option<Vec<PathBuf>>>> {
368 let (tx, rx) = oneshot::channel();
369 tx.send(Ok(None)).ok();
370 rx
371 }
372
373 fn prompt_for_new_path(
374 &self,
375 _directory: &Path,
376 _suggested_name: Option<&str>,
377 ) -> oneshot::Receiver<Result<Option<PathBuf>>> {
378 let (tx, rx) = oneshot::channel();
379 tx.send(Ok(None)).ok();
380 rx
381 }
382
383 fn can_select_mixed_files_and_dirs(&self) -> bool {
384 true
385 }
386
387 fn reveal_path(&self, _path: &Path) {}
388
389 fn open_with_system(&self, _path: &Path) {}
390
391 fn on_quit(&self, _callback: Box<dyn FnMut()>) {}
392
393 fn on_reopen(&self, _callback: Box<dyn FnMut()>) {}
394
395 fn on_app_menu_action(&self, _callback: Box<dyn FnMut(&dyn crate::Action)>) {}
396
397 fn on_will_open_app_menu(&self, _callback: Box<dyn FnMut()>) {}
398
399 fn on_validate_app_menu_command(&self, _callback: Box<dyn FnMut(&dyn crate::Action) -> bool>) {}
400
401 fn app_path(&self) -> Result<PathBuf> {
402 Ok(PathBuf::from("/dev/null"))
403 }
404
405 fn path_for_auxiliary_executable(&self, _name: &str) -> Result<PathBuf> {
406 Err(anyhow::anyhow!("not supported in headless mode"))
407 }
408
409 fn set_menus(&self, _menus: Vec<crate::Menu>, _keymap: &Keymap) {}
410
411 fn set_dock_menu(&self, _menu: Vec<crate::MenuItem>, _keymap: &Keymap) {}
412
413 fn add_recent_document(&self, _path: &Path) {}
414
415 fn set_cursor_style(&self, style: CursorStyle) {
416 *self.active_cursor.lock() = style;
417 }
418
419 fn should_auto_hide_scrollbars(&self) -> bool {
420 false
421 }
422
423 fn write_to_clipboard(&self, item: ClipboardItem) {
424 *self.clipboard.lock() = Some(item);
425 }
426
427 fn read_from_clipboard(&self) -> Option<ClipboardItem> {
428 self.clipboard.lock().clone()
429 }
430
431 fn read_from_find_pasteboard(&self) -> Option<ClipboardItem> {
432 self.find_pasteboard.lock().clone()
433 }
434
435 fn write_to_find_pasteboard(&self, item: ClipboardItem) {
436 *self.find_pasteboard.lock() = Some(item);
437 }
438
439 fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Task<Result<()>> {
440 Task::ready(Ok(()))
441 }
442
443 fn read_credentials(&self, _url: &str) -> Task<Result<Option<(String, Vec<u8>)>>> {
444 Task::ready(Ok(None))
445 }
446
447 fn delete_credentials(&self, _url: &str) -> Task<Result<()>> {
448 Task::ready(Ok(()))
449 }
450}
451
452// ─────────────────────────────────────────────────────────────────────────────
453// Headless Metal Window
454// ─────────────────────────────────────────────────────────────────────────────
455
456struct HeadlessMetalWindowState {
457 bounds: Bounds<Pixels>,
458 handle: AnyWindowHandle,
459 display: Rc<dyn PlatformDisplay>,
460 #[allow(dead_code)]
461 platform: Weak<HeadlessMetalPlatform>,
462 renderer: MetalRenderer,
463 sprite_atlas: Arc<dyn PlatformAtlas>,
464 last_image: Option<RgbaImage>,
465 title: Option<String>,
466 edited: bool,
467 input_handler: Option<PlatformInputHandler>,
468 should_close_handler: Option<Box<dyn FnMut() -> bool>>,
469 input_callback: Option<Box<dyn FnMut(PlatformInput) -> DispatchEventResult>>,
470 active_status_change_callback: Option<Box<dyn FnMut(bool)>>,
471 hover_status_change_callback: Option<Box<dyn FnMut(bool)>>,
472 resize_callback: Option<Box<dyn FnMut(Size<Pixels>, f32)>>,
473 moved_callback: Option<Box<dyn FnMut()>>,
474 hit_test_window_control_callback: Option<Box<dyn FnMut() -> Option<WindowControlArea>>>,
475}
476
477/// A headless window that renders to Metal textures without requiring a display.
478pub struct HeadlessMetalWindow(Mutex<HeadlessMetalWindowState>);
479
480impl HeadlessMetalWindow {
481 fn new(
482 handle: AnyWindowHandle,
483 params: WindowParams,
484 platform: Weak<HeadlessMetalPlatform>,
485 display: Rc<dyn PlatformDisplay>,
486 renderer_context: Arc<Mutex<InstanceBufferPool>>,
487 ) -> Self {
488 let renderer = MetalRenderer::new_headless(renderer_context);
489 let sprite_atlas = renderer.sprite_atlas().clone();
490
491 Self(Mutex::new(HeadlessMetalWindowState {
492 bounds: params.bounds,
493 handle,
494 display,
495 platform,
496 renderer,
497 sprite_atlas,
498 last_image: None,
499 title: None,
500 edited: false,
501 input_handler: None,
502 should_close_handler: None,
503 input_callback: None,
504 active_status_change_callback: None,
505 hover_status_change_callback: None,
506 resize_callback: None,
507 moved_callback: None,
508 hit_test_window_control_callback: None,
509 }))
510 }
511
512 /// Returns the last rendered image.
513 pub fn last_rendered_image(&self) -> Result<RgbaImage> {
514 let state = self.0.lock();
515 state
516 .last_image
517 .clone()
518 .ok_or_else(|| anyhow::anyhow!("No scene has been drawn yet"))
519 }
520}
521
522impl HasWindowHandle for HeadlessMetalWindow {
523 fn window_handle(
524 &self,
525 ) -> std::result::Result<raw_window_handle::WindowHandle<'_>, raw_window_handle::HandleError>
526 {
527 Err(raw_window_handle::HandleError::Unavailable)
528 }
529}
530
531impl HasDisplayHandle for HeadlessMetalWindow {
532 fn display_handle(
533 &self,
534 ) -> std::result::Result<raw_window_handle::DisplayHandle<'_>, raw_window_handle::HandleError>
535 {
536 Err(raw_window_handle::HandleError::Unavailable)
537 }
538}
539
540impl PlatformWindow for HeadlessMetalWindow {
541 fn bounds(&self) -> Bounds<Pixels> {
542 self.0.lock().bounds
543 }
544
545 fn window_bounds(&self) -> WindowBounds {
546 WindowBounds::Windowed(self.bounds())
547 }
548
549 fn is_maximized(&self) -> bool {
550 false
551 }
552
553 fn content_size(&self) -> Size<Pixels> {
554 self.bounds().size
555 }
556
557 fn resize(&mut self, size: Size<Pixels>) {
558 self.0.lock().bounds.size = size;
559 }
560
561 fn scale_factor(&self) -> f32 {
562 2.0 // Retina
563 }
564
565 fn appearance(&self) -> WindowAppearance {
566 WindowAppearance::Light
567 }
568
569 fn display(&self) -> Option<Rc<dyn PlatformDisplay>> {
570 Some(self.0.lock().display.clone())
571 }
572
573 fn mouse_position(&self) -> Point<Pixels> {
574 Point::default()
575 }
576
577 fn modifiers(&self) -> crate::Modifiers {
578 crate::Modifiers::default()
579 }
580
581 fn capslock(&self) -> crate::Capslock {
582 crate::Capslock::default()
583 }
584
585 fn set_input_handler(&mut self, input_handler: PlatformInputHandler) {
586 self.0.lock().input_handler = Some(input_handler);
587 }
588
589 fn take_input_handler(&mut self) -> Option<PlatformInputHandler> {
590 self.0.lock().input_handler.take()
591 }
592
593 fn prompt(
594 &self,
595 _level: PromptLevel,
596 _msg: &str,
597 _detail: Option<&str>,
598 _answers: &[PromptButton],
599 ) -> Option<oneshot::Receiver<usize>> {
600 None
601 }
602
603 fn activate(&self) {}
604
605 fn is_active(&self) -> bool {
606 false
607 }
608
609 fn is_hovered(&self) -> bool {
610 false
611 }
612
613 fn set_title(&mut self, title: &str) {
614 self.0.lock().title = Some(title.to_string());
615 }
616
617 fn set_app_id(&mut self, _app_id: &str) {}
618
619 fn set_edited(&mut self, edited: bool) {
620 self.0.lock().edited = edited;
621 }
622
623 fn background_appearance(&self) -> WindowBackgroundAppearance {
624 WindowBackgroundAppearance::Opaque
625 }
626
627 fn is_subpixel_rendering_supported(&self) -> bool {
628 false
629 }
630
631 fn set_background_appearance(&self, _background_appearance: WindowBackgroundAppearance) {}
632
633 fn show_character_palette(&self) {}
634
635 fn minimize(&self) {}
636
637 fn zoom(&self) {}
638
639 fn toggle_fullscreen(&self) {}
640
641 fn is_fullscreen(&self) -> bool {
642 false
643 }
644
645 fn on_request_frame(&self, _callback: Box<dyn FnMut(RequestFrameOptions)>) {}
646
647 fn on_input(&self, callback: Box<dyn FnMut(PlatformInput) -> DispatchEventResult>) {
648 self.0.lock().input_callback = Some(callback);
649 }
650
651 fn on_active_status_change(&self, callback: Box<dyn FnMut(bool)>) {
652 self.0.lock().active_status_change_callback = Some(callback);
653 }
654
655 fn on_hover_status_change(&self, callback: Box<dyn FnMut(bool)>) {
656 self.0.lock().hover_status_change_callback = Some(callback);
657 }
658
659 fn on_resize(&self, callback: Box<dyn FnMut(Size<Pixels>, f32)>) {
660 self.0.lock().resize_callback = Some(callback);
661 }
662
663 fn on_moved(&self, callback: Box<dyn FnMut()>) {
664 self.0.lock().moved_callback = Some(callback);
665 }
666
667 fn on_should_close(&self, callback: Box<dyn FnMut() -> bool>) {
668 self.0.lock().should_close_handler = Some(callback);
669 }
670
671 fn on_close(&self, _callback: Box<dyn FnOnce()>) {}
672
673 fn on_hit_test_window_control(&self, callback: Box<dyn FnMut() -> Option<WindowControlArea>>) {
674 self.0.lock().hit_test_window_control_callback = Some(callback);
675 }
676
677 fn on_appearance_changed(&self, _callback: Box<dyn FnMut()>) {}
678
679 fn draw(&self, scene: &Scene) {
680 let mut state = self.0.lock();
681 let size = state.bounds.size;
682 let scale_factor = 2.0; // Retina
683 let device_size = size.to_device_pixels(scale_factor);
684
685 // Render immediately and store the result
686 match state.renderer.render_scene_to_image(scene, device_size) {
687 Ok(image) => {
688 state.last_image = Some(image);
689 }
690 Err(e) => {
691 log::error!("Failed to render scene to image: {}", e);
692 }
693 }
694 }
695
696 fn render_to_image(&self, scene: &Scene) -> Result<RgbaImage> {
697 let mut state = self.0.lock();
698 let size = state.bounds.size;
699 let scale_factor = 2.0; // Retina
700 let device_size = size.to_device_pixels(scale_factor);
701 state.renderer.render_scene_to_image(scene, device_size)
702 }
703
704 fn sprite_atlas(&self) -> sync::Arc<dyn PlatformAtlas> {
705 self.0.lock().sprite_atlas.clone()
706 }
707
708 fn show_window_menu(&self, _position: Point<Pixels>) {}
709
710 fn start_window_move(&self) {}
711
712 fn update_ime_position(&self, _bounds: Bounds<Pixels>) {}
713
714 fn gpu_specs(&self) -> Option<GpuSpecs> {
715 None
716 }
717}
718
719// ─────────────────────────────────────────────────────────────────────────────
720// Headless Atlas (for sprite storage)
721// ─────────────────────────────────────────────────────────────────────────────
722
723struct HeadlessAtlasState {
724 next_id: u32,
725 tiles: HashMap<AtlasKey, AtlasTile>,
726}
727
728struct HeadlessAtlas(Mutex<HeadlessAtlasState>);
729
730impl HeadlessAtlas {
731 #[allow(dead_code)]
732 fn new() -> Self {
733 Self(Mutex::new(HeadlessAtlasState {
734 next_id: 0,
735 tiles: HashMap::default(),
736 }))
737 }
738}
739
740impl PlatformAtlas for HeadlessAtlas {
741 fn get_or_insert_with<'a>(
742 &self,
743 key: &AtlasKey,
744 build: &mut dyn FnMut() -> Result<Option<(Size<DevicePixels>, std::borrow::Cow<'a, [u8]>)>>,
745 ) -> Result<Option<AtlasTile>> {
746 let mut state = self.0.lock();
747 if let Some(tile) = state.tiles.get(key) {
748 return Ok(Some(tile.clone()));
749 }
750
751 let Some((size, _data)) = build()? else {
752 return Ok(None);
753 };
754
755 let id = state.next_id;
756 state.next_id += 1;
757
758 let tile = AtlasTile {
759 texture_id: AtlasTextureId {
760 index: 0,
761 kind: crate::AtlasTextureKind::Polychrome,
762 },
763 tile_id: TileId(id),
764 padding: 0,
765 bounds: Bounds {
766 origin: Point::default(),
767 size,
768 },
769 };
770
771 state.tiles.insert(key.clone(), tile.clone());
772 Ok(Some(tile))
773 }
774
775 fn remove(&self, key: &AtlasKey) {
776 self.0.lock().tiles.remove(key);
777 }
778}
779
780// ─────────────────────────────────────────────────────────────────────────────
781// Tests
782// ─────────────────────────────────────────────────────────────────────────────
783
784#[cfg(test)]
785mod tests {
786 use super::*;
787
788 #[test]
789 fn test_headless_platform_creation() {
790 // This should not panic or require main thread
791 let platform = HeadlessMetalPlatform::new(42);
792 assert!(!platform.displays().is_empty());
793 }
794
795 #[test]
796 fn test_headless_clipboard() {
797 let platform = HeadlessMetalPlatform::new(42);
798
799 // Write to clipboard
800 let item = ClipboardItem::new_string("test".to_string());
801 platform.write_to_clipboard(item);
802
803 // Read from clipboard
804 let read = platform.read_from_clipboard();
805 assert!(read.is_some());
806 }
807
808 #[test]
809 fn test_headless_app_context_creation() {
810 // This should not panic or require main thread
811 let _cx = HeadlessMetalAppContext::new();
812 }
813}