diff --git a/Cargo.lock b/Cargo.lock index e76b343b412f94..a6e087d146530a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7712,6 +7712,27 @@ dependencies = [ "smol_str", ] +[[package]] +name = "gpui_terminal" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "flate2", + "futures 0.3.32", + "gpui", + "gpui_macos", + "gpui_wgpu", + "libc", + "log", + "metal", + "objc", + "parking_lot", + "raw-window-handle", + "smol", + "uuid", +] + [[package]] name = "gpui_tokio" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index df1de093d38904..0cec65521c0c95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,6 +95,7 @@ members = [ "crates/gpui_macros", "crates/gpui_platform", "crates/gpui_shared_string", + "crates/gpui_terminal", "crates/gpui_tokio", "crates/gpui_util", "crates/gpui_web", @@ -361,6 +362,7 @@ gpui_macos = { path = "crates/gpui_macos", default-features = false } gpui_macros = { path = "crates/gpui_macros" } gpui_platform = { path = "crates/gpui_platform", default-features = false } gpui_shared_string = { path = "crates/gpui_shared_string" } +gpui_terminal = { path = "crates/gpui_terminal" } gpui_tokio = { path = "crates/gpui_tokio" } gpui_util = { path = "crates/gpui_util" } gpui_web = { path = "crates/gpui_web" } diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index 927f6344e6ab70..f2b67517a5261f 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -38,13 +38,6 @@ mod platform; pub mod prelude; /// Profiling utilities for task, frame, and thread performance tracking. pub mod profiler; -#[cfg(any( - test, - target_os = "windows", - target_os = "linux", - target_family = "wasm", - feature = "test-support" -))] #[expect(missing_docs)] pub mod queue; mod scene; @@ -145,7 +138,6 @@ pub use keymap::*; pub use path_builder::*; pub use platform::*; pub use profiler::*; -#[cfg(any(target_os = "windows", target_os = "linux", target_family = "wasm"))] pub use queue::{PriorityQueueReceiver, PriorityQueueSender}; pub use refineable::*; pub use scene::*; diff --git a/crates/gpui_apple/src/metal_renderer.rs b/crates/gpui_apple/src/metal_renderer.rs index e4411d2e92c824..80f1ecf01045b2 100644 --- a/crates/gpui_apple/src/metal_renderer.rs +++ b/crates/gpui_apple/src/metal_renderer.rs @@ -138,6 +138,10 @@ pub struct MetalRenderer { /// rendering headlessly without reading pixels back. #[cfg(any(test, feature = "test-support"))] headless_render_target: Option, + /// CPU-readable render target reused across `render_scene_to_pixels` calls. + readback_target: Option, + /// Format of every render target and pipeline output. + pixel_format: MTLPixelFormat, } #[repr(C)] @@ -173,17 +177,28 @@ impl MetalRenderer { ]; } - Self::new_internal(device, Some(layer), !transparent, instance_buffer_pool) + Self::new_internal( + device, + Some(layer), + !transparent, + instance_buffer_pool, + MTLPixelFormat::BGRA8Unorm, + ) } /// Creates a new headless MetalRenderer for offscreen rendering without a window. /// /// This renderer can render scenes to images without requiring a CAMetalLayer, - /// window, or AppKit. Use `render_scene_to_image()` to render scenes. - #[cfg(any(test, feature = "test-support"))] - pub fn new_headless(instance_buffer_pool: Arc>) -> Self { + /// window, or AppKit. Use `render_scene_to_pixels()` to render scenes. + /// + /// `pixel_format` is the format of the produced pixels: `BGRA8Unorm` or + /// `RGBA8Unorm`. Rendering in the consumer's format avoids a CPU swizzle. + pub fn new_headless( + instance_buffer_pool: Arc>, + pixel_format: MTLPixelFormat, + ) -> Self { let device = Self::create_device(); - Self::new_internal(device, None, true, instance_buffer_pool) + Self::new_internal(device, None, true, instance_buffer_pool, pixel_format) } fn create_device() -> metal::Device { @@ -213,6 +228,7 @@ impl MetalRenderer { layer: Option, opaque: bool, instance_buffer_pool: Arc>, + pixel_format: MTLPixelFormat, ) -> Self { #[cfg(feature = "runtime_shaders")] let library = device @@ -264,7 +280,7 @@ impl MetalRenderer { "paths_rasterization", "path_rasterization_vertex", "path_rasterization_fragment", - MTLPixelFormat::BGRA8Unorm, + pixel_format, PATH_SAMPLE_COUNT, ); let path_sprites_pipeline_state = build_path_sprite_pipeline_state( @@ -273,7 +289,7 @@ impl MetalRenderer { "path_sprites", "path_sprite_vertex", "path_sprite_fragment", - MTLPixelFormat::BGRA8Unorm, + pixel_format, ); let shadows_pipeline_state = build_pipeline_state( &device, @@ -281,7 +297,7 @@ impl MetalRenderer { "shadows", "shadow_vertex", "shadow_fragment", - MTLPixelFormat::BGRA8Unorm, + pixel_format, ); let quads_pipeline_state = build_pipeline_state( &device, @@ -289,7 +305,7 @@ impl MetalRenderer { "quads", "quad_vertex", "quad_fragment", - MTLPixelFormat::BGRA8Unorm, + pixel_format, ); let underlines_pipeline_state = build_pipeline_state( &device, @@ -297,7 +313,7 @@ impl MetalRenderer { "underlines", "underline_vertex", "underline_fragment", - MTLPixelFormat::BGRA8Unorm, + pixel_format, ); let monochrome_sprites_pipeline_state = build_pipeline_state( &device, @@ -305,7 +321,7 @@ impl MetalRenderer { "monochrome_sprites", "monochrome_sprite_vertex", "monochrome_sprite_fragment", - MTLPixelFormat::BGRA8Unorm, + pixel_format, ); let polychrome_sprites_pipeline_state = build_pipeline_state( &device, @@ -313,7 +329,7 @@ impl MetalRenderer { "polychrome_sprites", "polychrome_sprite_vertex", "polychrome_sprite_fragment", - MTLPixelFormat::BGRA8Unorm, + pixel_format, ); let surfaces_pipeline_state = build_pipeline_state( &device, @@ -321,7 +337,7 @@ impl MetalRenderer { "surfaces", "surface_vertex", "surface_fragment", - MTLPixelFormat::BGRA8Unorm, + pixel_format, ); let command_queue = device.new_command_queue(); @@ -354,6 +370,8 @@ impl MetalRenderer { path_sample_count: PATH_SAMPLE_COUNT, #[cfg(any(test, feature = "test-support"))] headless_render_target: None, + readback_target: None, + pixel_format, } } @@ -408,7 +426,7 @@ impl MetalRenderer { let texture_descriptor = metal::TextureDescriptor::new(); texture_descriptor.set_width(size.width.0 as u64); texture_descriptor.set_height(size.height.0 as u64); - texture_descriptor.set_pixel_format(metal::MTLPixelFormat::BGRA8Unorm); + texture_descriptor.set_pixel_format(self.pixel_format); texture_descriptor.set_storage_mode(metal::MTLStorageMode::Private); texture_descriptor .set_usage(metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead); @@ -648,6 +666,78 @@ impl MetalRenderer { Ok(()) } + /// Renders a scene offscreen and copies the result into `pixels` as + /// tightly packed rows in the renderer's pixel format, reusing the render + /// target between frames. `pixels` must be `width * height * 4` bytes. + /// + /// This is the presentation path for hosts that display frames themselves, + /// such as a terminal, rather than through a `CAMetalLayer`. + pub fn render_scene_to_pixels( + &mut self, + scene: &Scene, + size: Size, + pixels: &mut [u8], + ) -> Result<()> { + if size.width.0 <= 0 || size.height.0 <= 0 { + anyhow::bail!("Invalid size for render_scene_to_pixels: {:?}", size); + } + let bytes_per_row = size.width.0 as usize * 4; + anyhow::ensure!( + pixels.len() == bytes_per_row * size.height.0 as usize, + "pixel buffer does not match {size:?}" + ); + + let needs_new_target = self.readback_target.as_ref().is_none_or(|texture| { + texture.width() != size.width.0 as u64 || texture.height() != size.height.0 as u64 + }); + if needs_new_target { + self.update_path_intermediate_textures(size); + let texture_descriptor = metal::TextureDescriptor::new(); + texture_descriptor.set_width(size.width.0 as u64); + texture_descriptor.set_height(size.height.0 as u64); + texture_descriptor.set_pixel_format(self.pixel_format); + texture_descriptor.set_usage( + metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead, + ); + // Shared storage lets the CPU read the texture without a blit on + // unified memory. Discrete GPUs cannot share textures. + texture_descriptor.set_storage_mode(if self.is_unified_memory { + metal::MTLStorageMode::Shared + } else { + metal::MTLStorageMode::Managed + }); + self.readback_target = Some(self.device.new_texture(&texture_descriptor)); + } + let target_texture = self + .readback_target + .clone() + .context("readback target was just created")?; + + let command_buffer = self.render_frame(scene, &target_texture, size)?; + if !self.is_unified_memory { + let blit = command_buffer.new_blit_command_encoder(); + blit.synchronize_resource(&target_texture); + blit.end_encoding(); + } + command_buffer.commit(); + command_buffer.wait_until_completed(); + + target_texture.get_bytes( + pixels.as_mut_ptr() as *mut c_void, + bytes_per_row as u64, + metal::MTLRegion { + origin: metal::MTLOrigin { x: 0, y: 0, z: 0 }, + size: metal::MTLSize { + width: size.width.0 as u64, + height: size.height.0 as u64, + depth: 1, + }, + }, + 0, + ); + Ok(()) + } + fn draw_primitives_to_texture( &mut self, scene: &Scene, @@ -1604,7 +1694,8 @@ pub struct MetalHeadlessRenderer { impl MetalHeadlessRenderer { pub fn new() -> Self { let instance_buffer_pool = Arc::new(Mutex::new(InstanceBufferPool::default())); - let renderer = MetalRenderer::new_headless(instance_buffer_pool); + let renderer = + MetalRenderer::new_headless(instance_buffer_pool, MTLPixelFormat::BGRA8Unorm); Self { renderer } } } diff --git a/crates/gpui_macos/src/gpui_macos.rs b/crates/gpui_macos/src/gpui_macos.rs index 9088246f6bc81b..41984dc9ce009c 100644 --- a/crates/gpui_macos/src/gpui_macos.rs +++ b/crates/gpui_macos/src/gpui_macos.rs @@ -18,7 +18,9 @@ mod screen_capture; use gpui_apple::metal_renderer as renderer; pub mod metal_renderer { - pub use gpui_apple::metal_renderer::{PathRasterizationVertex, PathSprite, SurfaceBounds}; + pub use gpui_apple::metal_renderer::{ + InstanceBufferPool, MetalRenderer, PathRasterizationVertex, PathSprite, SurfaceBounds, + }; #[cfg(any(test, feature = "test-support"))] pub use gpui_apple::metal_renderer::MetalHeadlessRenderer; @@ -53,7 +55,7 @@ pub(crate) use platform::*; pub(crate) use window::*; #[cfg(feature = "font-kit")] -pub(crate) use text_system::*; +pub use text_system::MacTextSystem; pub use display_link::set_frame_wake_hook; pub use platform::MacPlatform; diff --git a/crates/gpui_terminal/Cargo.toml b/crates/gpui_terminal/Cargo.toml new file mode 100644 index 00000000000000..c2277931ef1fd0 --- /dev/null +++ b/crates/gpui_terminal/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "gpui_terminal" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "Apache-2.0" + +[lints] +workspace = true + +[lib] +path = "src/gpui_terminal.rs" + +[target.'cfg(any(target_os = "macos", target_os = "linux", target_os = "freebsd"))'.dependencies] +anyhow.workspace = true +base64.workspace = true +flate2 = "1.1.8" +futures.workspace = true +gpui.workspace = true +libc.workspace = true +log.workspace = true +parking_lot.workspace = true +raw-window-handle.workspace = true +smol.workspace = true +uuid.workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +gpui_macos = { workspace = true, features = ["font-kit"] } +metal.workspace = true +objc.workspace = true + +[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] +gpui_wgpu = { workspace = true, features = ["font-kit"] } + +[[example]] +name = "terminal_hello" +path = "examples/terminal_hello.rs" diff --git a/crates/gpui_terminal/LICENSE-APACHE b/crates/gpui_terminal/LICENSE-APACHE new file mode 120000 index 00000000000000..1cd601d0a3affa --- /dev/null +++ b/crates/gpui_terminal/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/gpui_terminal/examples/terminal_hello.rs b/crates/gpui_terminal/examples/terminal_hello.rs new file mode 100644 index 00000000000000..527dcb15218e28 --- /dev/null +++ b/crates/gpui_terminal/examples/terminal_hello.rs @@ -0,0 +1,121 @@ +//! Run inside kitty or Ghostty: +//! +//! ```sh +//! cargo run -p gpui_terminal --example terminal_hello +//! ``` +//! +//! Press ctrl-c to quit. + +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "freebsd"))] +mod app { + use gpui::{ + App, Context, FocusHandle, MouseMoveEvent, Point, Window, WindowOptions, div, prelude::*, + px, rgb, + }; + + struct Hello { + focus_handle: FocusHandle, + clicks: usize, + mouse: Point, + last_key: String, + } + + impl Render for Hello { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let size = window.viewport_size(); + div() + .id("root") + .track_focus(&self.focus_handle) + .size_full() + .flex() + .flex_col() + .gap_4() + .p_6() + .bg(rgb(0x1e1e2e)) + .text_color(rgb(0xcdd6f4)) + .text_xl() + .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _, cx| { + this.mouse = event.position; + cx.notify(); + })) + .on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, _, cx| { + this.last_key = event.keystroke.unparse(); + cx.notify(); + })) + .child(format!( + "GPUI in a terminal. {}x{} px, mouse {:.0},{:.0}, last key {:?}", + size.width.as_f32(), + size.height.as_f32(), + self.mouse.x.as_f32(), + self.mouse.y.as_f32(), + self.last_key, + )) + .child( + div() + .id("button") + .px_4() + .py_2() + .w(px(220.)) + .rounded_lg() + .bg(rgb(0x45475a)) + .hover(|style| style.bg(rgb(0x89b4fa)).text_color(rgb(0x1e1e2e))) + .active(|style| style.bg(rgb(0xf38ba8))) + .cursor_pointer() + .on_click(cx.listener(|this, _, _, cx| { + this.clicks += 1; + cx.notify(); + })) + .child(format!("Clicked {} times", self.clicks)), + ) + .child( + div() + .id("list") + .flex_1() + .overflow_y_scroll() + .rounded_lg() + .border_1() + .border_color(rgb(0x585b70)) + .children((0..200).map(|index| { + div() + .id(index) + .px_3() + .py_1() + .hover(|style| style.bg(rgb(0x313244))) + .child(format!("Row {index}")) + })), + ) + } + } + + pub fn main() -> anyhow::Result<()> { + gpui_terminal::application()?.run(|cx: &mut App| { + let window = cx.open_window(WindowOptions::default(), |window, cx| { + cx.new(|cx| { + let focus_handle = cx.focus_handle(); + window.focus(&focus_handle, cx); + Hello { + focus_handle, + clicks: 0, + mouse: Point::default(), + last_key: String::new(), + } + }) + }); + if let Err(error) = window { + log::error!("failed to open the terminal window: {error:#}"); + cx.quit(); + } + }); + Ok(()) + } +} + +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "freebsd"))] +fn main() -> anyhow::Result<()> { + app::main() +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "freebsd")))] +fn main() { + eprintln!("gpui_terminal supports macOS, Linux and FreeBSD"); +} diff --git a/crates/gpui_terminal/src/dispatcher.rs b/crates/gpui_terminal/src/dispatcher.rs new file mode 100644 index 00000000000000..fdf2239c398d57 --- /dev/null +++ b/crates/gpui_terminal/src/dispatcher.rs @@ -0,0 +1,237 @@ +use std::{ + collections::BinaryHeap, + sync::{Arc, mpsc}, + thread, + time::{Duration, Instant}, +}; + +use gpui::{ + PlatformDispatcher, Priority, PriorityQueueReceiver, PriorityQueueSender, RunnableVariant, + profiler, +}; +use parking_lot::{Condvar, Mutex}; + +use crate::tty::TerminalEvent; + +const MIN_THREADS: usize = 2; + +/// Everything the main thread waits for arrives on one channel, so the event +/// loop blocks in a single place: GPUI runnables, terminal input and resizes. +pub(crate) enum MainMessage { + Runnable(RunnableVariant), + Terminal(TerminalEvent), +} + +/// Tells an embedding host that the main thread has work, so it calls +/// `TerminalPlatform::pump_events`. Empty when the platform owns its loop. +#[derive(Clone, Default)] +pub(crate) struct Wake(Arc>>>); + +impl Wake { + pub(crate) fn set(&self, hook: Option>) { + *self.0.lock() = hook; + } + + pub(crate) fn wake(&self) { + let hook = self.0.lock().clone(); + if let Some(hook) = hook { + hook(); + } + } +} + +/// Sends to the main thread and wakes the host after every message. +#[derive(Clone)] +pub(crate) struct MainSender { + sender: mpsc::Sender, + wake: Wake, +} + +impl MainSender { + pub(crate) fn new(sender: mpsc::Sender, wake: Wake) -> Self { + Self { sender, wake } + } + + pub(crate) fn send(&self, message: MainMessage) -> Result<(), mpsc::SendError> { + self.sender.send(message)?; + self.wake.wake(); + Ok(()) + } +} + +pub(crate) struct TerminalDispatcher { + main_sender: MainSender, + background_sender: PriorityQueueSender, + timers: Arc, + main_thread_id: thread::ThreadId, +} + +impl TerminalDispatcher { + pub(crate) fn new(main_sender: MainSender) -> Self { + let (background_sender, background_receiver) = PriorityQueueReceiver::new(); + let thread_count = thread::available_parallelism() + .map_or(MIN_THREADS, |count| count.get().max(MIN_THREADS)); + for index in 0..thread_count { + let receiver: PriorityQueueReceiver = background_receiver.clone(); + thread::Builder::new() + .name(format!("TerminalWorker-{index}")) + .spawn(move || { + for runnable in receiver.iter() { + run(runnable); + } + }) + .expect("failed to spawn terminal worker thread"); + } + + let timers = Arc::new(TimerQueue { + state: Mutex::new(TimerState { + heap: BinaryHeap::new(), + next_sequence: 0, + }), + condvar: Condvar::new(), + }); + thread::Builder::new() + .name("TerminalTimer".to_owned()) + .spawn({ + let timers = timers.clone(); + move || timers.run() + }) + .expect("failed to spawn terminal timer thread"); + + Self { + main_sender, + background_sender, + timers, + main_thread_id: thread::current().id(), + } + } + + /// Wakes the host at `due`, e.g. when a throttled frame becomes due. + pub(crate) fn wake_main_thread_at(&self, due: Instant) { + self.timers + .push(due, TimerAction::Wake(self.main_sender.wake.clone())); + } +} + +impl PlatformDispatcher for TerminalDispatcher { + fn is_main_thread(&self) -> bool { + thread::current().id() == self.main_thread_id + } + + fn dispatch(&self, runnable: RunnableVariant, priority: Priority) { + if let Err(error) = self.background_sender.send(priority, runnable) { + // Workers only stop when the process exits. The runnable may wrap a + // !Send future, so it must not be dropped on this thread. + std::mem::forget(error); + } + } + + fn dispatch_on_main_thread(&self, runnable: RunnableVariant, _priority: Priority) { + if let Err(error) = self.main_sender.send(MainMessage::Runnable(runnable)) { + // The event loop has exited. Same reason as above for forgetting. + std::mem::forget(error); + } + } + + fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) { + self.timers + .push(Instant::now() + duration, TimerAction::Run(runnable)); + } + + fn spawn_realtime(&self, function: Box) { + thread::Builder::new() + .name("TerminalRealtime".to_owned()) + .spawn(function) + .expect("failed to spawn terminal realtime thread"); + } +} + +fn run(runnable: RunnableVariant) { + let location = runnable.metadata().location; + let spawned = runnable.metadata().spawned; + profiler::update_running_task(spawned, location); + runnable.run(); + profiler::save_task_timing(); +} + +struct TimerQueue { + state: Mutex, + condvar: Condvar, +} + +struct TimerState { + heap: BinaryHeap, + next_sequence: u64, +} + +impl TimerQueue { + fn push(&self, due: Instant, action: TimerAction) { + let mut state = self.state.lock(); + let sequence = state.next_sequence; + state.next_sequence += 1; + state.heap.push(TimerEntry { + due, + sequence, + action, + }); + self.condvar.notify_one(); + } + + fn run(&self) { + let mut state = self.state.lock(); + loop { + let Some(due) = state.heap.peek().map(|entry| entry.due) else { + self.condvar.wait(&mut state); + continue; + }; + if due > Instant::now() { + self.condvar.wait_until(&mut state, due); + continue; + } + let Some(entry) = state.heap.pop() else { + continue; + }; + drop(state); + match entry.action { + TimerAction::Run(runnable) => run(runnable), + TimerAction::Wake(wake) => wake.wake(), + } + state = self.state.lock(); + } + } +} + +enum TimerAction { + Run(RunnableVariant), + Wake(Wake), +} + +struct TimerEntry { + due: Instant, + sequence: u64, + action: TimerAction, +} + +impl PartialEq for TimerEntry { + fn eq(&self, other: &Self) -> bool { + self.due == other.due && self.sequence == other.sequence + } +} + +impl Eq for TimerEntry {} + +impl PartialOrd for TimerEntry { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for TimerEntry { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + // Reversed: BinaryHeap is a max-heap and the earliest timer must be on top. + other + .due + .cmp(&self.due) + .then_with(|| other.sequence.cmp(&self.sequence)) + } +} diff --git a/crates/gpui_terminal/src/gpui_terminal.rs b/crates/gpui_terminal/src/gpui_terminal.rs new file mode 100644 index 00000000000000..5efb3496c76189 --- /dev/null +++ b/crates/gpui_terminal/src/gpui_terminal.rs @@ -0,0 +1,593 @@ +#![cfg(any(target_os = "macos", target_os = "linux", target_os = "freebsd"))] +//! Runs GPUI applications inside a terminal. +//! +//! The window is the terminal grid. Each frame is rendered offscreen on the +//! GPU, read back, and shown with the kitty graphics protocol. Terminal input +//! (SGR pixel mouse, kitty keyboard protocol, bracketed paste, focus and +//! resize reports) is translated into GPUI platform input. +//! +//! ```text +//! GPUI Scene ──► Metal / wgpu offscreen texture ──► RGBA readback into POSIX shm +//! │ +//! ▼ +//! terminal ◄── ESC_G a=T,t=s,i=1,p=1 (one ~100 byte write) +//! │ or, over SSH, the frame inline (t=d,o=z) +//! │ +//! └── /dev/tty bytes ──► input thread ──► main loop ──► PlatformInput +//! ``` + +mod dispatcher; +mod kitty; +mod tty; +mod window; + +use std::{ + cell::{Cell, RefCell}, + path::{Path, PathBuf}, + rc::{Rc, Weak}, + sync::{Arc, mpsc}, + time::{Duration, Instant}, +}; + +use anyhow::{Context as _, Result}; +use base64::Engine as _; +use futures::channel::oneshot; +use gpui::{ + Action, AnyWindowHandle, BackgroundExecutor, Bounds, ClipboardItem, CursorStyle, DisplayId, + DummyKeyboardMapper, ForegroundExecutor, Keymap, Menu, MenuItem, PathPromptOptions, Pixels, + Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, + PlatformWindow, Point, Task, ThermalState, WindowAppearance, WindowKind, WindowParams, px, + size, +}; + +use crate::{ + dispatcher::{MainMessage, MainSender, TerminalDispatcher, Wake}, + tty::{TerminalEvent, Tty}, + window::{TerminalWindow, WindowInner}, +}; + +/// How long one `pump_events` call may run queued work before it yields to +/// the host. A task that keeps waking itself must not starve the host loop. +const PUMP_BUDGET: Duration = Duration::from_millis(8); + +/// A GPUI application whose single window is the current terminal. +pub fn application() -> Result { + Ok(gpui::Application::with_platform(Rc::new( + TerminalPlatform::new()?, + ))) +} + +pub struct TerminalPlatform { + /// The host owns the run loop: `run` returns after launching and the host + /// calls `pump_events` whenever the wake hook fires. + embedded: bool, + dispatcher: Arc, + wake: Wake, + scheduled_wake: Cell>, + shut_down: Cell, + background_executor: BackgroundExecutor, + foreground_executor: ForegroundExecutor, + text_system: Arc, + main_receiver: mpsc::Receiver, + tty: Rc>, + display: Rc, + quit_requested: Rc>, + window: RefCell)>>, + clipboard: RefCell>, + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + primary: RefCell>, + cursor_style: Cell>, + quit_callback: RefCell bool>>>, +} + +impl TerminalPlatform { + /// Takes over the controlling terminal: raw mode, alternate screen, mouse + /// and keyboard reporting. The terminal is restored on quit, panic or + /// `exit(3)`. `Platform::run` blocks until the application quits. + pub fn new() -> Result { + Self::create(false) + } + + /// Like `new`, for a host that owns the thread's run loop (e.g. Node). + /// `Platform::run` returns right after launching. Set a wake hook with + /// `set_wake_hook`, and call `pump_events` on this thread when it fires. + pub fn new_embedded() -> Result { + Self::create(true) + } + + fn create(embedded: bool) -> Result { + let wake = Wake::default(); + let (sender, main_receiver) = mpsc::channel(); + let main_sender = MainSender::new(sender, wake.clone()); + let dispatcher = Arc::new(TerminalDispatcher::new(main_sender.clone())); + let tty = Tty::open()?; + let terminal_size = tty.size()?; + tty.spawn_input_thread(main_sender)?; + Ok(Self { + embedded, + dispatcher: dispatcher.clone(), + wake, + scheduled_wake: Cell::new(None), + shut_down: Cell::new(false), + background_executor: BackgroundExecutor::new(dispatcher.clone()), + foreground_executor: ForegroundExecutor::new(dispatcher), + text_system: new_text_system(), + main_receiver, + tty: Rc::new(RefCell::new(tty)), + display: Rc::new(TerminalDisplay { + bounds: Bounds::new( + Point::default(), + size( + px(terminal_size.width as f32), + px(terminal_size.height as f32), + ), + ), + }), + quit_requested: Rc::new(Cell::new(false)), + window: RefCell::new(None), + clipboard: RefCell::new(None), + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + primary: RefCell::new(None), + cursor_style: Cell::new(None), + quit_callback: RefCell::new(None), + }) + } + + /// Called from any thread whenever the main thread has work: a runnable, + /// terminal input, or a frame that became due. Pass `None` to clear it. + pub fn set_wake_hook(&self, hook: Option>) { + self.wake.set(hook); + } + + /// Runs queued main-thread work and a due frame, without blocking. + /// Returns false once the application quit; the terminal is restored then. + pub fn pump_events(&self) -> bool { + if self.shut_down.get() { + return false; + } + let started = Instant::now(); + while !self.quit_requested.get() { + if started.elapsed() >= PUMP_BUDGET { + self.wake.wake(); + break; + } + match self.main_receiver.try_recv() { + Ok(message) => self.handle_message(message), + Err(_) => break, + } + } + if !self.quit_requested.get() + && let Some(deadline) = self.window().and_then(|window| window.run_frame_if_due()) + { + self.wake_at(deadline); + } + if self.quit_requested.get() { + self.shut_down(); + return false; + } + true + } + + fn wake_at(&self, deadline: Instant) { + let pending = self + .scheduled_wake + .get() + .is_some_and(|scheduled| scheduled > Instant::now() && scheduled <= deadline); + if !pending { + self.scheduled_wake.set(Some(deadline)); + self.dispatcher.wake_main_thread_at(deadline); + } + } + + fn shut_down(&self) { + if self.shut_down.replace(true) { + return; + } + let should_quit = self + .quit_callback + .borrow_mut() + .as_mut() + .is_none_or(|callback| callback()); + if !should_quit { + log::warn!("the quit callback asked to stay open, but the terminal session ended"); + } + if let Some(window) = self.window() { + window.close(); + } + tty::restore_terminal(); + } + + fn window(&self) -> Option> { + self.window.borrow().as_ref()?.1.upgrade() + } + + fn write_to_terminal(&self, bytes: &[u8]) { + if let Err(error) = self.tty.borrow_mut().write_all(bytes) { + log::error!("{error:#}"); + } + } + + /// OSC 52 sets a system selection (`c` clipboard, `p` primary), also over SSH. + fn write_selection(&self, selection: char, item: &ClipboardItem) { + if let Some(text) = item.text() { + let encoded = base64::engine::general_purpose::STANDARD.encode(text); + self.write_to_terminal(format!("\x1b]52;{selection};{encoded}\x1b\\").as_bytes()); + } + } + + /// Hands a URL or file to the user's apps with `open(1)` or `xdg-open(1)`. + fn run_open(&self, arguments: Vec) { + let program = if cfg!(target_os = "macos") { + "open" + } else { + "xdg-open" + }; + self.background_executor + .spawn(async move { + match smol::process::Command::new(program) + .args(&arguments) + .status() + .await + { + Ok(status) if status.success() => {} + Ok(status) => log::error!("{program} {arguments:?} exited with {status}"), + Err(error) => log::error!("failed to run {program} {arguments:?}: {error}"), + } + }) + .detach(); + } + + fn handle_message(&self, message: MainMessage) { + match message { + MainMessage::Runnable(runnable) => { + let location = runnable.metadata().location; + let spawned = runnable.metadata().spawned; + gpui::profiler::update_running_task(spawned, location); + runnable.run(); + gpui::profiler::save_task_timing(); + } + MainMessage::Terminal(TerminalEvent::Quit) => self.quit_requested.set(true), + MainMessage::Terminal(event) => { + if let Some(window) = self.window() { + window.handle_event(event); + } + } + } + } +} + +impl Platform for TerminalPlatform { + fn background_executor(&self) -> BackgroundExecutor { + self.background_executor.clone() + } + + fn foreground_executor(&self) -> ForegroundExecutor { + self.foreground_executor.clone() + } + + fn text_system(&self) -> Arc { + self.text_system.clone() + } + + fn run(&self, on_finish_launching: Box) { + on_finish_launching(); + if self.embedded { + return; + } + while !self.quit_requested.get() { + let deadline = self.window().and_then(|window| window.run_frame_if_due()); + let message = match deadline { + Some(deadline) => { + let timeout = deadline.saturating_duration_since(Instant::now()); + match self.main_receiver.recv_timeout(timeout) { + Ok(message) => message, + Err(mpsc::RecvTimeoutError::Timeout) => continue, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + None => match self.main_receiver.recv() { + Ok(message) => message, + Err(_) => break, + }, + }; + self.handle_message(message); + } + self.shut_down(); + } + + fn quit(&self) { + self.quit_requested.set(true); + self.wake.wake(); + } + + fn restart(&self, _binary_path: Option, _arguments: Vec) {} + + fn activate(&self, _ignoring_other_apps: bool) {} + + fn hide(&self) {} + + fn hide_other_apps(&self) {} + + fn unhide_other_apps(&self) {} + + fn displays(&self) -> Vec> { + vec![self.display.clone()] + } + + fn primary_display(&self) -> Option> { + Some(self.display.clone()) + } + + fn active_window(&self) -> Option { + let window = self.window.borrow(); + let (handle, inner) = window.as_ref()?; + (inner.strong_count() > 0).then_some(*handle) + } + + fn open_window( + &self, + handle: AnyWindowHandle, + params: WindowParams, + ) -> Result> { + anyhow::ensure!( + matches!(params.kind, WindowKind::Normal), + "a terminal only has one surface; {:?} windows are not supported", + params.kind + ); + anyhow::ensure!( + self.window().is_none(), + "a terminal only has one surface and a window is already open" + ); + let size = self.tty.borrow().size()?; + let window = TerminalWindow::new( + self.tty.clone(), + self.display.clone(), + self.quit_requested.clone(), + self.wake.clone(), + size, + )?; + *self.window.borrow_mut() = Some((handle, Rc::downgrade(&window.0))); + Ok(Box::new(window)) + } + + fn window_appearance(&self) -> WindowAppearance { + WindowAppearance::Dark + } + + fn open_url(&self, url: &str) { + self.run_open(vec![url.into()]); + } + + fn on_open_urls(&self, _callback: Box)>) {} + + fn register_url_scheme(&self, _url: &str) -> Task> { + Task::ready(Err(anyhow::anyhow!( + "a terminal application cannot register URL schemes" + ))) + } + + fn prompt_for_paths( + &self, + _options: PathPromptOptions, + ) -> oneshot::Receiver>>> { + let (sender, receiver) = oneshot::channel(); + sender + .send(Err(anyhow::anyhow!( + "file dialogs are not available in a terminal" + ))) + .ok(); + receiver + } + + fn prompt_for_new_path( + &self, + _directory: &Path, + _suggested_name: Option<&str>, + ) -> oneshot::Receiver>> { + let (sender, receiver) = oneshot::channel(); + sender + .send(Err(anyhow::anyhow!( + "file dialogs are not available in a terminal" + ))) + .ok(); + receiver + } + + fn can_select_mixed_files_and_dirs(&self) -> bool { + false + } + + fn reveal_path(&self, path: &Path) { + if cfg!(target_os = "macos") { + self.run_open(vec!["-R".into(), path.into()]); + } else { + // xdg-open cannot select a file; open the folder that contains it. + self.run_open(vec![path.parent().unwrap_or(path).into()]); + } + } + + fn open_with_system(&self, path: &Path) { + self.run_open(vec![path.into()]); + } + + fn on_quit(&self, callback: Box bool>) { + *self.quit_callback.borrow_mut() = Some(callback); + } + + fn on_reopen(&self, _callback: Box) {} + + fn on_system_wake(&self, _callback: Box) {} + + fn set_menus(&self, _menus: Vec, _keymap: &Keymap) {} + + fn set_dock_menu(&self, _menu: Vec, _keymap: &Keymap) {} + + fn on_app_menu_action(&self, _callback: Box) {} + + fn on_will_open_app_menu(&self, _callback: Box) {} + + fn on_validate_app_menu_command(&self, _callback: Box bool>) {} + + fn thermal_state(&self) -> ThermalState { + ThermalState::Nominal + } + + fn on_thermal_state_change(&self, _callback: Box) {} + + fn compositor_name(&self) -> &'static str { + "terminal" + } + + fn app_path(&self) -> Result { + std::env::current_exe().context("resolving the executable path") + } + + fn path_for_auxiliary_executable(&self, name: &str) -> Result { + let executable = self.app_path()?; + let directory = executable + .parent() + .context("the executable has no parent directory")?; + Ok(directory.join(name)) + } + + /// OSC 22 sets the pointer shape in kitty, Ghostty and WezTerm. The names + /// are CSS cursor names. + fn set_cursor_style(&self, style: CursorStyle) { + if self.cursor_style.replace(Some(style)) == Some(style) { + return; + } + let name = match style { + CursorStyle::Arrow => "default", + CursorStyle::IBeam => "text", + CursorStyle::Crosshair => "crosshair", + CursorStyle::ClosedHand => "grabbing", + CursorStyle::OpenHand => "grab", + CursorStyle::PointingHand => "pointer", + CursorStyle::ResizeLeft | CursorStyle::ResizeRight | CursorStyle::ResizeLeftRight => { + "ew-resize" + } + CursorStyle::ResizeUp | CursorStyle::ResizeDown | CursorStyle::ResizeUpDown => { + "ns-resize" + } + CursorStyle::ResizeUpLeftDownRight => "nwse-resize", + CursorStyle::ResizeUpRightDownLeft => "nesw-resize", + CursorStyle::ResizeColumn => "col-resize", + CursorStyle::ResizeRow => "row-resize", + CursorStyle::IBeamCursorForVerticalLayout => "vertical-text", + CursorStyle::OperationNotAllowed => "not-allowed", + CursorStyle::DragLink => "alias", + CursorStyle::DragCopy => "copy", + CursorStyle::ContextualMenu => "context-menu", + }; + self.write_to_terminal(format!("\x1b]22;{name}\x1b\\").as_bytes()); + } + + fn hide_cursor_until_mouse_moves(&self) {} + + fn is_cursor_visible(&self) -> bool { + true + } + + fn should_auto_hide_scrollbars(&self) -> bool { + true + } + + /// The terminal's paste arrives as bracketed paste, not through here, so + /// this only returns what this application copied. + fn read_from_clipboard(&self) -> Option { + self.clipboard.borrow().clone() + } + + fn write_to_clipboard(&self, item: ClipboardItem) { + self.write_selection('c', &item); + *self.clipboard.borrow_mut() = Some(item); + } + + #[cfg(target_os = "macos")] + fn read_from_find_pasteboard(&self) -> Option { + None + } + + #[cfg(target_os = "macos")] + fn write_to_find_pasteboard(&self, _item: ClipboardItem) {} + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + fn read_from_primary(&self) -> Option { + self.primary.borrow().clone() + } + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + fn write_to_primary(&self, item: ClipboardItem) { + self.write_selection('p', &item); + *self.primary.borrow_mut() = Some(item); + } + + fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Task> { + Task::ready(Err(anyhow::anyhow!( + "credential storage is not available in a terminal" + ))) + } + + fn read_credentials(&self, _url: &str) -> Task)>>> { + Task::ready(Ok(None)) + } + + fn delete_credentials(&self, _url: &str) -> Task> { + Task::ready(Err(anyhow::anyhow!( + "credential storage is not available in a terminal" + ))) + } + + fn keyboard_layout(&self) -> Box { + Box::new(TerminalKeyboardLayout) + } + + fn keyboard_mapper(&self) -> Rc { + Rc::new(DummyKeyboardMapper) + } + + fn on_keyboard_layout_change(&self, _callback: Box) {} +} + +#[cfg(target_os = "macos")] +fn new_text_system() -> Arc { + Arc::new(gpui_macos::MacTextSystem::new()) +} + +#[cfg(not(target_os = "macos"))] +fn new_text_system() -> Arc { + Arc::new(gpui_wgpu::CosmicTextSystem::new("IBM Plex Sans")) +} + +/// The kitty keyboard protocol reports layout-independent keys plus the +/// typed text, so there is no layout to expose. +struct TerminalKeyboardLayout; + +impl PlatformKeyboardLayout for TerminalKeyboardLayout { + fn id(&self) -> &str { + "terminal" + } + + fn name(&self) -> &str { + "Terminal" + } +} + +#[derive(Debug)] +struct TerminalDisplay { + bounds: Bounds, +} + +impl PlatformDisplay for TerminalDisplay { + fn id(&self) -> DisplayId { + DisplayId::new(0) + } + + fn uuid(&self) -> Result { + Ok(uuid::Uuid::nil()) + } + + fn bounds(&self) -> Bounds { + self.bounds + } +} diff --git a/crates/gpui_terminal/src/kitty.rs b/crates/gpui_terminal/src/kitty.rs new file mode 100644 index 00000000000000..75f052c02440ce --- /dev/null +++ b/crates/gpui_terminal/src/kitty.rs @@ -0,0 +1,444 @@ +//! Presents rendered frames with the kitty graphics protocol. +//! +//! When the terminal runs on this machine, each frame is a POSIX shared +//! memory object (`t=s`) that the GPU readback writes into directly, so the +//! pty only carries a ~100 byte escape code. Over SSH the terminal cannot open +//! our shared memory, so frames are sent inline instead: zlib-compressed, +//! base64, in 4096 byte chunks (`t=d,o=z`). +//! +//! Which one works is asked once at startup, like kitty's own `icat` does: a +//! one pixel shared memory query (`a=q,t=s`) followed by `CSI c`. Terminals +//! answer in order, so a device attributes reply without an `OK` for the query +//! means shared memory is not reachable. +//! +//! Re-transmitting image id 1 with placement id 1 (`a=T,i=1,p=1`) replaces the +//! old frame and its placement in one command, and the synchronized update +//! brackets (`CSI ? 2026`) keep it flicker free. +//! https://sw.kovidgoyal.net/kitty/graphics-protocol/ + +use std::{ + collections::VecDeque, + ffi::CString, + io::{self, Write as _}, + ptr, + time::{Duration, Instant}, +}; + +use anyhow::{Context as _, Result}; +use base64::Engine as _; + +use crate::tty::Tty; + +/// Frames whose shared memory object we still own. The terminal unlinks an +/// object once it read it; we unlink anything older than this ourselves, so a +/// terminal that stops reading cannot leak memory without bound. +const IN_FLIGHT_FRAMES: usize = 4; +/// Image id of the startup shared memory query. +const PROBE_ID: u32 = 0x4750_0001; +/// A terminal that answers neither the query nor `CSI c` gets inline frames. +const PROBE_TIMEOUT: Duration = Duration::from_secs(2); +/// Inline frames are followed by a query whose reply says the terminal +/// consumed them. Each fence gets a fresh id from this range. +const FENCE_ID_START: u32 = 0x4750_1000; +const FENCE_ID_COUNT: u32 = 0x1_0000; +/// Inline frames that may be on their way to the terminal. More would only +/// sit in the SSH buffers and delay the response to input. +const MAX_UNACKED_FRAMES: usize = 2; +/// A fence that never got a reply stops holding frames back after this. +const FENCE_TIMEOUT: Duration = Duration::from_secs(2); +/// Kitty's limit for the base64 payload of one escape code. +const CHUNK_SIZE: usize = 4096; + +enum Transport { + /// Waiting for the terminal to answer the shared memory query. + Probing { + /// Must stay mapped and linked until the terminal answered. + _probe: SharedMemory, + deadline: Instant, + }, + SharedMemory, + Inline, +} + +pub(crate) struct KittyPresenter { + transport: Transport, + process_id: u32, + frame_counter: u64, + in_flight: VecDeque, + command: Vec, + /// Reused render target for inline frames. + inline_pixels: Vec, + fences: VecDeque<(u32, Instant)>, + next_fence: u32, +} + +/// A frame the next scene is rendered into. +pub(crate) struct Frame { + width: u32, + height: u32, + target: Target, +} + +enum Target { + Shared(SharedMemory), + Inline(Vec), +} + +impl Frame { + pub(crate) fn pixels(&mut self) -> &mut [u8] { + match &mut self.target { + Target::Shared(shared) => shared.bytes(), + Target::Inline(pixels) => pixels, + } + } +} + +/// A mapped POSIX shared memory object. Dropping it unlinks the object unless +/// it was handed to the terminal, which unlinks it after reading. +struct SharedMemory { + name: CString, + mapping: *mut libc::c_void, + length: usize, + presented: bool, +} + +impl SharedMemory { + fn bytes(&mut self) -> &mut [u8] { + unsafe { std::slice::from_raw_parts_mut(self.mapping as *mut u8, self.length) } + } +} + +impl Drop for SharedMemory { + fn drop(&mut self) { + unsafe { libc::munmap(self.mapping, self.length) }; + if !self.presented { + unsafe { libc::shm_unlink(self.name.as_ptr()) }; + } + } +} + +impl KittyPresenter { + /// Asks the terminal whether it can read our shared memory. No frame can + /// be presented until `wait_until` returns `None`. + pub(crate) fn new(tty: &mut Tty) -> Result { + let mut presenter = Self { + transport: Transport::Inline, + process_id: std::process::id(), + frame_counter: 0, + in_flight: VecDeque::new(), + command: Vec::new(), + inline_pixels: Vec::new(), + fences: VecDeque::new(), + next_fence: 0, + }; + let mut probe = match presenter.create_shared_memory(3) { + Ok(probe) => probe, + Err(error) => { + log::info!("no shared memory ({error:#}), sending frames inline"); + return Ok(presenter); + } + }; + probe.bytes().fill(0); + let encoded_name = base64::engine::general_purpose::STANDARD.encode(probe.name.as_bytes()); + tty.write_all( + format!("\x1b_Ga=q,i={PROBE_ID},s=1,v=1,f=24,t=s,S=3;{encoded_name}\x1b\\\x1b[c") + .as_bytes(), + )?; + presenter.transport = Transport::Probing { + _probe: probe, + deadline: Instant::now() + PROBE_TIMEOUT, + }; + Ok(presenter) + } + + pub(crate) fn handle_reply(&mut self, id: u32, ok: bool) { + if id == PROBE_ID { + if matches!(self.transport, Transport::Probing { .. }) { + self.choose(if ok { + Transport::SharedMemory + } else { + Transport::Inline + }); + } + return; + } + // Replies arrive in order, so a fence's reply also answers every older one. + if let Some(index) = self.fences.iter().position(|(fence, _)| *fence == id) { + self.fences.drain(..=index); + } + } + + pub(crate) fn handle_device_attributes(&mut self) { + if matches!(self.transport, Transport::Probing { .. }) { + self.choose(Transport::Inline); + } + } + + fn choose(&mut self, transport: Transport) { + match transport { + Transport::SharedMemory => log::info!("sending frames through shared memory"), + _ => log::info!("the terminal cannot read our shared memory, sending frames inline"), + } + self.transport = transport; + } + + /// When a frame cannot be presented yet, the time to check again. A + /// terminal reply may also make it ready earlier. + pub(crate) fn wait_until(&mut self, now: Instant) -> Option { + if let Transport::Probing { deadline, .. } = self.transport { + if now < deadline { + return Some(deadline); + } + log::warn!("the terminal did not answer the graphics query"); + self.choose(Transport::Inline); + } + while self.fences.len() >= MAX_UNACKED_FRAMES { + let (_, sent) = self.fences.front()?; + let expires = *sent + FENCE_TIMEOUT; + if now < expires { + return Some(expires); + } + self.fences.pop_front(); + } + None + } + + /// Allocates the target for an RGBA frame of `width` x `height`. + /// Dropping the frame without `present` discards it. + pub(crate) fn begin_frame(&mut self, width: u32, height: u32) -> Result { + let length = width as usize * height as usize * 4; + let target = match self.transport { + Transport::Probing { .. } => anyhow::bail!("the frame transport is not known yet"), + Transport::SharedMemory => Target::Shared(self.create_shared_memory(length)?), + Transport::Inline => { + let mut pixels = std::mem::take(&mut self.inline_pixels); + pixels.resize(length, 0); + Target::Inline(pixels) + } + }; + Ok(Frame { + width, + height, + target, + }) + } + + /// Shows `frame` covering the grid from its top-left cell. + pub(crate) fn present(&mut self, tty: &mut Tty, frame: Frame) -> Result<()> { + self.command.clear(); + match frame.target { + Target::Shared(mut shared) => { + if self.in_flight.len() >= IN_FLIGHT_FRAMES + && let Some(stale) = self.in_flight.pop_front() + { + // ENOENT is the normal case: the terminal already unlinked it. + unsafe { libc::shm_unlink(stale.as_ptr()) }; + } + let encoded_name = + base64::engine::general_purpose::STANDARD.encode(shared.name.as_bytes()); + write!( + self.command, + "\x1b[?2026h\x1b[H\x1b_Ga=T,i=1,p=1,f=32,t=s,s={},v={},S={},C=1,q=2;{encoded_name}\x1b\\\x1b[?2026l", + frame.width, frame.height, shared.length, + )?; + shared.presented = true; + self.in_flight.push_back(shared.name.clone()); + } + Target::Inline(pixels) => { + let fence = FENCE_ID_START + self.next_fence; + self.next_fence = (self.next_fence + 1) % FENCE_ID_COUNT; + write_inline_frame(&mut self.command, &pixels, frame.width, frame.height, fence)?; + self.fences.push_back((fence, Instant::now())); + self.inline_pixels = pixels; + } + } + // TODO: over a slow link this write blocks the main thread until the + // link drains. A writer thread would keep input responsive; the fence + // gate already bounds it to two frames. + tty.write_all(&self.command) + } + + fn create_shared_memory(&mut self, length: usize) -> Result { + // macOS caps shared memory names at 31 bytes including the slash. + let name = format!("/gpui{:x}.{:x}", self.process_id, self.frame_counter); + self.frame_counter += 1; + let name = CString::new(name).context("shared memory name")?; + let mapping = map_shared_memory(&name, length)?; + Ok(SharedMemory { + name, + mapping, + length, + presented: false, + }) + } +} + +impl Drop for KittyPresenter { + fn drop(&mut self) { + for name in self.in_flight.drain(..) { + unsafe { libc::shm_unlink(name.as_ptr()) }; + } + } +} + +/// Appends an RGBA frame as chunked `t=d,o=z` escape codes, then a one pixel +/// query with id `fence` whose reply tells us the terminal got this far. +fn write_inline_frame( + command: &mut Vec, + pixels: &[u8], + width: u32, + height: u32, + fence: u32, +) -> Result<()> { + let mut encoder = flate2::write::ZlibEncoder::new( + Vec::with_capacity(pixels.len() / 16), + flate2::Compression::fast(), + ); + encoder.write_all(pixels)?; + let payload = base64::engine::general_purpose::STANDARD.encode(encoder.finish()?); + command.extend_from_slice(b"\x1b[?2026h\x1b[H"); + let mut chunks = payload.as_bytes().chunks(CHUNK_SIZE).peekable(); + let mut first = true; + while let Some(chunk) = chunks.next() { + let more = u8::from(chunks.peek().is_some()); + if first { + write!( + command, + "\x1b_Ga=T,i=1,p=1,f=32,o=z,t=d,s={width},v={height},C=1,q=2,m={more};" + )?; + first = false; + } else { + write!(command, "\x1b_Gm={more},q=2;")?; + } + command.extend_from_slice(chunk); + command.extend_from_slice(b"\x1b\\"); + } + write!( + command, + "\x1b[?2026l\x1b_Ga=q,i={fence},s=1,v=1,f=24,t=d;AAAA\x1b\\" + )?; + Ok(()) +} + +/// macOS refuses write(2) on shared memory, so the object is always mapped. +/// On Linux the pages are allocated up front: a full `/dev/shm` (64 MB in +/// Docker by default) then fails here with ENOSPC instead of a SIGBUS on write. +fn map_shared_memory(name: &CString, length: usize) -> Result<*mut libc::c_void> { + let fd = unsafe { + libc::shm_open( + name.as_ptr(), + libc::O_CREAT | libc::O_EXCL | libc::O_RDWR, + 0o600 as libc::c_uint, + ) + }; + if fd < 0 { + return Err(io::Error::last_os_error()).context("shm_open"); + } + let result = if let Err(error) = allocate(fd, length) { + Err(error) + } else { + let mapping = unsafe { + libc::mmap( + ptr::null_mut(), + length, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_SHARED, + fd, + 0, + ) + }; + if mapping == libc::MAP_FAILED { + Err(io::Error::last_os_error()).context("mmap") + } else { + Ok(mapping) + } + }; + unsafe { libc::close(fd) }; + if result.is_err() { + unsafe { libc::shm_unlink(name.as_ptr()) }; + } + result +} + +#[cfg(target_os = "macos")] +fn allocate(fd: libc::c_int, length: usize) -> Result<()> { + if unsafe { libc::ftruncate(fd, length as libc::off_t) } != 0 { + return Err(io::Error::last_os_error()).context("ftruncate"); + } + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn allocate(fd: libc::c_int, length: usize) -> Result<()> { + // posix_fallocate returns the error number instead of setting errno. + match unsafe { libc::posix_fallocate(fd, 0, length as libc::off_t) } { + 0 => Ok(()), + error => Err(io::Error::from_raw_os_error(error)).context("posix_fallocate"), + } +} + +#[cfg(test)] +mod tests { + use std::io::Read as _; + + use super::*; + + #[test] + fn inline_frames_are_chunked_and_fenced() { + let (width, height) = (64u32, 48u32); + // Noise, so the compressed payload spans several chunks. + let mut seed = 0x9e37_79b9u32; + let pixels: Vec = (0..width * height * 4) + .map(|_| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (seed >> 24) as u8 + }) + .collect(); + let mut command = Vec::new(); + write_inline_frame(&mut command, &pixels, width, height, 77).unwrap(); + let text = String::from_utf8(command).unwrap(); + + let body = text + .strip_prefix("\x1b[?2026h\x1b[H") + .unwrap() + .strip_suffix("\x1b[?2026l\x1b_Ga=q,i=77,s=1,v=1,f=24,t=d;AAAA\x1b\\") + .unwrap(); + let codes: Vec<&str> = body + .split("\x1b\\") + .filter(|code| !code.is_empty()) + .collect(); + assert!(codes.len() > 1, "expected several chunks"); + let mut payload = String::new(); + for (index, code) in codes.iter().enumerate() { + let (keys, chunk) = code + .strip_prefix("\x1b_G") + .unwrap() + .split_once(';') + .unwrap(); + let last = index == codes.len() - 1; + let expected_keys = match (index, last) { + (0, _) => format!( + "a=T,i=1,p=1,f=32,o=z,t=d,s={width},v={height},C=1,q=2,m={}", + u8::from(!last) + ), + (_, false) => "m=1,q=2".to_owned(), + (_, true) => "m=0,q=2".to_owned(), + }; + assert_eq!(keys, expected_keys); + assert!(chunk.len() <= CHUNK_SIZE); + if !last { + assert_eq!(chunk.len() % 4, 0); + } + payload.push_str(chunk); + } + + let compressed = base64::engine::general_purpose::STANDARD + .decode(payload) + .unwrap(); + let mut decoded = Vec::new(); + flate2::read::ZlibDecoder::new(compressed.as_slice()) + .read_to_end(&mut decoded) + .unwrap(); + assert_eq!(decoded, pixels); + } +} diff --git a/crates/gpui_terminal/src/tty.rs b/crates/gpui_terminal/src/tty.rs new file mode 100644 index 00000000000000..6b9476b3e9fe6e --- /dev/null +++ b/crates/gpui_terminal/src/tty.rs @@ -0,0 +1,1196 @@ +//! The controlling terminal: raw mode, the modes GPUIX turns on, window size, +//! and a parser that turns terminal input bytes into GPUI-shaped events. + +use std::{ + fs::File, + io::{self, Read as _, Write as _}, + os::fd::{AsRawFd, RawFd}, + ptr, + sync::{ + Mutex, OnceLock, + atomic::{AtomicI32, Ordering}, + }, + thread, + time::Duration, +}; + +use anyhow::{Context as _, Result}; +use gpui::{Keystroke, Modifiers, MouseButton, NavigationDirection}; + +use crate::dispatcher::{MainMessage, MainSender}; + +/// Kitty keyboard flags: disambiguate (1), report event types (2), report +/// alternate keys (4), report all keys as escape codes (8), report associated +/// text (16). With 8 every key, including plain text and Escape, arrives as an +/// unambiguous `CSI ... u`, so there is no lone-Escape timeout in practice. +/// https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement +const KITTY_KEYBOARD_FLAGS: u32 = 1 | 2 | 4 | 8 | 16; + +/// Alternate screen, hidden cursor, any-motion mouse in SGR pixel mode, focus +/// reports, bracketed paste, in-band resize reports, kitty keyboard, then the +/// size and mode queries whose replies the parser reads. +fn enter_sequence() -> String { + format!( + "\x1b[?1049h\x1b[?25l\x1b[2J\ + \x1b[?1003h\x1b[?1006h\x1b[?1016h\ + \x1b[?1004h\x1b[?2004h\x1b[?2048h\ + \x1b[>{KITTY_KEYBOARD_FLAGS}u\ + \x1b[?1016$p" + ) +} + +const EXIT_SEQUENCE: &str = "\x1b_Ga=d,d=A,q=2\x1b\\\ + \x1b[ (f32, f32) { + ( + self.width as f32 / self.columns.max(1) as f32, + self.height as f32 / self.rows.max(1) as f32, + ) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum KeyEventKind { + Press, + Repeat, + Release, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum MouseEventKind { + Press(MouseButton), + Release(MouseButton), + Move(Option), + Wheel { dx: i8, dy: i8 }, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum TerminalEvent { + Key { + keystroke: Keystroke, + kind: KeyEventKind, + }, + /// A modifier key changed state. Only reported with the kitty protocol. + Modifiers(Modifiers), + /// `x` and `y` are terminal pixels when `pixels` is set, else 0-based cells. + Mouse { + kind: MouseEventKind, + x: f32, + y: f32, + pixels: bool, + modifiers: Modifiers, + }, + Paste(String), + Focus(bool), + Resize(TerminalSize), + /// Reply to the `CSI ? 1016 $ p` query: whether SGR pixel mouse works. + PixelMouseSupported(bool), + /// `ESC _ G i=; ESC \`, the reply to a kitty graphics command. + GraphicsReply { + id: u32, + ok: bool, + }, + /// Reply to `CSI c` (primary device attributes). Terminals answer in + /// order, so every reply to an earlier query has arrived before it. + DeviceAttributes, + Quit, +} + +struct Restore { + fd: RawFd, + original: libc::termios, + captured: Option, +} + +/// stdout and stderr, when they are this terminal, go to an unlinked temp file +/// while the app runs. Any print would otherwise scroll the alternate screen +/// and the frame with it. The file is replayed after the terminal is restored. +struct CapturedOutput { + file: RawFd, + /// `(redirected fd, dup of the original)`. + saved: Vec<(RawFd, RawFd)>, +} + +impl CapturedOutput { + fn start() -> Option { + let targets: Vec = [libc::STDOUT_FILENO, libc::STDERR_FILENO] + .into_iter() + .filter(|fd| unsafe { libc::isatty(*fd) } == 1) + .collect(); + if targets.is_empty() { + return None; + } + let directory = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".to_owned()); + let template = format!( + "{}/gpui-terminal-output.XXXXXX", + directory.trim_end_matches('/') + ); + let mut template = std::ffi::CString::new(template).ok()?.into_bytes_with_nul(); + let file = unsafe { libc::mkstemp(template.as_mut_ptr() as *mut libc::c_char) }; + if file < 0 { + log::error!( + "cannot capture terminal output: {}", + io::Error::last_os_error() + ); + return None; + } + unsafe { libc::unlink(template.as_ptr() as *const libc::c_char) }; + let saved = targets + .into_iter() + .filter_map(|target| { + let original = unsafe { libc::dup(target) }; + if original < 0 || unsafe { libc::dup2(file, target) } < 0 { + return None; + } + Some((target, original)) + }) + .collect(); + Some(Self { file, saved }) + } + + fn finish(self) { + for (target, original) in &self.saved { + unsafe { + libc::dup2(*original, *target); + libc::close(*original); + } + } + let mut buffer = [0u8; 16 * 1024]; + unsafe { libc::lseek(self.file, 0, libc::SEEK_SET) }; + loop { + let count = unsafe { + libc::read( + self.file, + buffer.as_mut_ptr() as *mut libc::c_void, + buffer.len(), + ) + }; + if count <= 0 { + break; + } + let mut written = 0; + while written < count as usize { + let result = unsafe { + libc::write( + libc::STDERR_FILENO, + buffer[written..].as_ptr() as *const libc::c_void, + count as usize - written, + ) + }; + if result <= 0 { + break; + } + written += result as usize; + } + } + unsafe { libc::close(self.file) }; + } +} + +/// Kept global so the panic hook and signal path can put the terminal back +/// even when the platform is mid-borrow. +static RESTORE: Mutex> = Mutex::new(None); +static SIGNAL_PIPE_WRITE: AtomicI32 = AtomicI32::new(-1); + +pub(crate) struct Tty { + file: File, +} + +impl Tty { + /// Opens the controlling terminal and switches it to raw mode. + pub(crate) fn open() -> Result { + let file = File::options() + .read(true) + .write(true) + .open("/dev/tty") + .context("gpui_terminal needs a controlling terminal (/dev/tty)")?; + let fd = file.as_raw_fd(); + + let mut original = unsafe { std::mem::zeroed::() }; + if unsafe { libc::tcgetattr(fd, &mut original) } != 0 { + return Err(io::Error::last_os_error()).context("tcgetattr failed"); + } + let mut raw = original; + unsafe { libc::cfmakeraw(&mut raw) }; + if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw) } != 0 { + return Err(io::Error::last_os_error()).context("tcsetattr failed"); + } + *RESTORE.lock().unwrap_or_else(|error| error.into_inner()) = Some(Restore { + fd, + original, + captured: CapturedOutput::start(), + }); + install_restore_hooks(); + + let mut tty = Self { file }; + tty.write_all(enter_sequence().as_bytes())?; + Ok(tty) + } + + pub(crate) fn write_all(&mut self, bytes: &[u8]) -> Result<()> { + self.file + .write_all(bytes) + .context("writing to the terminal") + } + + pub(crate) fn size(&self) -> Result { + let mut size = unsafe { std::mem::zeroed::() }; + if unsafe { libc::ioctl(self.file.as_raw_fd(), libc::TIOCGWINSZ, &mut size) } != 0 { + return Err(io::Error::last_os_error()).context("TIOCGWINSZ failed"); + } + Ok(TerminalSize { + columns: size.ws_col, + rows: size.ws_row, + width: size.ws_xpixel as u32, + height: size.ws_ypixel as u32, + }) + } + + pub(crate) fn spawn_input_thread(&self, sender: MainSender) -> Result<()> { + let reader = self + .file + .try_clone() + .context("cloning the terminal handle")?; + let signal_read = install_signal_pipe()?; + thread::Builder::new() + .name("TerminalInput".to_owned()) + .spawn(move || { + if let Err(error) = read_input(reader, signal_read, &sender) { + log::error!("terminal input stopped: {error:#}"); + sender.send(MainMessage::Terminal(TerminalEvent::Quit)).ok(); + } + }) + .context("spawning the terminal input thread")?; + Ok(()) + } +} + +impl Drop for Tty { + fn drop(&mut self) { + restore_terminal(); + } +} + +pub(crate) fn restore_terminal() { + let Some(restore) = RESTORE + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take() + else { + return; + }; + unsafe { + libc::write( + restore.fd, + EXIT_SEQUENCE.as_ptr() as *const libc::c_void, + EXIT_SEQUENCE.len(), + ); + libc::tcsetattr(restore.fd, libc::TCSANOW, &restore.original); + } + if let Some(captured) = restore.captured { + captured.finish(); + } +} + +extern "C" fn restore_terminal_at_exit() { + restore_terminal(); +} + +/// Panics, and `exit(3)` without unwinding (Node's `process.exit`), must still +/// leave raw mode and the alternate screen. +fn install_restore_hooks() { + static INSTALLED: OnceLock<()> = OnceLock::new(); + INSTALLED.get_or_init(|| { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + restore_terminal(); + previous(info); + })); + unsafe { libc::atexit(restore_terminal_at_exit) }; + }); +} + +const SIGNAL_RESIZE: u8 = b'w'; +const SIGNAL_QUIT: u8 = b'q'; + +/// The SIGWINCH handler installed before ours, e.g. Node's libuv one when GPUI +/// is embedded. It keeps running so the host still sees resizes. Quit signals +/// are not forwarded: a host handler may exit on the spot, before the terminal +/// is restored. Quitting the app restores it, then the host exits. +static PREVIOUS_RESIZE_ACTION: OnceLock = OnceLock::new(); + +extern "C" fn on_signal( + signal: libc::c_int, + info: *mut libc::siginfo_t, + context: *mut libc::c_void, +) { + let fd = SIGNAL_PIPE_WRITE.load(Ordering::Relaxed); + if fd >= 0 { + let byte = if signal == libc::SIGWINCH { + SIGNAL_RESIZE + } else { + SIGNAL_QUIT + }; + // write(2) is async-signal-safe; a full pipe already holds a pending wake. + unsafe { libc::write(fd, &byte as *const u8 as *const libc::c_void, 1) }; + } + + if signal != libc::SIGWINCH { + return; + } + let Some(previous) = PREVIOUS_RESIZE_ACTION.get() else { + return; + }; + let handler = previous.sa_sigaction; + if handler == libc::SIG_DFL || handler == libc::SIG_IGN { + return; + } + unsafe { + if previous.sa_flags & libc::SA_SIGINFO != 0 { + let handler: extern "C" fn(libc::c_int, *mut libc::siginfo_t, *mut libc::c_void) = + std::mem::transmute(handler); + handler(signal, info, context); + } else { + let handler: extern "C" fn(libc::c_int) = std::mem::transmute(handler); + handler(signal); + } + } +} + +fn install_signal_pipe() -> Result { + let mut fds = [0 as RawFd; 2]; + if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 { + return Err(io::Error::last_os_error()).context("creating the signal pipe"); + } + unsafe { + libc::fcntl(fds[1], libc::F_SETFL, libc::O_NONBLOCK); + } + SIGNAL_PIPE_WRITE.store(fds[1], Ordering::Relaxed); + let mut action = unsafe { std::mem::zeroed::() }; + action.sa_sigaction = on_signal as *const () as libc::sighandler_t; + action.sa_flags = libc::SA_SIGINFO | libc::SA_RESTART; + unsafe { libc::sigemptyset(&mut action.sa_mask) }; + for signal in [libc::SIGWINCH, libc::SIGTERM, libc::SIGHUP, libc::SIGINT] { + let mut previous = unsafe { std::mem::zeroed::() }; + unsafe { libc::sigaction(signal, &action, &mut previous) }; + if signal == libc::SIGWINCH { + PREVIOUS_RESIZE_ACTION.get_or_init(|| previous); + } + } + Ok(fds[0]) +} + +fn read_input(mut reader: File, signal_read: RawFd, sender: &MainSender) -> Result<()> { + let tty_fd = reader.as_raw_fd(); + let mut parser = InputParser::default(); + let mut buffer = vec![0u8; 64 * 1024]; + let mut events = Vec::new(); + loop { + // select(2), not poll(2): macOS poll reports POLLNVAL for /dev/tty. + let mut read_set = unsafe { std::mem::zeroed::() }; + unsafe { + libc::FD_ZERO(&mut read_set); + libc::FD_SET(tty_fd, &mut read_set); + libc::FD_SET(signal_read, &mut read_set); + } + let mut timeout = libc::timeval { + tv_sec: 0, + tv_usec: ESCAPE_TIMEOUT.as_micros() as libc::suseconds_t, + }; + let timeout = if parser.has_pending_escape() { + &mut timeout as *mut libc::timeval + } else { + ptr::null_mut() + }; + let ready = unsafe { + libc::select( + tty_fd.max(signal_read) + 1, + &mut read_set, + ptr::null_mut(), + ptr::null_mut(), + timeout, + ) + }; + if ready < 0 { + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::Interrupted { + continue; + } + return Err(error).context("waiting for terminal input"); + } + if ready == 0 { + parser.flush_pending_escape(&mut events); + } + + if unsafe { libc::FD_ISSET(signal_read, &read_set) } { + let mut signals = [0u8; 16]; + let count = unsafe { + libc::read( + signal_read, + signals.as_mut_ptr() as *mut libc::c_void, + signals.len(), + ) + }; + let signals = &signals[..count.max(0) as usize]; + if signals.contains(&SIGNAL_QUIT) { + events.push(TerminalEvent::Quit); + } else if signals.contains(&SIGNAL_RESIZE) { + let mut size = unsafe { std::mem::zeroed::() }; + if unsafe { libc::ioctl(tty_fd, libc::TIOCGWINSZ, &mut size) } == 0 { + events.push(TerminalEvent::Resize(TerminalSize { + columns: size.ws_col, + rows: size.ws_row, + width: size.ws_xpixel as u32, + height: size.ws_ypixel as u32, + })); + } + } + } + + if unsafe { libc::FD_ISSET(tty_fd, &read_set) } { + let count = match reader.read(&mut buffer) { + Ok(0) => anyhow::bail!("the terminal closed"), + Ok(count) => count, + Err(error) if error.kind() == io::ErrorKind::Interrupted => 0, + Err(error) => return Err(error).context("reading the terminal"), + }; + parser.feed(&buffer[..count], &mut events); + } + + for event in events.drain(..) { + let quit = event == TerminalEvent::Quit; + if sender.send(MainMessage::Terminal(event)).is_err() || quit { + return Ok(()); + } + } + } +} + +const PASTE_START: &[u8] = b"\x1b[200~"; +const PASTE_END: &[u8] = b"\x1b[201~"; +/// Longest escape sequence the parser waits for before discarding a prefix. +const MAX_SEQUENCE_LENGTH: usize = 4096; + +#[derive(Default)] +pub(crate) struct InputParser { + buffer: Vec, + paste: Option>, + pressed_button: Option, +} + +enum Parsed { + Event(usize, Option), + Incomplete, +} + +impl InputParser { + pub(crate) fn feed(&mut self, bytes: &[u8], events: &mut Vec) { + self.buffer.extend_from_slice(bytes); + let mut start = 0; + while start < self.buffer.len() { + if let Some(paste) = &mut self.paste { + let rest = &self.buffer[start..]; + match find(rest, PASTE_END) { + Some(end) => { + paste.extend_from_slice(&rest[..end]); + let text = String::from_utf8_lossy(paste).replace("\r\n", "\n"); + events.push(TerminalEvent::Paste(text.replace('\r', "\n"))); + self.paste = None; + start += end + PASTE_END.len(); + } + None => { + // Keep a tail that might be the start of the end marker. + let keep = PASTE_END.len().min(rest.len()); + paste.extend_from_slice(&rest[..rest.len() - keep]); + start = self.buffer.len() - keep; + break; + } + } + continue; + } + match self.parse(start) { + Parsed::Event(length, event) => { + if let Some(event) = event { + events.push(event); + } + start += length; + } + Parsed::Incomplete => { + if self.buffer.len() - start > MAX_SEQUENCE_LENGTH { + start += 1; + continue; + } + break; + } + } + } + self.buffer.drain(..start); + } + + pub(crate) fn has_pending_escape(&self) -> bool { + self.paste.is_none() && self.buffer.first() == Some(&0x1b) + } + + /// Called when a partial sequence timed out: a lone ESC is the Escape key, + /// and ESC + byte is Alt + that key. + pub(crate) fn flush_pending_escape(&mut self, events: &mut Vec) { + if !self.has_pending_escape() { + return; + } + if self.buffer.len() == 1 { + events.push(key_event(named_keystroke("escape", Modifiers::default()))); + self.buffer.clear(); + } else { + self.buffer.remove(0); + let mut alt_events = Vec::new(); + self.feed(&[], &mut alt_events); + for mut event in alt_events { + if let TerminalEvent::Key { keystroke, .. } = &mut event { + keystroke.modifiers.alt = true; + keystroke.key_char = None; + } + events.push(event); + } + } + } + + fn parse(&mut self, start: usize) -> Parsed { + let bytes = &self.buffer[start..]; + let Some(&first) = bytes.first() else { + return Parsed::Incomplete; + }; + if first != 0x1b { + return parse_legacy_byte(bytes); + } + let Some(&second) = bytes.get(1) else { + return Parsed::Incomplete; + }; + match second { + b'[' => { + if bytes.starts_with(PASTE_START) { + self.paste = Some(Vec::new()); + return Parsed::Event(PASTE_START.len(), None); + } + // CSI: parameter bytes 0x30–0x3f, intermediates 0x20–0x2f, final 0x40–0x7e. + let Some(final_index) = bytes[2..] + .iter() + .position(|byte| (0x40..=0x7e).contains(byte)) + .map(|index| index + 2) + else { + return Parsed::Incomplete; + }; + let params = bytes[2..final_index].to_vec(); + let final_byte = bytes[final_index]; + let event = self.parse_csi(¶ms, final_byte); + Parsed::Event(final_index + 1, event) + } + b']' | b'_' | b'P' => { + // OSC, APC (kitty graphics replies) and DCS end with ST or BEL. + for index in 2..bytes.len() { + let length = if bytes[index] == 0x07 { + index + 1 + } else if bytes[index] == 0x1b && bytes.get(index + 1) == Some(&b'\\') { + index + 2 + } else { + continue; + }; + let event = if second == b'_' && bytes.get(2) == Some(&b'G') { + parse_graphics_reply(&bytes[3..index]) + } else { + None + }; + return Parsed::Event(length, event); + } + Parsed::Incomplete + } + b'O' => { + let Some(&key) = bytes.get(2) else { + return Parsed::Incomplete; + }; + let event = legacy_final_key(key) + .map(|name| key_event(named_keystroke(name, Modifiers::default()))); + Parsed::Event(3, event) + } + 0x1b => Parsed::Event( + 1, + Some(key_event(named_keystroke("escape", Modifiers::default()))), + ), + _ => match parse_legacy_byte(&bytes[1..]) { + Parsed::Event(length, event) => Parsed::Event( + length + 1, + event.map(|mut event| { + if let TerminalEvent::Key { keystroke, .. } = &mut event { + keystroke.modifiers.alt = true; + keystroke.key_char = None; + } + event + }), + ), + Parsed::Incomplete => Parsed::Incomplete, + }, + } + } + + fn parse_csi(&mut self, params: &[u8], final_byte: u8) -> Option { + let params = std::str::from_utf8(params).ok()?; + match final_byte { + b'M' | b'm' if params.starts_with('<') => { + self.parse_sgr_mouse(¶ms[1..], final_byte == b'M') + } + b'u' if params.starts_with('?') => None, + b'u' => parse_kitty_key(params), + b'~' => { + let (code, modifiers, kind) = split_key_params(params)?; + let name = match code { + 2 => "insert", + 3 => "delete", + 5 => "pageup", + 6 => "pagedown", + 1 | 7 => "home", + 4 | 8 => "end", + 11 => "f1", + 12 => "f2", + 13 => "f3", + 14 => "f4", + 15 => "f5", + 17 => "f6", + 18 => "f7", + 19 => "f8", + 20 => "f9", + 21 => "f10", + 23 => "f11", + 24 => "f12", + _ => return None, + }; + Some(TerminalEvent::Key { + keystroke: named_keystroke(name, modifiers), + kind, + }) + } + b'I' if params.is_empty() => Some(TerminalEvent::Focus(true)), + b'O' if params.is_empty() => Some(TerminalEvent::Focus(false)), + b't' => { + let numbers: Vec = params + .split(';') + .filter_map(|part| part.parse().ok()) + .collect(); + match numbers.as_slice() { + [48, rows, columns, height, width] => { + Some(TerminalEvent::Resize(TerminalSize { + columns: *columns as u16, + rows: *rows as u16, + width: *width, + height: *height, + })) + } + _ => None, + } + } + b'y' if params.starts_with("?1016;") => { + // DECRPM: 0 means the mode is not recognized. + let state = params.trim_start_matches("?1016;").trim_end_matches('$'); + Some(TerminalEvent::PixelMouseSupported(state != "0")) + } + b'c' if params.starts_with('?') => Some(TerminalEvent::DeviceAttributes), + _ => { + let name = legacy_final_key(final_byte)?; + let (_, modifiers, kind) = if params.is_empty() { + (1, Modifiers::default(), KeyEventKind::Press) + } else { + split_key_params(params)? + }; + Some(TerminalEvent::Key { + keystroke: named_keystroke(name, modifiers), + kind, + }) + } + } + } + + fn parse_sgr_mouse(&mut self, params: &str, press: bool) -> Option { + let mut parts = params.split(';'); + let code: u32 = parts.next()?.parse().ok()?; + let x: f32 = parts.next()?.parse().ok()?; + let y: f32 = parts.next()?.parse().ok()?; + let modifiers = Modifiers { + shift: code & 4 != 0, + alt: code & 8 != 0, + control: code & 16 != 0, + ..Modifiers::default() + }; + let button = match (code & 0b11) | ((code & 128) >> 5) { + 0 => Some(MouseButton::Left), + 1 => Some(MouseButton::Middle), + 2 => Some(MouseButton::Right), + 4 => Some(MouseButton::Navigate(NavigationDirection::Back)), + 5 => Some(MouseButton::Navigate(NavigationDirection::Forward)), + _ => None, + }; + let kind = if code & 64 != 0 { + match code & 0b11 { + 0 => MouseEventKind::Wheel { dx: 0, dy: -1 }, + 1 => MouseEventKind::Wheel { dx: 0, dy: 1 }, + 2 => MouseEventKind::Wheel { dx: -1, dy: 0 }, + _ => MouseEventKind::Wheel { dx: 1, dy: 0 }, + } + } else if code & 32 != 0 { + MouseEventKind::Move(button.or(self.pressed_button)) + } else if press { + let button = button?; + self.pressed_button = Some(button); + MouseEventKind::Press(button) + } else { + let button = button.or(self.pressed_button)?; + self.pressed_button = None; + MouseEventKind::Release(button) + }; + // SGR coordinates are 1-based in both cell and pixel mode. + Some(TerminalEvent::Mouse { + kind, + x: (x - 1.).max(0.), + y: (y - 1.).max(0.), + pixels: false, + modifiers, + }) + } +} + +/// `i=31,p=1;OK` → the image id and whether the command succeeded. +fn parse_graphics_reply(body: &[u8]) -> Option { + let body = std::str::from_utf8(body).ok()?; + let (keys, message) = body.split_once(';')?; + let id = keys + .split(',') + .find_map(|key| key.strip_prefix("i="))? + .parse() + .ok()?; + Some(TerminalEvent::GraphicsReply { + id, + ok: message == "OK", + }) +} + +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +fn key_event(keystroke: Keystroke) -> TerminalEvent { + TerminalEvent::Key { + keystroke, + kind: KeyEventKind::Press, + } +} + +fn named_keystroke(name: &str, modifiers: Modifiers) -> Keystroke { + let key_char = match name { + "space" => Some(" "), + "tab" => Some("\t"), + "enter" => Some("\n"), + _ => None, + } + .filter(|_| !modifiers.control && !modifiers.platform) + .map(str::to_owned); + Keystroke { + modifiers, + key: name.to_owned(), + key_char, + } +} + +fn legacy_final_key(final_byte: u8) -> Option<&'static str> { + Some(match final_byte { + b'A' => "up", + b'B' => "down", + b'C' => "right", + b'D' => "left", + b'H' => "home", + b'F' => "end", + b'P' => "f1", + b'Q' => "f2", + b'R' => "f3", + b'S' => "f4", + _ => return None, + }) +} + +/// Bytes outside escape sequences: what a terminal sends without the kitty +/// protocol, or when it does not support it. +fn parse_legacy_byte(bytes: &[u8]) -> Parsed { + let Some(&first) = bytes.first() else { + return Parsed::Incomplete; + }; + let control = Modifiers { + control: true, + ..Modifiers::default() + }; + let keystroke = match first { + b'\r' | b'\n' => named_keystroke("enter", Modifiers::default()), + b'\t' => named_keystroke("tab", Modifiers::default()), + 0x7f | 0x08 => named_keystroke("backspace", Modifiers::default()), + 0x00 => named_keystroke("space", control), + 0x01..=0x1a => Keystroke { + modifiers: control, + key: ((first - 1 + b'a') as char).to_string(), + key_char: None, + }, + 0x1b..=0x1f => return Parsed::Event(1, None), + _ => { + let length = utf8_length(first); + if bytes.len() < length { + return Parsed::Incomplete; + } + let Ok(text) = std::str::from_utf8(&bytes[..length]) else { + return Parsed::Event(1, None); + }; + return Parsed::Event(length, Some(key_event(text_keystroke(text, None)))); + } + }; + Parsed::Event(1, Some(key_event(keystroke))) +} + +fn utf8_length(first: u8) -> usize { + match first { + 0xc0..=0xdf => 2, + 0xe0..=0xef => 3, + 0xf0..=0xf7 => 4, + _ => 1, + } +} + +/// A key that types `text`. GPUI names a shifted letter by its lowercase key +/// with `shift`, and any other shifted symbol by the symbol itself. +fn text_keystroke(text: &str, modifiers: Option) -> Keystroke { + let mut modifiers = modifiers.unwrap_or_default(); + if text == " " { + return named_keystroke("space", modifiers); + } + let lowercase = text.to_lowercase(); + if lowercase != text { + modifiers.shift = true; + } + Keystroke { + modifiers, + key: lowercase, + key_char: Some(text.to_owned()), + } +} + +/// `code ; modifiers[:event]` as used by `CSI ~` and the legacy letter finals. +fn split_key_params(params: &str) -> Option<(u32, Modifiers, KeyEventKind)> { + let mut fields = params.split(';'); + let code = fields.next().unwrap_or("1"); + let code = if code.is_empty() { + 1 + } else { + code.parse().ok()? + }; + let (modifiers, kind) = parse_modifier_field(fields.next().unwrap_or("")); + Some((code, modifiers, kind)) +} + +fn parse_modifier_field(field: &str) -> (Modifiers, KeyEventKind) { + let mut parts = field.split(':'); + let mask = parts + .next() + .and_then(|part| part.parse::().ok()) + .unwrap_or(1) + .saturating_sub(1); + let kind = match parts.next() { + Some("2") => KeyEventKind::Repeat, + Some("3") => KeyEventKind::Release, + _ => KeyEventKind::Press, + }; + ( + Modifiers { + shift: mask & 1 != 0, + alt: mask & 2 != 0, + control: mask & 4 != 0, + platform: mask & 8 != 0, + function: false, + }, + kind, + ) +} + +/// `CSI code[:shifted[:base]] ; modifiers[:event] ; text u` +/// https://sw.kovidgoyal.net/kitty/keyboard-protocol/#an-overview +fn parse_kitty_key(params: &str) -> Option { + let mut fields = params.split(';'); + let mut codes = fields.next()?.split(':'); + let code: u32 = codes.next()?.parse().ok()?; + let shifted: Option = codes.next().and_then(|code| code.parse().ok()); + let (mut modifiers, kind) = parse_modifier_field(fields.next().unwrap_or("")); + let text: Option = fields.next().map(|field| { + field + .split(':') + .filter_map(|codepoint| codepoint.parse().ok().and_then(char::from_u32)) + .collect() + }); + + if let Some(modifier_state) = modifier_key(code, modifiers, kind) { + return Some(TerminalEvent::Modifiers(modifier_state)); + } + + let name = match code { + 13 | 57414 => "enter", + 27 => "escape", + 9 => "tab", + 127 => "backspace", + 32 => "space", + 57417 => "left", + 57418 => "right", + 57419 => "up", + 57420 => "down", + 57421 => "pageup", + 57422 => "pagedown", + 57423 => "home", + 57424 => "end", + 57425 => "insert", + 57426 => "delete", + 57376..=57398 => { + return Some(TerminalEvent::Key { + keystroke: named_keystroke(&format!("f{}", code - 57376 + 13), modifiers), + kind, + }); + } + // Remaining private-use codes are lock, media and system keys GPUI has no name for. + 57344..=63743 if keypad_character(code).is_none() => return None, + _ => { + let character = keypad_character(code).or_else(|| char::from_u32(code))?; + // Terminals without "report associated text" send no text field; + // the key code, shifted if needed, is then the typed character. + let typed = text.or_else(|| { + (!modifiers.alt).then(|| match shifted.filter(|_| modifiers.shift) { + Some(shifted) => char::from_u32(shifted) + .map(String::from) + .unwrap_or_default(), + None => character.to_string(), + }) + }); + let key_char = typed.filter(|text| { + !text.is_empty() + && !modifiers.control + && !modifiers.platform + && kind != KeyEventKind::Release + }); + let key = if modifiers.shift && !character.is_ascii_lowercase() { + match shifted.and_then(char::from_u32) { + Some(shifted) => { + modifiers.shift = false; + shifted.to_string() + } + None => character.to_string(), + } + } else { + character.to_string() + }; + return Some(TerminalEvent::Key { + keystroke: Keystroke { + modifiers, + key, + key_char, + }, + kind, + }); + } + }; + let mut keystroke = named_keystroke(name, modifiers); + // Associated text wins over the default, e.g. option-space types U+00A0. + if keystroke.key_char.is_some() + && let Some(text) = text.filter(|text| !text.is_empty()) + { + keystroke.key_char = Some(text); + } + if kind == KeyEventKind::Release { + keystroke.key_char = None; + } + Some(TerminalEvent::Key { keystroke, kind }) +} + +/// Keypad keys have their own codes so that bindings can tell them apart. +/// GPUI does not, so they type and bind like the main keys. +fn keypad_character(code: u32) -> Option { + Some(match code { + 57399..=57408 => char::from_digit(code - 57399, 10)?, + 57409 => '.', + 57410 => '/', + 57411 => '*', + 57412 => '-', + 57413 => '+', + 57415 => '=', + 57416 => ',', + _ => return None, + }) +} + +/// Kitty reports modifier keys themselves (57441–57452). The modifier field +/// already includes the key on press and excludes it on release. +fn modifier_key(code: u32, modifiers: Modifiers, _kind: KeyEventKind) -> Option { + if (57441..=57452).contains(&code) { + Some(modifiers) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(bytes: &[u8]) -> Vec { + let mut parser = InputParser::default(); + let mut events = Vec::new(); + parser.feed(bytes, &mut events); + events + } + + fn keystrokes(bytes: &[u8]) -> Vec { + parse(bytes) + .into_iter() + .filter_map(|event| match event { + TerminalEvent::Key { + keystroke, + kind: KeyEventKind::Press, + } => Some(format!("{} {:?}", keystroke.unparse(), keystroke.key_char)), + _ => None, + }) + .collect() + } + + #[test] + fn parses_kitty_keys() { + assert_eq!( + keystrokes( + b"\x1b[97;;97u\x1b[97;2:1;65u\x1b[49:33;2;33u\x1b[99;5u\x1b[13u\x1b[57399;1;48u\x1b[32;3;160u" + ), + vec![ + "a Some(\"a\")", + "shift-a Some(\"A\")", + "! Some(\"!\")", + "ctrl-c None", + "enter Some(\"\\n\")", + "0 Some(\"0\")", + "alt-space Some(\"\\u{a0}\")", + ] + ); + assert_eq!( + keystrokes(b"\x1b[1;5A\x1b[3;2~\x1b[27u"), + vec!["ctrl-up None", "shift-delete None", "escape None",] + ); + } + + #[test] + fn parses_release_and_modifier_keys() { + assert_eq!( + parse(b"\x1b[97;1:3u\x1b[57441;2u"), + vec![ + TerminalEvent::Key { + keystroke: Keystroke { + modifiers: Modifiers::default(), + key: "a".into(), + key_char: None, + }, + kind: KeyEventKind::Release, + }, + TerminalEvent::Modifiers(Modifiers { + shift: true, + ..Modifiers::default() + }), + ] + ); + } + + #[test] + fn parses_sgr_mouse_and_tracks_drag() { + let events = + parse(b"\x1b[<0;11;21M\x1b[<32;12;22M\x1b[<35;13;23M\x1b[<0;12;22m\x1b[<65;1;1M"); + let kinds: Vec<_> = events + .into_iter() + .map(|event| match event { + TerminalEvent::Mouse { kind, x, y, .. } => (kind, x, y), + other => panic!("unexpected {other:?}"), + }) + .collect(); + assert_eq!( + kinds, + vec![ + (MouseEventKind::Press(MouseButton::Left), 10., 20.), + (MouseEventKind::Move(Some(MouseButton::Left)), 11., 21.), + (MouseEventKind::Move(Some(MouseButton::Left)), 12., 22.), + (MouseEventKind::Release(MouseButton::Left), 11., 21.), + (MouseEventKind::Wheel { dx: 0, dy: 1 }, 0., 0.), + ] + ); + } + + #[test] + fn handles_split_sequences_paste_and_replies() { + let mut parser = InputParser::default(); + let mut events = Vec::new(); + parser.feed(b"\x1b[200~hello\r\nwor", &mut events); + parser.feed(b"ld\x1b[20", &mut events); + parser.feed( + b"1~\x1b_Gi=1;OK\x1b\\\x1b_Gi=7,p=1;ENOENT:No such file\x1b\\\x1b[?62;22c\ + \x1b[?1016;2$y\x1b[48;30;100;630;1000t\x1b[I\x1b[9", + &mut events, + ); + parser.feed(b"7u", &mut events); + assert_eq!( + events, + vec![ + TerminalEvent::Paste("hello\nworld".into()), + TerminalEvent::GraphicsReply { id: 1, ok: true }, + TerminalEvent::GraphicsReply { id: 7, ok: false }, + TerminalEvent::DeviceAttributes, + TerminalEvent::PixelMouseSupported(true), + TerminalEvent::Resize(TerminalSize { + columns: 100, + rows: 30, + width: 1000, + height: 630, + }), + TerminalEvent::Focus(true), + key_event(text_keystroke("a", None)), + ] + ); + } + + #[test] + fn parses_legacy_input() { + let mut parser = InputParser::default(); + let mut events = Vec::new(); + parser.feed(b"x\x03\x7f\x1bb\xc3\xa9\x1b", &mut events); + assert!(parser.has_pending_escape()); + parser.flush_pending_escape(&mut events); + let names: Vec<_> = events + .into_iter() + .map(|event| match event { + TerminalEvent::Key { keystroke, .. } => keystroke.unparse(), + other => panic!("unexpected {other:?}"), + }) + .collect(); + assert_eq!( + names, + vec!["x", "ctrl-c", "backspace", "alt-b", "é", "escape"] + ); + } +} diff --git a/crates/gpui_terminal/src/window.rs b/crates/gpui_terminal/src/window.rs new file mode 100644 index 00000000000000..35ad78826b9b74 --- /dev/null +++ b/crates/gpui_terminal/src/window.rs @@ -0,0 +1,699 @@ +use std::{ + cell::{Cell, RefCell}, + rc::Rc, + sync::Arc, + time::{Duration, Instant}, +}; + +use gpui::{ + Bounds, Capslock, DevicePixels, DispatchEventResult, GpuSpecs, KeyDownEvent, KeyUpEvent, + Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, + Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, + Point, PromptButton, PromptLevel, RequestFrameOptions, Scene, ScrollDelta, ScrollWheelEvent, + Size, TouchPhase, WindowAppearance, WindowBackgroundAppearance, WindowBounds, + WindowControlArea, point, px, size, +}; + +use crate::{ + dispatcher::Wake, + kitty::KittyPresenter, + tty::{KeyEventKind, MouseEventKind, TerminalEvent, TerminalSize, Tty}, +}; + +/// Frames are demand driven; this only caps how often a dirty window repaints. +const MIN_FRAME_INTERVAL: Duration = Duration::from_micros(16_667); +const DOUBLE_CLICK_INTERVAL: Duration = Duration::from_millis(500); +const DOUBLE_CLICK_DISTANCE: f32 = 4.; +/// Used when the terminal reports no pixel size (`ws_xpixel == 0`). +const FALLBACK_CELL_SIZE: (u32, u32) = (8, 16); + +#[derive(Default)] +struct Callbacks { + request_frame: Option>, + input: Option DispatchEventResult>>, + active_status_change: Option>, + hover_status_change: Option>, + resize: Option, f32)>>, + close: Option>, +} + +struct ClickState { + button: MouseButton, + position: Point, + time: Instant, + count: usize, +} + +struct WindowState { + size: TerminalSize, + scale_factor: f32, + pixel_mouse: bool, + mouse_position: Point, + modifiers: Modifiers, + is_active: bool, + input_handler: Option, + last_click: Option, + title: String, +} + +pub(crate) struct WindowInner { + tty: Rc>, + display: Rc, + quit_requested: Rc>, + wake: Wake, + state: RefCell, + callbacks: RefCell, + renderer: RefCell, + presenter: RefCell, + frame_requested: Cell, + last_frame: Cell>, +} + +pub(crate) struct TerminalWindow(pub(crate) Rc); + +impl TerminalWindow { + pub(crate) fn new( + tty: Rc>, + display: Rc, + quit_requested: Rc>, + wake: Wake, + size: TerminalSize, + ) -> anyhow::Result { + let size = with_pixel_fallback(size); + let scale_factor = scale_factor_for(size); + let presenter = KittyPresenter::new(&mut tty.borrow_mut())?; + Ok(Self(Rc::new(WindowInner { + tty, + display, + quit_requested, + wake, + state: RefCell::new(WindowState { + size, + scale_factor, + pixel_mouse: true, + mouse_position: Point::default(), + modifiers: Modifiers::default(), + is_active: true, + input_handler: None, + last_click: None, + title: String::new(), + }), + callbacks: RefCell::new(Callbacks::default()), + renderer: RefCell::new(Renderer::new(gpui::size( + DevicePixels(size.width as i32), + DevicePixels(size.height as i32), + ))?), + presenter: RefCell::new(presenter), + frame_requested: Cell::new(true), + last_frame: Cell::new(None), + }))) + } +} + +/// Terminals report physical pixels but no DPI. A cell taller than 24 device +/// pixels is a HiDPI screen at any common font size. +/// `GPUI_TERMINAL_SCALE` overrides the guess. +fn scale_factor_for(size: TerminalSize) -> f32 { + if let Some(scale) = std::env::var("GPUI_TERMINAL_SCALE") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|scale| scale.is_finite() && *scale > 0.) + { + return scale; + } + let (_, cell_height) = size.cell_size(); + if cell_height >= 24. { 2. } else { 1. } +} + +fn with_pixel_fallback(mut size: TerminalSize) -> TerminalSize { + size.columns = size.columns.max(1); + size.rows = size.rows.max(1); + if size.width == 0 || size.height == 0 { + size.width = size.columns as u32 * FALLBACK_CELL_SIZE.0; + size.height = size.rows as u32 * FALLBACK_CELL_SIZE.1; + } + size +} + +impl WindowInner { + fn request_frame(&self) { + if !self.frame_requested.replace(true) { + self.wake.wake(); + } + } + + fn logical_size(&self) -> Size { + let state = self.state.borrow(); + size( + px(state.size.width as f32 / state.scale_factor), + px(state.size.height as f32 / state.scale_factor), + ) + } + + fn dispatch(&self, input: PlatformInput) -> Option { + let mut callback = self.callbacks.borrow_mut().input.take()?; + let result = callback(input); + self.callbacks.borrow_mut().input.get_or_insert(callback); + Some(result) + } + + fn with_input_handler(&self, f: impl FnOnce(&mut PlatformInputHandler) -> R) -> Option { + let mut handler = self.state.borrow_mut().input_handler.take()?; + let result = f(&mut handler); + self.state.borrow_mut().input_handler.get_or_insert(handler); + Some(result) + } + + /// When the window wants a frame and the frame interval elapsed, renders + /// it. Returns when the next frame is due, if one is wanted. + pub(crate) fn run_frame_if_due(&self) -> Option { + if !self.frame_requested.get() { + return None; + } + let now = Instant::now(); + if let Some(last_frame) = self.last_frame.get() + && now < last_frame + MIN_FRAME_INTERVAL + { + return Some(last_frame + MIN_FRAME_INTERVAL); + } + if let Some(deadline) = self.presenter.borrow_mut().wait_until(now) { + return Some(deadline); + } + self.frame_requested.set(false); + self.last_frame.set(Some(now)); + let mut callback = self.callbacks.borrow_mut().request_frame.take()?; + callback(RequestFrameOptions { + require_presentation: false, + force_render: false, + }); + self.callbacks + .borrow_mut() + .request_frame + .get_or_insert(callback); + self.frame_requested + .get() + .then_some(now + MIN_FRAME_INTERVAL) + } + + pub(crate) fn handle_event(&self, event: TerminalEvent) { + match event { + TerminalEvent::Key { keystroke, kind } => self.handle_key(keystroke, kind), + TerminalEvent::Modifiers(modifiers) => { + self.state.borrow_mut().modifiers = modifiers; + self.dispatch(PlatformInput::ModifiersChanged(ModifiersChangedEvent { + modifiers, + capslock: Capslock::default(), + })); + } + TerminalEvent::Mouse { + kind, + x, + y, + pixels: _, + modifiers, + } => self.handle_mouse(kind, x, y, modifiers), + TerminalEvent::Paste(text) => { + self.with_input_handler(|handler| handler.replace_text_in_range(None, &text)); + } + TerminalEvent::Focus(focused) => { + self.state.borrow_mut().is_active = focused; + self.notify_status(|callbacks| &mut callbacks.active_status_change, focused); + self.notify_status(|callbacks| &mut callbacks.hover_status_change, focused); + } + TerminalEvent::Resize(size) => self.handle_resize(size), + TerminalEvent::PixelMouseSupported(supported) => { + self.state.borrow_mut().pixel_mouse = supported; + } + TerminalEvent::GraphicsReply { id, ok } => { + self.presenter.borrow_mut().handle_reply(id, ok); + } + TerminalEvent::DeviceAttributes => { + self.presenter.borrow_mut().handle_device_attributes() + } + TerminalEvent::Quit => self.quit_requested.set(true), + } + } + + /// Callbacks are taken out while they run: they re-enter the window. + fn notify_status( + &self, + slot: impl Fn(&mut Callbacks) -> &mut Option>, + value: bool, + ) { + let callback = slot(&mut self.callbacks.borrow_mut()).take(); + if let Some(mut callback) = callback { + callback(value); + slot(&mut self.callbacks.borrow_mut()).get_or_insert(callback); + } + } + + fn handle_key(&self, keystroke: gpui::Keystroke, kind: KeyEventKind) { + if kind == KeyEventKind::Release { + self.dispatch(PlatformInput::KeyUp(KeyUpEvent { keystroke })); + return; + } + let result = self.dispatch(PlatformInput::KeyDown(KeyDownEvent { + keystroke: keystroke.clone(), + is_held: kind == KeyEventKind::Repeat, + prefer_character_input: false, + })); + if result.is_some_and(|result| !result.propagate) { + return; + } + let modifiers = keystroke.modifiers; + if !modifiers.control + && !modifiers.platform + && let Some(text) = keystroke.key_char.as_deref() + && self + .with_input_handler(|handler| handler.replace_text_in_range(None, text)) + .is_some() + { + return; + } + // Raw mode turns off the terminal's own SIGINT, so an app that does + // not bind ctrl-c must still be able to exit with it. + if modifiers.control && keystroke.key == "c" && !modifiers.alt && !modifiers.shift { + self.quit_requested.set(true); + } + } + + fn handle_mouse(&self, kind: MouseEventKind, x: f32, y: f32, modifiers: Modifiers) { + let (position, moved) = { + let mut state = self.state.borrow_mut(); + let (x, y) = if state.pixel_mouse { + (x, y) + } else { + let (cell_width, cell_height) = state.size.cell_size(); + ((x + 0.5) * cell_width, (y + 0.5) * cell_height) + }; + let position = point(px(x / state.scale_factor), px(y / state.scale_factor)); + let moved = state.mouse_position != position; + state.mouse_position = position; + state.modifiers = modifiers; + (position, moved) + }; + // A terminal can report a press or a wheel tick at a position it never + // reported a move to (motion reports are coalesced, or the pointer + // entered over the grid). GPUI derives hover from moves, so send one. + if moved && !matches!(kind, MouseEventKind::Move(_)) { + let pressed_button = match kind { + MouseEventKind::Release(button) => Some(button), + _ => None, + }; + self.dispatch(PlatformInput::MouseMove(MouseMoveEvent { + position, + pressed_button, + modifiers, + })); + } + let input = match kind { + MouseEventKind::Press(button) => { + let click_count = self.click_count(button, position); + PlatformInput::MouseDown(MouseDownEvent { + button, + position, + modifiers, + click_count, + first_mouse: false, + }) + } + MouseEventKind::Release(button) => { + let click_count = self + .state + .borrow() + .last_click + .as_ref() + .map_or(1, |click| click.count); + PlatformInput::MouseUp(MouseUpEvent { + button, + position, + modifiers, + click_count, + }) + } + MouseEventKind::Move(pressed_button) => PlatformInput::MouseMove(MouseMoveEvent { + position, + pressed_button, + modifiers, + }), + MouseEventKind::Wheel { dx, dy } => { + // One wheel report scrolls one terminal line, like any TUI. + let state = self.state.borrow(); + let (cell_width, cell_height) = state.size.cell_size(); + let scale_factor = state.scale_factor; + drop(state); + PlatformInput::ScrollWheel(ScrollWheelEvent { + position, + delta: ScrollDelta::Pixels(point( + px(-(dx as f32) * cell_width / scale_factor), + px(-(dy as f32) * cell_height / scale_factor), + )), + modifiers, + touch_phase: TouchPhase::Moved, + }) + } + }; + self.dispatch(input); + } + + fn click_count(&self, button: MouseButton, position: Point) -> usize { + let now = Instant::now(); + let mut state = self.state.borrow_mut(); + let count = match &state.last_click { + Some(click) + if click.button == button + && now.duration_since(click.time) < DOUBLE_CLICK_INTERVAL + && (click.position.x - position.x).abs() < px(DOUBLE_CLICK_DISTANCE) + && (click.position.y - position.y).abs() < px(DOUBLE_CLICK_DISTANCE) => + { + click.count + 1 + } + _ => 1, + }; + state.last_click = Some(ClickState { + button, + position, + time: now, + count, + }); + count + } + + fn handle_resize(&self, size: TerminalSize) { + let size = with_pixel_fallback(size); + let scale_factor = { + let mut state = self.state.borrow_mut(); + if state.size == size { + return; + } + state.size = size; + state.scale_factor + }; + let logical_size = self.logical_size(); + let callback = self.callbacks.borrow_mut().resize.take(); + if let Some(mut callback) = callback { + callback(logical_size, scale_factor); + self.callbacks.borrow_mut().resize.get_or_insert(callback); + } + self.frame_requested.set(true); + } + + fn draw(&self, scene: &Scene) { + let (width, height) = { + let state = self.state.borrow(); + (state.size.width, state.size.height) + }; + let result = autoreleasepool(|| { + let mut presenter = self.presenter.borrow_mut(); + let mut frame = presenter.begin_frame(width, height)?; + self.renderer.borrow_mut().render( + scene, + size(DevicePixels(width as i32), DevicePixels(height as i32)), + frame.pixels(), + )?; + presenter.present(&mut self.tty.borrow_mut(), frame) + }); + if let Err(error) = result { + log::error!("failed to draw terminal frame: {error:#}"); + } + } + + pub(crate) fn close(&self) { + let close = self.callbacks.borrow_mut().close.take(); + if let Some(close) = close { + close(); + } + } +} + +impl raw_window_handle::HasWindowHandle for TerminalWindow { + fn window_handle( + &self, + ) -> Result, raw_window_handle::HandleError> { + Err(raw_window_handle::HandleError::NotSupported) + } +} + +impl raw_window_handle::HasDisplayHandle for TerminalWindow { + fn display_handle( + &self, + ) -> Result, raw_window_handle::HandleError> { + Err(raw_window_handle::HandleError::NotSupported) + } +} + +impl PlatformWindow for TerminalWindow { + fn bounds(&self) -> Bounds { + Bounds::new(Point::default(), self.0.logical_size()) + } + + fn is_maximized(&self) -> bool { + true + } + + fn window_bounds(&self) -> WindowBounds { + WindowBounds::Windowed(self.bounds()) + } + + fn content_size(&self) -> Size { + self.0.logical_size() + } + + // The terminal owns its size. + fn resize(&mut self, _size: Size) {} + + fn scale_factor(&self) -> f32 { + self.0.state.borrow().scale_factor + } + + fn appearance(&self) -> WindowAppearance { + WindowAppearance::Dark + } + + fn display(&self) -> Option> { + Some(self.0.display.clone()) + } + + fn mouse_position(&self) -> Point { + self.0.state.borrow().mouse_position + } + + fn modifiers(&self) -> Modifiers { + self.0.state.borrow().modifiers + } + + fn capslock(&self) -> Capslock { + Capslock::default() + } + + fn set_input_handler(&mut self, input_handler: PlatformInputHandler) { + self.0.state.borrow_mut().input_handler = Some(input_handler); + } + + fn take_input_handler(&mut self) -> Option { + self.0.state.borrow_mut().input_handler.take() + } + + fn prompt( + &self, + _level: PromptLevel, + _msg: &str, + _detail: Option<&str>, + _answers: &[PromptButton], + ) -> Option> { + // GPUI falls back to prompts it renders itself. + None + } + + fn activate(&self) {} + + fn is_active(&self) -> bool { + self.0.state.borrow().is_active + } + + fn is_hovered(&self) -> bool { + self.0.state.borrow().is_active + } + + fn background_appearance(&self) -> WindowBackgroundAppearance { + WindowBackgroundAppearance::Opaque + } + + fn set_title(&mut self, title: &str) { + let mut state = self.0.state.borrow_mut(); + if state.title == title { + return; + } + state.title = title.to_owned(); + // OSC 2 sets the terminal window title. Control bytes would end it early. + let title: String = title + .chars() + .filter(|character| !character.is_control()) + .collect(); + if let Err(error) = self + .0 + .tty + .borrow_mut() + .write_all(format!("\x1b]2;{title}\x1b\\").as_bytes()) + { + log::error!("failed to set the terminal title: {error:#}"); + } + } + + fn get_title(&self) -> String { + self.0.state.borrow().title.clone() + } + + fn set_background_appearance(&self, _background_appearance: WindowBackgroundAppearance) {} + + fn minimize(&self) {} + + fn zoom(&self) {} + + fn toggle_fullscreen(&self) {} + + fn is_fullscreen(&self) -> bool { + true + } + + fn frame_waker(&self) -> Option> { + // Weak: the waker lives in the window's invalidator, which the frame + // callback owns, so a strong reference would leak the window. + let inner = Rc::downgrade(&self.0); + Some(Rc::new(move || { + if let Some(inner) = inner.upgrade() { + inner.request_frame(); + } + })) + } + + fn schedule_frame(&self) { + self.0.request_frame(); + } + + fn on_request_frame(&self, callback: Box) { + self.0.callbacks.borrow_mut().request_frame = Some(callback); + } + + fn on_input(&self, callback: Box DispatchEventResult>) { + self.0.callbacks.borrow_mut().input = Some(callback); + } + + fn on_active_status_change(&self, callback: Box) { + self.0.callbacks.borrow_mut().active_status_change = Some(callback); + } + + fn on_hover_status_change(&self, callback: Box) { + self.0.callbacks.borrow_mut().hover_status_change = Some(callback); + } + + fn on_resize(&self, callback: Box, f32)>) { + self.0.callbacks.borrow_mut().resize = Some(callback); + } + + fn on_moved(&self, _callback: Box) {} + + fn on_should_close(&self, _callback: Box bool>) {} + + fn on_hit_test_window_control(&self, _callback: Box Option>) { + } + + fn on_close(&self, callback: Box) { + self.0.callbacks.borrow_mut().close = Some(callback); + } + + fn on_appearance_changed(&self, _callback: Box) {} + + fn draw(&self, scene: &Scene) { + self.0.draw(scene); + } + + fn sprite_atlas(&self) -> Arc { + self.0.renderer.borrow().sprite_atlas() + } + + fn is_subpixel_rendering_supported(&self) -> bool { + false + } + + fn update_ime_position(&self, _bounds: Bounds) {} + + fn gpu_specs(&self) -> Option { + None + } +} + +/// Renders a scene offscreen into CPU-readable RGBA pixels. +#[cfg(target_os = "macos")] +struct Renderer(gpui_macos::metal_renderer::MetalRenderer); + +#[cfg(target_os = "macos")] +impl Renderer { + fn new(_size: Size) -> anyhow::Result { + let instance_buffer_pool = Arc::new(parking_lot::Mutex::new( + gpui_macos::metal_renderer::InstanceBufferPool::default(), + )); + // Kitty takes RGBA. Rendering in that format avoids a CPU swizzle. + Ok(Self( + gpui_macos::metal_renderer::MetalRenderer::new_headless( + instance_buffer_pool, + metal::MTLPixelFormat::RGBA8Unorm, + ), + )) + } + + fn render( + &mut self, + scene: &Scene, + size: Size, + pixels: &mut [u8], + ) -> anyhow::Result<()> { + self.0.render_scene_to_pixels(scene, size, pixels) + } + + fn sprite_atlas(&self) -> Arc { + self.0.sprite_atlas().clone() + } +} + +/// The event loop has no Cocoa run loop draining autoreleased Metal objects +/// (command buffers, encoders, pass descriptors), so each frame drains its own. +#[cfg(target_os = "macos")] +fn autoreleasepool(body: impl FnOnce() -> T) -> T { + objc::rc::autoreleasepool(body) +} + +#[cfg(not(target_os = "macos"))] +fn autoreleasepool(body: impl FnOnce() -> T) -> T { + body() +} + +#[cfg(not(target_os = "macos"))] +struct Renderer { + renderer: gpui_wgpu::WgpuRenderer, + _context: gpui_wgpu::WgpuContext, +} + +#[cfg(not(target_os = "macos"))] +impl Renderer { + fn new(size: Size) -> anyhow::Result { + let context = gpui_wgpu::WgpuContext::new_offscreen()?; + Ok(Self { + renderer: gpui_wgpu::WgpuRenderer::new_offscreen(&context, size)?, + _context: context, + }) + } + + fn render( + &mut self, + scene: &Scene, + size: Size, + pixels: &mut [u8], + ) -> anyhow::Result<()> { + self.renderer.render_to_pixels(scene, size, pixels) + } + + fn sprite_atlas(&self) -> Arc { + self.renderer.sprite_atlas().clone() + } +} diff --git a/crates/gpui_wgpu/src/wgpu_context.rs b/crates/gpui_wgpu/src/wgpu_context.rs index 7e744e80d6c453..4a83d21deaeac7 100644 --- a/crates/gpui_wgpu/src/wgpu_context.rs +++ b/crates/gpui_wgpu/src/wgpu_context.rs @@ -141,6 +141,57 @@ impl WgpuContext { }) } + /// Creates a context with no window surface, for offscreen rendering + /// with [`WgpuRenderer::new_offscreen`](crate::WgpuRenderer::new_offscreen). + /// Software adapters (llvmpipe, lavapipe) are accepted, so this also works + /// on machines without a GPU. + #[cfg(not(target_family = "wasm"))] + pub fn new_offscreen() -> anyhow::Result { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::PRIMARY | wgpu::Backends::GL, + flags: wgpu::InstanceFlags::default(), + backend_options: wgpu::BackendOptions::default(), + memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), + display: None, + }); + let adapter = gpui::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + compatible_surface: None, + force_fallback_adapter: false, + })) + .context("no GPU adapter available for offscreen rendering")?; + let (device, queue, dual_source_blending, color_texture_format) = + gpui::block_on(Self::create_device(&adapter))?; + + let device_lost = Arc::new(AtomicBool::new(false)); + device.set_device_lost_callback({ + let device_lost = Arc::clone(&device_lost); + move |reason, message| { + log::error!("wgpu device lost: reason={reason:?}, message={message}"); + if reason != wgpu::DeviceLostReason::Destroyed { + device_lost.store(true, Ordering::Relaxed); + } + } + }); + log::info!( + "Selected offscreen GPU adapter: {:?} ({:?})", + adapter.get_info().name, + adapter.get_info().backend + ); + + let backend = WgpuBackend::Native(adapter.get_info().backend); + Ok(Self { + instance, + adapter, + device: Arc::new(device), + queue: Arc::new(queue), + backend, + dual_source_blending, + color_texture_format, + device_lost, + }) + } + #[cfg(target_family = "wasm")] pub async fn new_web( canvas: &web_sys::HtmlCanvasElement, diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs index 07ee2fbcebdf36..47ce2a3b8f5627 100644 --- a/crates/gpui_wgpu/src/wgpu_renderer.rs +++ b/crates/gpui_wgpu/src/wgpu_renderer.rs @@ -180,7 +180,9 @@ enum InstanceData { struct WgpuResources { device: Arc, queue: Arc, - surface: wgpu::Surface<'static>, + /// `None` for an offscreen renderer, which draws into `readback` instead. + surface: Option>, + readback: Option, pipelines: WgpuPipelines, bind_group_layouts: WgpuBindGroupLayouts, atlas_sampler: wgpu::Sampler, @@ -198,6 +200,17 @@ struct WgpuResources { path_msaa_view: Option, } +/// Offscreen render target plus the buffer its pixels are copied into. +struct Readback { + texture: wgpu::Texture, + view: wgpu::TextureView, + buffer: wgpu::Buffer, + width: u32, + height: u32, + /// wgpu requires buffer rows aligned to `COPY_BYTES_PER_ROW_ALIGNMENT`. + padded_bytes_per_row: u32, +} + impl WgpuResources { fn invalidate_intermediate_textures(&mut self) { self.path_intermediate_texture = None; @@ -311,13 +324,32 @@ impl WgpuRenderer { Self::new_internal( Some(Rc::clone(&gpu_context)), context, - surface, + Some(surface), config, compositor_gpu, atlas, ) } + /// Creates a renderer without a window. Frames are drawn with + /// [`render_to_pixels`](Self::render_to_pixels) and read back as RGBA, for + /// hosts that present frames themselves, such as a terminal. + pub fn new_offscreen(context: &WgpuContext, size: Size) -> anyhow::Result { + let atlas = Arc::new(WgpuAtlas::from_context(context)); + Self::new_internal( + None, + context, + None, + WgpuSurfaceConfig { + size, + transparent: false, + preferred_present_mode: None, + }, + None, + atlas, + ) + } + #[cfg(target_family = "wasm")] pub fn new_from_canvas( context: &WgpuContext, @@ -339,18 +371,27 @@ impl WgpuRenderer { config: WgpuSurfaceConfig, ) -> anyhow::Result { let atlas = Arc::new(WgpuAtlas::from_context(context)); - Self::new_internal(None, context, surface, config, None, atlas) + Self::new_internal(None, context, Some(surface), config, None, atlas) } fn new_internal( gpu_context: Option, context: &WgpuContext, - surface: wgpu::Surface<'static>, + surface: Option>, config: WgpuSurfaceConfig, compositor_gpu: Option, atlas: Arc, ) -> anyhow::Result { - let surface_caps = surface.get_capabilities(&context.adapter); + let surface_caps = match &surface { + Some(surface) => surface.get_capabilities(&context.adapter), + // Offscreen frames are read back as RGBA, the layout image consumers expect. + None => wgpu::SurfaceCapabilities { + formats: vec![wgpu::TextureFormat::Rgba8Unorm], + present_modes: vec![wgpu::PresentMode::Fifo], + alpha_modes: vec![wgpu::CompositeAlphaMode::Opaque], + usages: wgpu::TextureUsages::RENDER_ATTACHMENT, + }, + }; let preferred_formats = [ wgpu::TextureFormat::Bgra8Unorm, wgpu::TextureFormat::Rgba8Unorm, @@ -430,7 +471,9 @@ impl WgpuRenderer { }; // Configure the surface immediately. The adapter selection process already validated // that this adapter can successfully configure this surface. - surface.configure(&context.device, &surface_config); + if let Some(surface) = &surface { + surface.configure(&context.device, &surface_config); + } let queue = Arc::clone(&context.queue); let rendering_params = RenderingParameters::new(&context.adapter, surface_format); @@ -583,6 +626,7 @@ impl WgpuRenderer { device, queue, surface, + readback: None, pipelines, bind_group_layouts, atlas_sampler, @@ -1169,9 +1213,9 @@ impl WgpuRenderer { texture.destroy(); } - resources - .surface - .configure(&resources.device, &surface_config); + if let Some(surface) = &resources.surface { + surface.configure(&resources.device, &surface_config); + } // Invalidate intermediate textures - they will be lazily recreated // in draw() after we confirm the surface is healthy. This avoids @@ -1228,9 +1272,9 @@ impl WgpuRenderer { let Some(resources) = self.resources.as_mut() else { return; }; - resources - .surface - .configure(&resources.device, &surface_config); + if let Some(surface) = &resources.surface { + surface.configure(&resources.device, &surface_config); + } resources.pipelines = Self::create_pipelines( &resources.device, &resources.bind_group_layouts, @@ -1323,24 +1367,20 @@ impl WgpuRenderer { self.atlas.before_frame(); - let frame = match self.resources().surface.get_current_texture() { + let Some(surface) = self.resources().surface.as_ref() else { + log::error!("draw() called on an offscreen renderer; use render_to_pixels()"); + return false; + }; + let frame = match surface.get_current_texture() { wgpu::CurrentSurfaceTexture::Success(frame) => frame, wgpu::CurrentSurfaceTexture::Suboptimal(frame) => { // Textures must be destroyed before the surface can be reconfigured. drop(frame); - let surface_config = self.surface_config.clone(); - let resources = self.resources_mut(); - resources - .surface - .configure(&resources.device, &surface_config); + surface.configure(&self.resources().device, &self.surface_config); return false; } wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Outdated => { - let surface_config = self.surface_config.clone(); - let resources = self.resources_mut(); - resources - .surface - .configure(&resources.device, &surface_config); + surface.configure(&self.resources().device, &self.surface_config); return false; } wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => { @@ -1360,6 +1400,19 @@ impl WgpuRenderer { .texture .create_view(&wgpu::TextureViewDescriptor::default()); + self.write_globals(); + + if let Err(error) = self.record_frame(scene, &frame_view) { + log::error!("{error:#}"); + self.resources().queue.submit(std::iter::empty()); + return false; + } + + frame.present(); + true + } + + fn write_globals(&self) { let gamma_params = GammaParams { gamma_ratios: self.rendering_params.gamma_ratios, grayscale_enhanced_contrast: self.rendering_params.grayscale_enhanced_contrast, @@ -1406,15 +1459,149 @@ impl WgpuRenderer { bytemuck::bytes_of(&gamma_params), ); } + } - if let Err(error) = self.record_frame(scene, &frame_view) { - log::error!("{error:#}"); - self.resources().queue.submit(std::iter::empty()); - return false; + /// Renders `scene` offscreen and copies it into `pixels` as tightly packed + /// RGBA rows. `pixels` must hold `width * height * 4` bytes. Blocks until + /// the GPU finished, so the caller can hand the pixels on right away. + pub fn render_to_pixels( + &mut self, + scene: &Scene, + size: Size, + pixels: &mut [u8], + ) -> Result<()> { + anyhow::ensure!( + self.resources().surface.is_none(), + "render_to_pixels needs a renderer from new_offscreen" + ); + anyhow::ensure!(!self.device_lost(), "the GPU device was lost"); + let width = (size.width.0.max(1) as u32).min(self.max_texture_size); + let height = (size.height.0.max(1) as u32).min(self.max_texture_size); + let unpadded_bytes_per_row = width * 4; + anyhow::ensure!( + pixels.len() == unpadded_bytes_per_row as usize * height as usize, + "pixel buffer does not match {width}x{height}" + ); + + self.update_drawable_size(size); + self.atlas.before_frame(); + self.ensure_intermediate_textures(); + self.ensure_readback(width, height); + self.write_globals(); + + let view = self + .resources() + .readback + .as_ref() + .map(|readback| readback.view.clone()) + .context("readback target was just created")?; + self.record_frame(scene, &view)?; + + let resources = self.resources(); + let readback = resources + .readback + .as_ref() + .context("readback target was just created")?; + let mut encoder = + resources + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("readback_encoder"), + }); + encoder.copy_texture_to_buffer( + readback.texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &readback.buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(readback.padded_bytes_per_row), + rows_per_image: Some(height), + }, + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + let submission = resources.queue.submit(std::iter::once(encoder.finish())); + + let slice = readback.buffer.slice(..); + let (sender, receiver) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |result| { + sender.send(result).ok(); + }); + resources + .device + .poll(wgpu::PollType::Wait { + submission_index: Some(submission), + timeout: None, + }) + .map_err(|error| anyhow::anyhow!("waiting for the GPU: {error:?}"))?; + receiver + .recv() + .context("the readback map callback never ran")? + .map_err(|error| anyhow::anyhow!("mapping the readback buffer: {error}"))?; + + { + let mapped = slice.get_mapped_range(); + let padded = readback.padded_bytes_per_row as usize; + let unpadded = unpadded_bytes_per_row as usize; + for (source, target) in mapped + .chunks_exact(padded) + .zip(pixels.chunks_exact_mut(unpadded)) + { + target.copy_from_slice(&source[..unpadded]); + } } + readback.buffer.unmap(); - frame.present(); - true + if let Some(error) = self.last_error.lock().unwrap().take() { + anyhow::bail!("GPU error during offscreen frame: {error}"); + } + Ok(()) + } + + fn ensure_readback(&mut self, width: u32, height: u32) { + let format = self.surface_config.format; + let resources = self.resources_mut(); + if resources + .readback + .as_ref() + .is_some_and(|readback| readback.width == width && readback.height == height) + { + return; + } + let texture = resources.device.create_texture(&wgpu::TextureDescriptor { + label: Some("readback_texture"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let padded_bytes_per_row = (width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); + let buffer = resources.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback_buffer"), + size: u64::from(padded_bytes_per_row) * u64::from(height), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + resources.readback = Some(Readback { + texture, + view, + buffer, + width, + height, + padded_bytes_per_row, + }); } fn record_frame(&mut self, scene: &Scene, frame_view: &wgpu::TextureView) -> Result<()> { @@ -2095,7 +2282,7 @@ impl WgpuRenderer { .as_mut() .expect("GPU resources not available"); surface.configure(&res.device, &self.surface_config); - res.surface = surface; + res.surface = Some(surface); // Invalidate intermediate textures — they'll be recreated lazily. res.invalidate_intermediate_textures(); @@ -2190,7 +2377,7 @@ impl WgpuRenderer { *self = Self::new_internal( Some(gpu_context.clone()), context, - surface, + Some(surface), config, self.compositor_gpu, self.atlas.clone(), @@ -2313,7 +2500,9 @@ mod tests { // https://github.com/gpui-ce/gpui-ce/commit/6d043b22e477 #[test] fn surface_shader_samples_a_single_rgba_texture() { - assert!(STORAGE_BUFFER_SHADERS.contains("@group(1) @binding(1) var t_surface: texture_2d")); + assert!( + STORAGE_BUFFER_SHADERS.contains("@group(1) @binding(1) var t_surface: texture_2d") + ); assert!(!STORAGE_BUFFER_SHADERS.contains("var t_y: texture_2d")); assert!(!STORAGE_BUFFER_SHADERS.contains("var t_cb_cr: texture_2d")); assert!(WEBGL_SHADERS.contains("@group(1) @binding(1) var t_surface: texture_2d"));