diff --git a/crates/gpui_linux/src/linux/headless.rs b/crates/gpui_linux/src/linux/headless.rs index 2237aeb194..6667a3b5a1 100644 --- a/crates/gpui_linux/src/linux/headless.rs +++ b/crates/gpui_linux/src/linux/headless.rs @@ -1,3 +1,4 @@ mod client; +mod window; pub(crate) use client::*; diff --git a/crates/gpui_linux/src/linux/headless/client.rs b/crates/gpui_linux/src/linux/headless/client.rs index e127da1f2f..06da3cbd94 100644 --- a/crates/gpui_linux/src/linux/headless/client.rs +++ b/crates/gpui_linux/src/linux/headless/client.rs @@ -4,6 +4,7 @@ use std::rc::Rc; use calloop::{EventLoop, LoopHandle}; use gpui_util::ResultExt; +use crate::linux::headless::window::{HeadlessDisplay, HeadlessWindow}; use crate::linux::{LinuxClient, LinuxCommon, LinuxKeyboardLayout}; use gpui::{ AnyWindowHandle, CursorStyle, DisplayId, PlatformDisplay, PlatformKeyboardLayout, @@ -14,6 +15,7 @@ pub struct HeadlessClientState { pub(crate) _loop_handle: LoopHandle<'static, HeadlessClient>, pub(crate) event_loop: Option>, pub(crate) common: LinuxCommon, + pub(crate) display: Rc, } #[derive(Clone)] @@ -47,6 +49,7 @@ impl HeadlessClient { event_loop: Some(event_loop), _loop_handle: handle, common, + display: Rc::new(HeadlessDisplay::new()), }))) } } @@ -61,15 +64,16 @@ impl LinuxClient for HeadlessClient { } fn displays(&self) -> Vec> { - vec![] + vec![self.0.borrow().display.clone()] } fn primary_display(&self) -> Option> { - None + Some(self.0.borrow().display.clone()) } - fn display(&self, _id: DisplayId) -> Option> { - None + fn display(&self, id: DisplayId) -> Option> { + let display = self.0.borrow().display.clone(); + (display.id() == id).then_some(display) } #[cfg(feature = "screen-capture")] @@ -96,9 +100,12 @@ impl LinuxClient for HeadlessClient { fn open_window( &self, _handle: AnyWindowHandle, - _params: WindowParams, + params: WindowParams, ) -> anyhow::Result> { - anyhow::bail!("neither DISPLAY nor WAYLAND_DISPLAY is set. You can run in headless mode"); + Ok(Box::new(HeadlessWindow::new( + params, + self.0.borrow().display.clone(), + ))) } fn compositor_name(&self) -> &'static str { diff --git a/crates/gpui_linux/src/linux/headless/window.rs b/crates/gpui_linux/src/linux/headless/window.rs new file mode 100644 index 0000000000..e41b09723d --- /dev/null +++ b/crates/gpui_linux/src/linux/headless/window.rs @@ -0,0 +1,287 @@ +//! Windows for the headless platform client. +//! +//! A headless window has no compositor surface and no GPU: layout, text +//! shaping, and entity plumbing run normally, `draw` discards the scene, and +//! the sprite atlas hands out tiles without uploading pixels (mirroring +//! GPUI's `TestWindow`/`TestAtlas`). This lets command-line tools drive real +//! `Window`-based code paths without a display server. + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; + +use collections::HashMap; +use parking_lot::Mutex; +use uuid::Uuid; + +use gpui::{ + AtlasKey, AtlasTextureId, AtlasTile, Bounds, Capslock, DevicePixels, DispatchEventResult, + DisplayId, GpuSpecs, Modifiers, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, + PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, + Scene, Size, TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds, + WindowControlArea, WindowParams, px, +}; + +#[derive(Debug)] +pub(crate) struct HeadlessDisplay { + bounds: Bounds, +} + +impl HeadlessDisplay { + pub(crate) fn new() -> Self { + Self { + bounds: Bounds::from_corners(Point::default(), Point::new(px(1920.), px(1080.))), + } + } +} + +impl PlatformDisplay for HeadlessDisplay { + fn id(&self) -> DisplayId { + DisplayId::new(0) + } + + fn uuid(&self) -> anyhow::Result { + // Stable identity: there is exactly one headless display. + Ok(Uuid::nil()) + } + + fn bounds(&self) -> Bounds { + self.bounds + } +} + +struct HeadlessWindowState { + bounds: Bounds, + display: Rc, + input_handler: Option, + title: Option, + is_fullscreen: bool, +} + +pub(crate) struct HeadlessWindow(Rc>); + +impl raw_window_handle::HasWindowHandle for HeadlessWindow { + fn window_handle( + &self, + ) -> Result, raw_window_handle::HandleError> { + // Headless windows are not backed by a native window. + Err(raw_window_handle::HandleError::NotSupported) + } +} + +impl raw_window_handle::HasDisplayHandle for HeadlessWindow { + fn display_handle( + &self, + ) -> Result, raw_window_handle::HandleError> { + Err(raw_window_handle::HandleError::NotSupported) + } +} + +impl HeadlessWindow { + pub(crate) fn new(params: WindowParams, display: Rc) -> Self { + Self(Rc::new(RefCell::new(HeadlessWindowState { + bounds: params.bounds, + display, + input_handler: None, + title: None, + is_fullscreen: false, + }))) + } +} + +impl PlatformWindow for HeadlessWindow { + fn bounds(&self) -> Bounds { + self.0.borrow().bounds + } + + fn is_maximized(&self) -> bool { + false + } + + fn window_bounds(&self) -> WindowBounds { + WindowBounds::Windowed(self.bounds()) + } + + fn content_size(&self) -> Size { + self.bounds().size + } + + fn resize(&mut self, size: Size) { + self.0.borrow_mut().bounds.size = size; + } + + fn scale_factor(&self) -> f32 { + 1.0 + } + + fn appearance(&self) -> WindowAppearance { + WindowAppearance::Dark + } + + fn display(&self) -> Option> { + Some(self.0.borrow().display.clone()) + } + + fn mouse_position(&self) -> Point { + Point::default() + } + + fn modifiers(&self) -> Modifiers { + Modifiers::default() + } + + fn capslock(&self) -> Capslock { + Capslock::default() + } + + fn set_input_handler(&mut self, input_handler: PlatformInputHandler) { + self.0.borrow_mut().input_handler = Some(input_handler); + } + + fn take_input_handler(&mut self) -> Option { + self.0.borrow_mut().input_handler.take() + } + + fn prompt( + &self, + _level: PromptLevel, + _msg: &str, + _detail: Option<&str>, + _answers: &[PromptButton], + ) -> Option> { + // Fall back to GPUI's rendered prompts. + None + } + + fn activate(&self) {} + + fn is_active(&self) -> bool { + false + } + + fn is_hovered(&self) -> bool { + false + } + + fn background_appearance(&self) -> WindowBackgroundAppearance { + WindowBackgroundAppearance::Opaque + } + + fn set_title(&mut self, title: &str) { + self.0.borrow_mut().title = Some(title.to_owned()); + } + + fn get_title(&self) -> String { + self.0.borrow().title.clone().unwrap_or_default() + } + + fn set_background_appearance(&self, _background: WindowBackgroundAppearance) {} + + fn minimize(&self) {} + + fn zoom(&self) {} + + fn toggle_fullscreen(&self) { + let mut state = self.0.borrow_mut(); + state.is_fullscreen = !state.is_fullscreen; + } + + fn is_fullscreen(&self) -> bool { + self.0.borrow().is_fullscreen + } + + // No compositor drives a frame loop, so frame and status callbacks are + // dropped: anything that awaits a frame will never resolve headlessly. + fn on_request_frame(&self, _callback: Box) {} + + fn on_input(&self, _callback: Box DispatchEventResult>) {} + + fn on_active_status_change(&self, _callback: Box) {} + + fn on_hover_status_change(&self, _callback: Box) {} + + fn on_resize(&self, _callback: Box, f32)>) {} + + fn on_moved(&self, _callback: Box) {} + + fn on_should_close(&self, _callback: Box bool>) {} + + fn on_close(&self, _callback: Box) {} + + fn on_hit_test_window_control(&self, _callback: Box Option>) { + } + + fn on_appearance_changed(&self, _callback: Box) {} + + fn draw(&self, _scene: &Scene) {} + + fn sprite_atlas(&self) -> Arc { + Arc::new(HeadlessAtlas::default()) + } + + fn is_subpixel_rendering_supported(&self) -> bool { + false + } + + fn update_ime_position(&self, _bounds: Bounds) {} + + fn gpu_specs(&self) -> Option { + None + } +} + +/// Allocates atlas tiles without uploading pixels, so glyph and sprite +/// painting completes headlessly. +#[derive(Default)] +struct HeadlessAtlas(Mutex); + +#[derive(Default)] +struct HeadlessAtlasState { + next_id: u32, + tiles: HashMap, +} + +impl PlatformAtlas for HeadlessAtlas { + fn get_or_insert_with<'a>( + &self, + key: &AtlasKey, + build: &mut dyn FnMut() -> anyhow::Result< + Option<(Size, std::borrow::Cow<'a, [u8]>)>, + >, + ) -> anyhow::Result> { + { + let state = self.0.lock(); + if let Some(&tile) = state.tiles.get(key) { + return Ok(Some(tile)); + } + } + + let Some((size, _)) = build()? else { + return Ok(None); + }; + + let mut state = self.0.lock(); + state.next_id += 1; + let texture_id = state.next_id; + state.next_id += 1; + let tile_id = state.next_id; + let tile = AtlasTile { + texture_id: AtlasTextureId { + index: texture_id, + kind: key.texture_kind(), + }, + tile_id: TileId(tile_id), + padding: 0, + bounds: Bounds { + origin: Point::default(), + size, + }, + }; + state.tiles.insert(key.clone(), tile); + Ok(Some(tile)) + } + + fn remove(&self, key: &AtlasKey) { + self.0.lock().tiles.remove(key); + } +}