headless_metal.rs

  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}