From 44e8b726a9bc20672654c0752ac0215012c66bd0 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 20 Oct 2026 12:53:13 +0200 Subject: [PATCH 1/6] gpui: Expose offscreen Metal rendering for non-window hosts Plumbing for hosts that present frames themselves (a terminal): - gpui_apple: MetalRenderer::new_headless is no longer test-only and takes the output pixel format (BGRA8Unorm or RGBA8Unorm). Every pipeline and the path intermediate texture use it, so a consumer that wants RGBA gets it without a CPU swizzle. - gpui_apple: render_scene_to_pixels renders into a reused Shared (Managed on discrete GPUs) texture and copies tightly packed rows into a caller slice, e.g. a mmap'd shared memory object. - gpui_macos: export MetalRenderer, InstanceBufferPool and MacTextSystem. - gpui: gpui::queue (PriorityQueueSender/Receiver) is no longer gated off macOS, so a custom macOS dispatcher can use it. Discord: https://discord.com/channels/1422625037164351591/1562047194893131797 Session: ses_ea72eb238ffeNBQNOGK7liwLQG --- crates/gpui/src/gpui.rs | 8 -- crates/gpui_apple/src/metal_renderer.rs | 121 +++++++++++++++++++++--- crates/gpui_macos/src/gpui_macos.rs | 6 +- 3 files changed, 110 insertions(+), 25 deletions(-) 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; From 8a346ab794d486e725a84ecb590621c1853053d6 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 20 Oct 2026 12:53:13 +0200 Subject: [PATCH 2/6] gpui_terminal: Run GPUI apps inside kitty-graphics terminals New GPUI platform (macOS for now). The window is the terminal grid. Frames: Metal offscreen render in RGBA, read straight into a POSIX shm object, shown with ESC_G a=T,i=1,p=1,f=32,t=s inside CSI ?2026 sync, so the pty only carries ~100 bytes per frame. Reusing image/placement id replaces the frame in place without flicker. Frames are demand driven (frame_waker/schedule_frame) and capped at 60 Hz. Each frame runs in an autorelease pool because there is no Cocoa run loop. Input: /dev/tty in raw mode, SGR any-motion mouse with pixel coordinates (1003/1006/1016), kitty keyboard flags 31 (press/repeat/release, alternate keys, associated text), bracketed paste, focus reports, in-band resize (2048) plus SIGWINCH. The input thread uses select() because macOS poll() reports POLLNVAL for /dev/tty. Text goes through the input handler when a key is unhandled; unhandled ctrl-c quits. Title via OSC 2, pointer shape via OSC 22, clipboard write via OSC 52. Terminal is restored on quit, signals and panic. Main loop: one mpsc channel carries GPUI main-thread runnables and terminal events; background work uses gpui's priority queue; timers run on their own thread. Verified in Ghostty 1.3.1 (hover, clicks, wheel, keys, resize) and with a fake kitty pty harness that reads shm frames into PNGs. ~4 ms per 1600x960 frame on Apple silicon. Example: cargo run -p gpui_terminal --example terminal_hello Discord: https://discord.com/channels/1422625037164351591/1562047194893131797 Session: ses_ea72eb238ffeNBQNOGK7liwLQG --- Cargo.lock | 19 + Cargo.toml | 2 + crates/gpui_terminal/Cargo.toml | 31 + crates/gpui_terminal/LICENSE-APACHE | 1 + .../gpui_terminal/examples/terminal_hello.rs | 121 ++ crates/gpui_terminal/src/dispatcher.rs | 181 +++ crates/gpui_terminal/src/gpui_terminal.rs | 464 ++++++++ crates/gpui_terminal/src/kitty.rs | 151 +++ crates/gpui_terminal/src/tty.rs | 1028 +++++++++++++++++ crates/gpui_terminal/src/window.rs | 636 ++++++++++ 10 files changed, 2634 insertions(+) create mode 100644 crates/gpui_terminal/Cargo.toml create mode 120000 crates/gpui_terminal/LICENSE-APACHE create mode 100644 crates/gpui_terminal/examples/terminal_hello.rs create mode 100644 crates/gpui_terminal/src/dispatcher.rs create mode 100644 crates/gpui_terminal/src/gpui_terminal.rs create mode 100644 crates/gpui_terminal/src/kitty.rs create mode 100644 crates/gpui_terminal/src/tty.rs create mode 100644 crates/gpui_terminal/src/window.rs diff --git a/Cargo.lock b/Cargo.lock index e76b343b412f94..e562138cacdf7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7712,6 +7712,25 @@ dependencies = [ "smol_str", ] +[[package]] +name = "gpui_terminal" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "futures 0.3.32", + "gpui", + "gpui_macos", + "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_terminal/Cargo.toml b/crates/gpui_terminal/Cargo.toml new file mode 100644 index 00000000000000..481c8a3272f1df --- /dev/null +++ b/crates/gpui_terminal/Cargo.toml @@ -0,0 +1,31 @@ +[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(target_os = "macos")'.dependencies] +anyhow.workspace = true +base64.workspace = true +futures.workspace = true +gpui.workspace = true +gpui_macos = { workspace = true, features = ["font-kit"] } +libc.workspace = true +log.workspace = true +metal.workspace = true +objc.workspace = true +parking_lot.workspace = true +raw-window-handle.workspace = true +smol.workspace = true +uuid.workspace = true + +[[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..1aace0f5a713be --- /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(target_os = "macos")] +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(target_os = "macos")] +fn main() -> anyhow::Result<()> { + app::main() +} + +#[cfg(not(target_os = "macos"))] +fn main() { + eprintln!("gpui_terminal only supports macOS so far"); +} diff --git a/crates/gpui_terminal/src/dispatcher.rs b/crates/gpui_terminal/src/dispatcher.rs new file mode 100644 index 00000000000000..65058242a5989f --- /dev/null +++ b/crates/gpui_terminal/src/dispatcher.rs @@ -0,0 +1,181 @@ +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), +} + +pub(crate) struct TerminalDispatcher { + main_sender: mpsc::Sender, + background_sender: PriorityQueueSender, + timers: Arc, + main_thread_id: thread::ThreadId, +} + +impl TerminalDispatcher { + pub(crate) fn new(main_sender: mpsc::Sender) -> 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(), + } + } +} + +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) { + let mut state = self.timers.state.lock(); + let sequence = state.next_sequence; + state.next_sequence += 1; + state.heap.push(TimerEntry { + due: Instant::now() + duration, + sequence, + runnable, + }); + self.timers.condvar.notify_one(); + } + + 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 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); + run(entry.runnable); + state = self.state.lock(); + } + } +} + +struct TimerEntry { + due: Instant, + sequence: u64, + runnable: RunnableVariant, +} + +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..062704740000ba --- /dev/null +++ b/crates/gpui_terminal/src/gpui_terminal.rs @@ -0,0 +1,464 @@ +#![cfg(target_os = "macos")] +//! 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 offscreen texture ──► BGRA readback +//! │ swizzle into POSIX shm +//! ▼ +//! terminal ◄── ESC_G a=T,t=s,i=1,p=1 (one ~100 byte write) +//! │ +//! └── /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::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, TerminalDispatcher}, + tty::{TerminalEvent, Tty}, + window::{TerminalWindow, WindowInner}, +}; + +/// 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 { + background_executor: BackgroundExecutor, + foreground_executor: ForegroundExecutor, + text_system: Arc, + main_receiver: mpsc::Receiver, + tty: Rc>, + display: Rc, + quit_requested: Rc>, + window: RefCell)>>, + clipboard: 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 or panic. + pub fn new() -> Result { + let (main_sender, main_receiver) = mpsc::channel(); + 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 { + background_executor: BackgroundExecutor::new(dispatcher.clone()), + foreground_executor: ForegroundExecutor::new(dispatcher), + text_system: Arc::new(gpui_macos::MacTextSystem::new()), + 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), + cursor_style: Cell::new(None), + quit_callback: RefCell::new(None), + }) + } + + 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:#}"); + } + } + + /// Runs macOS `open(1)`, which hands URLs and files to the user's apps. + fn run_open(&self, arguments: [std::ffi::OsString; N]) { + self.background_executor + .spawn(async move { + match smol::process::Command::new("open") + .args(&arguments) + .status() + .await + { + Ok(status) if status.success() => {} + Ok(status) => log::error!("open {arguments:?} exited with {status}"), + Err(error) => log::error!("failed to run open {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(); + 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); + } + + 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 quit(&self) { + self.quit_requested.set(true); + } + + 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(), + 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([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) { + self.run_open(["-R".into(), path.into()]); + } + + fn open_with_system(&self, path: &Path) { + self.run_open([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() + } + + /// OSC 52 puts text on the system clipboard, also over SSH. + fn write_to_clipboard(&self, item: ClipboardItem) { + if let Some(text) = item.text() { + let encoded = base64::engine::general_purpose::STANDARD.encode(text); + self.write_to_terminal(format!("\x1b]52;c;{encoded}\x1b\\").as_bytes()); + } + *self.clipboard.borrow_mut() = Some(item); + } + + fn read_from_find_pasteboard(&self) -> Option { + None + } + + fn write_to_find_pasteboard(&self, _item: ClipboardItem) {} + + 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) {} +} + +/// 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..fdd6a9eb3e3a08 --- /dev/null +++ b/crates/gpui_terminal/src/kitty.rs @@ -0,0 +1,151 @@ +//! Presents rendered frames with the kitty graphics protocol. +//! +//! Each frame is a POSIX shared memory object (`t=s`) that the GPU readback +//! writes into directly, as RGBA, so the pty only carries a ~100 byte escape +//! code and the CPU copies each pixel once. The terminal maps the object, +//! reads it, then unlinks it. 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, fmt::Write as _, io, ptr}; + +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; + +pub(crate) struct KittyPresenter { + process_id: u32, + frame_counter: u64, + in_flight: VecDeque, + command: String, +} + +/// A mapped shared memory object the next frame is rendered into. +pub(crate) struct Frame { + name: CString, + mapping: *mut libc::c_void, + length: usize, + width: u32, + height: u32, + presented: bool, +} + +impl Frame { + pub(crate) fn pixels(&mut self) -> &mut [u8] { + unsafe { std::slice::from_raw_parts_mut(self.mapping as *mut u8, self.length) } + } +} + +impl Drop for Frame { + fn drop(&mut self) { + unsafe { libc::munmap(self.mapping, self.length) }; + if !self.presented { + unsafe { libc::shm_unlink(self.name.as_ptr()) }; + } + } +} + +impl KittyPresenter { + pub(crate) fn new() -> Self { + Self { + process_id: std::process::id(), + frame_counter: 0, + in_flight: VecDeque::new(), + command: String::new(), + } + } + + /// Creates the shared memory object 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 { + // 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 length = width as usize * height as usize * 4; + let mapping = map_shared_memory(&name, length)?; + Ok(Frame { + name, + mapping, + length, + width, + height, + presented: false, + }) + } + + /// Shows `frame` covering the grid from its top-left cell. + pub(crate) fn present(&mut self, tty: &mut Tty, mut frame: Frame) -> Result<()> { + 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(frame.name.as_bytes()); + self.command.clear(); + 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, frame.length, + )?; + frame.presented = true; + self.in_flight.push_back(frame.name.clone()); + drop(frame); + tty.write_all(self.command.as_bytes()) + } +} + +impl Drop for KittyPresenter { + fn drop(&mut self) { + for name in self.in_flight.drain(..) { + unsafe { libc::shm_unlink(name.as_ptr()) }; + } + } +} + +/// macOS refuses write(2) on shared memory, so the object is always mapped. +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 unsafe { libc::ftruncate(fd, length as libc::off_t) } != 0 { + Err(io::Error::last_os_error()).context("ftruncate") + } 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 +} diff --git a/crates/gpui_terminal/src/tty.rs b/crates/gpui_terminal/src/tty.rs new file mode 100644 index 00000000000000..fbc986bfb89e9f --- /dev/null +++ b/crates/gpui_terminal/src/tty.rs @@ -0,0 +1,1028 @@ +//! 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}, + mpsc, + }, + thread, + time::Duration, +}; + +use anyhow::{Context as _, Result}; +use gpui::{Keystroke, Modifiers, MouseButton, NavigationDirection}; + +use crate::dispatcher::MainMessage; + +/// 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), + Quit, +} + +struct Restore { + fd: RawFd, + original: libc::termios, +} + +/// 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 }); + install_panic_hook(); + + 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: mpsc::Sender) -> 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); + } +} + +fn install_panic_hook() { + 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); + })); + }); +} + +const SIGNAL_RESIZE: u8 = b'w'; +const SIGNAL_QUIT: u8 = b'q'; + +extern "C" fn on_signal(signal: libc::c_int) { + let fd = SIGNAL_PIPE_WRITE.load(Ordering::Relaxed); + if fd < 0 { + return; + } + 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) }; +} + +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); + for signal in [libc::SIGWINCH, libc::SIGTERM, libc::SIGHUP, libc::SIGINT] { + unsafe { libc::signal(signal, on_signal as *const () as libc::sighandler_t) }; + } + Ok(fds[0]) +} + +fn read_input( + mut reader: File, + signal_read: RawFd, + sender: &mpsc::Sender, +) -> 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() { + if bytes[index] == 0x07 { + return Parsed::Event(index + 1, None); + } + if bytes[index] == 0x1b && bytes.get(index + 1) == Some(&b'\\') { + return Parsed::Event(index + 2, None); + } + } + 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")) + } + _ => { + 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, + }) + } +} + +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[?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::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..fa4e3dbec59339 --- /dev/null +++ b/crates/gpui_terminal/src/window.rs @@ -0,0 +1,636 @@ +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::{ + 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>, + 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>, + size: TerminalSize, + ) -> anyhow::Result { + let size = with_pixel_fallback(size); + let scale_factor = scale_factor_for(size); + Ok(Self(Rc::new(WindowInner { + tty, + display, + quit_requested, + 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()?), + presenter: RefCell::new(KittyPresenter::new()), + 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 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); + } + 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::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) + }; + // The loop has no run loop draining autoreleased Metal objects + // (command buffers, encoders, pass descriptors), so drain per frame. + let result = objc::rc::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.frame_requested.set(true); + } + })) + } + + fn schedule_frame(&self) { + self.0.frame_requested.set(true); + } + + 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() -> 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() + } +} From c609b6220c18b3b34c74c28042ffb0c55a6e47e4 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 20 Oct 2026 13:07:43 +0200 Subject: [PATCH 3/6] gpui_terminal: Support Linux through wgpu offscreen rendering gpui_wgpu: - WgpuContext::new_offscreen picks an adapter without a window surface (Backends::PRIMARY | GL, software adapters accepted, so it works on machines without a GPU via lavapipe/llvmpipe). - WgpuResources.surface is Option; None means offscreen. New WgpuRenderer::new_offscreen and render_to_pixels: render into a reused RGBA texture, copy to a reused MAP_READ buffer (rows padded to 256 bytes), wait for the submission, unpad rows into the caller slice. draw() returns false without a surface. Globals upload is factored out of draw() into write_globals(). Windowed paths are unchanged. gpui_terminal: - Builds on macOS, Linux and FreeBSD. Linux uses CosmicTextSystem and the wgpu offscreen renderer; macOS keeps Metal + CoreText. - open(1) on macOS, xdg-open(1) elsewhere. OSC 52 primary selection for write_to_primary. - Linux shm pages are reserved with posix_fallocate, so a full /dev/shm fails with ENOSPC instead of a SIGBUS during the readback write. Verified in arm64 Debian Docker with mesa lavapipe: unit tests, clippy, and a fake kitty pty harness (hover, clicks, scroll, keys, resize, ctrl-c, odd sizes that exercise row padding). Discord: https://discord.com/channels/1422625037164351591/1562047194893131797 Session: ses_ea72eb238ffeNBQNOGK7liwLQG --- Cargo.lock | 1 + crates/gpui_terminal/Cargo.toml | 13 +- .../gpui_terminal/examples/terminal_hello.rs | 8 +- crates/gpui_terminal/src/gpui_terminal.rs | 77 ++++-- crates/gpui_terminal/src/kitty.rs | 23 +- crates/gpui_terminal/src/window.rs | 53 +++- crates/gpui_wgpu/src/wgpu_context.rs | 51 ++++ crates/gpui_wgpu/src/wgpu_renderer.rs | 253 +++++++++++++++--- 8 files changed, 414 insertions(+), 65 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e562138cacdf7f..f72b84d354fd93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7721,6 +7721,7 @@ dependencies = [ "futures 0.3.32", "gpui", "gpui_macos", + "gpui_wgpu", "libc", "log", "metal", diff --git a/crates/gpui_terminal/Cargo.toml b/crates/gpui_terminal/Cargo.toml index 481c8a3272f1df..c2f2e4cfc821e1 100644 --- a/crates/gpui_terminal/Cargo.toml +++ b/crates/gpui_terminal/Cargo.toml @@ -11,21 +11,26 @@ workspace = true [lib] path = "src/gpui_terminal.rs" -[target.'cfg(target_os = "macos")'.dependencies] +[target.'cfg(any(target_os = "macos", target_os = "linux", target_os = "freebsd"))'.dependencies] anyhow.workspace = true base64.workspace = true futures.workspace = true gpui.workspace = true -gpui_macos = { workspace = true, features = ["font-kit"] } libc.workspace = true log.workspace = true -metal.workspace = true -objc.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/examples/terminal_hello.rs b/crates/gpui_terminal/examples/terminal_hello.rs index 1aace0f5a713be..527dcb15218e28 100644 --- a/crates/gpui_terminal/examples/terminal_hello.rs +++ b/crates/gpui_terminal/examples/terminal_hello.rs @@ -6,7 +6,7 @@ //! //! Press ctrl-c to quit. -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "freebsd"))] mod app { use gpui::{ App, Context, FocusHandle, MouseMoveEvent, Point, Window, WindowOptions, div, prelude::*, @@ -110,12 +110,12 @@ mod app { } } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "freebsd"))] fn main() -> anyhow::Result<()> { app::main() } -#[cfg(not(target_os = "macos"))] +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "freebsd")))] fn main() { - eprintln!("gpui_terminal only supports macOS so far"); + eprintln!("gpui_terminal supports macOS, Linux and FreeBSD"); } diff --git a/crates/gpui_terminal/src/gpui_terminal.rs b/crates/gpui_terminal/src/gpui_terminal.rs index 062704740000ba..bee1891ed291fd 100644 --- a/crates/gpui_terminal/src/gpui_terminal.rs +++ b/crates/gpui_terminal/src/gpui_terminal.rs @@ -1,4 +1,4 @@ -#![cfg(target_os = "macos")] +#![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 @@ -7,9 +7,9 @@ //! resize reports) is translated into GPUI platform input. //! //! ```text -//! GPUI Scene ──► Metal offscreen texture ──► BGRA readback -//! │ swizzle into POSIX shm -//! ▼ +//! 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) //! │ //! └── /dev/tty bytes ──► input thread ──► main loop ──► PlatformInput @@ -62,6 +62,8 @@ pub struct TerminalPlatform { quit_requested: Rc>, window: RefCell)>>, clipboard: RefCell>, + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + primary: RefCell>, cursor_style: Cell>, quit_callback: RefCell bool>>>, } @@ -78,7 +80,7 @@ impl TerminalPlatform { Ok(Self { background_executor: BackgroundExecutor::new(dispatcher.clone()), foreground_executor: ForegroundExecutor::new(dispatcher), - text_system: Arc::new(gpui_macos::MacTextSystem::new()), + text_system: new_text_system(), main_receiver, tty: Rc::new(RefCell::new(tty)), display: Rc::new(TerminalDisplay { @@ -93,6 +95,8 @@ impl TerminalPlatform { 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), }) @@ -108,18 +112,31 @@ impl TerminalPlatform { } } - /// Runs macOS `open(1)`, which hands URLs and files to the user's apps. - fn run_open(&self, arguments: [std::ffi::OsString; N]) { + /// 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("open") + match smol::process::Command::new(program) .args(&arguments) .status() .await { Ok(status) if status.success() => {} - Ok(status) => log::error!("open {arguments:?} exited with {status}"), - Err(error) => log::error!("failed to run open {arguments:?}: {error}"), + Ok(status) => log::error!("{program} {arguments:?} exited with {status}"), + Err(error) => log::error!("failed to run {program} {arguments:?}: {error}"), } }) .detach(); @@ -250,7 +267,7 @@ impl Platform for TerminalPlatform { } fn open_url(&self, url: &str) { - self.run_open([url.into()]); + self.run_open(vec![url.into()]); } fn on_open_urls(&self, _callback: Box)>) {} @@ -293,11 +310,16 @@ impl Platform for TerminalPlatform { } fn reveal_path(&self, path: &Path) { - self.run_open(["-R".into(), path.into()]); + 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([path.into()]); + self.run_open(vec![path.into()]); } fn on_quit(&self, callback: Box bool>) { @@ -388,21 +410,30 @@ impl Platform for TerminalPlatform { self.clipboard.borrow().clone() } - /// OSC 52 puts text on the system clipboard, also over SSH. fn write_to_clipboard(&self, item: ClipboardItem) { - if let Some(text) = item.text() { - let encoded = base64::engine::general_purpose::STANDARD.encode(text); - self.write_to_terminal(format!("\x1b]52;c;{encoded}\x1b\\").as_bytes()); - } + 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" @@ -430,6 +461,16 @@ impl Platform for TerminalPlatform { 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; diff --git a/crates/gpui_terminal/src/kitty.rs b/crates/gpui_terminal/src/kitty.rs index fdd6a9eb3e3a08..3832dc1923c130 100644 --- a/crates/gpui_terminal/src/kitty.rs +++ b/crates/gpui_terminal/src/kitty.rs @@ -113,6 +113,8 @@ impl Drop for KittyPresenter { } /// 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( @@ -124,8 +126,8 @@ fn map_shared_memory(name: &CString, length: usize) -> Result<*mut libc::c_void> if fd < 0 { return Err(io::Error::last_os_error()).context("shm_open"); } - let result = if unsafe { libc::ftruncate(fd, length as libc::off_t) } != 0 { - Err(io::Error::last_os_error()).context("ftruncate") + let result = if let Err(error) = allocate(fd, length) { + Err(error) } else { let mapping = unsafe { libc::mmap( @@ -149,3 +151,20 @@ fn map_shared_memory(name: &CString, length: usize) -> Result<*mut libc::c_void> } 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"), + } +} diff --git a/crates/gpui_terminal/src/window.rs b/crates/gpui_terminal/src/window.rs index fa4e3dbec59339..2657450a304df1 100644 --- a/crates/gpui_terminal/src/window.rs +++ b/crates/gpui_terminal/src/window.rs @@ -94,7 +94,10 @@ impl TerminalWindow { title: String::new(), }), callbacks: RefCell::new(Callbacks::default()), - renderer: RefCell::new(Renderer::new()?), + renderer: RefCell::new(Renderer::new(gpui::size( + DevicePixels(size.width as i32), + DevicePixels(size.height as i32), + ))?), presenter: RefCell::new(KittyPresenter::new()), frame_requested: Cell::new(true), last_frame: Cell::new(None), @@ -380,9 +383,7 @@ impl WindowInner { let state = self.state.borrow(); (state.size.width, state.size.height) }; - // The loop has no run loop draining autoreleased Metal objects - // (command buffers, encoders, pass descriptors), so drain per frame. - let result = objc::rc::autoreleasepool(|| { + let result = autoreleasepool(|| { let mut presenter = self.presenter.borrow_mut(); let mut frame = presenter.begin_frame(width, height)?; self.renderer.borrow_mut().render( @@ -608,7 +609,7 @@ struct Renderer(gpui_macos::metal_renderer::MetalRenderer); #[cfg(target_os = "macos")] impl Renderer { - fn new() -> anyhow::Result { + fn new(_size: Size) -> anyhow::Result { let instance_buffer_pool = Arc::new(parking_lot::Mutex::new( gpui_macos::metal_renderer::InstanceBufferPool::default(), )); @@ -634,3 +635,45 @@ impl Renderer { 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")); From 59813f0ddacc1c782ef48385dcfe46839a89f0a0 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 20 Oct 2026 13:13:59 +0200 Subject: [PATCH 4/6] gpui_terminal: Add an embedded mode for hosts that own the run loop GPUIX runs GPUI on Node's main thread and pumps it from JS. The terminal platform now supports that shape, like MacPlatform::new_embedded: - TerminalPlatform::new_embedded(): Platform::run returns after launching. - pump_events(): runs queued main-thread work (8 ms budget, then re-wakes) and a due frame without blocking. Returns false once the app quit; the terminal is restored by then. - set_wake_hook(): fires from any thread when the main thread has work. MainSender wakes after every runnable and terminal event, the window wakes when a frame is first requested, and a throttled frame schedules a wake on the timer thread at its deadline. Also, for any host: - Signal handlers use sigaction and forward to the handlers installed before them (Node's libuv), instead of replacing them. SIG_DFL is not forwarded, so SIGTERM/SIGINT quit and restore the terminal. - atexit restores the terminal, so exit(3) without unwinding (process.exit) does not leave raw mode and the alternate screen. - stdout/stderr that are the terminal are redirected to an unlinked temp file while the app runs, then replayed after restore. A print would otherwise scroll the alternate screen and the frame with it. Discord: https://discord.com/channels/1422625037164351591/1562055543466496043 Session: ses_ea7105a47ffeNLpB02sEyo5t9g --- crates/gpui_terminal/src/dispatcher.rs | 82 ++++++++-- crates/gpui_terminal/src/gpui_terminal.rs | 121 ++++++++++++-- crates/gpui_terminal/src/tty.rs | 186 +++++++++++++++++++--- crates/gpui_terminal/src/window.rs | 14 +- 4 files changed, 348 insertions(+), 55 deletions(-) diff --git a/crates/gpui_terminal/src/dispatcher.rs b/crates/gpui_terminal/src/dispatcher.rs index 65058242a5989f..fdf2239c398d57 100644 --- a/crates/gpui_terminal/src/dispatcher.rs +++ b/crates/gpui_terminal/src/dispatcher.rs @@ -22,15 +22,52 @@ pub(crate) enum MainMessage { 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: mpsc::Sender, + main_sender: MainSender, background_sender: PriorityQueueSender, timers: Arc, main_thread_id: thread::ThreadId, } impl TerminalDispatcher { - pub(crate) fn new(main_sender: mpsc::Sender) -> Self { + 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)); @@ -68,6 +105,12 @@ impl TerminalDispatcher { 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 { @@ -91,15 +134,8 @@ impl PlatformDispatcher for TerminalDispatcher { } fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) { - let mut state = self.timers.state.lock(); - let sequence = state.next_sequence; - state.next_sequence += 1; - state.heap.push(TimerEntry { - due: Instant::now() + duration, - sequence, - runnable, - }); - self.timers.condvar.notify_one(); + self.timers + .push(Instant::now() + duration, TimerAction::Run(runnable)); } fn spawn_realtime(&self, function: Box) { @@ -129,6 +165,18 @@ struct TimerState { } 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 { @@ -144,16 +192,24 @@ impl TimerQueue { continue; }; drop(state); - run(entry.runnable); + 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, - runnable: RunnableVariant, + action: TimerAction, } impl PartialEq for TimerEntry { diff --git a/crates/gpui_terminal/src/gpui_terminal.rs b/crates/gpui_terminal/src/gpui_terminal.rs index bee1891ed291fd..a2560685e7a1cd 100644 --- a/crates/gpui_terminal/src/gpui_terminal.rs +++ b/crates/gpui_terminal/src/gpui_terminal.rs @@ -25,7 +25,7 @@ use std::{ path::{Path, PathBuf}, rc::{Rc, Weak}, sync::{Arc, mpsc}, - time::Instant, + time::{Duration, Instant}, }; use anyhow::{Context as _, Result}; @@ -40,11 +40,15 @@ use gpui::{ }; use crate::{ - dispatcher::{MainMessage, TerminalDispatcher}, + 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( @@ -53,6 +57,13 @@ pub fn application() -> Result { } 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, @@ -70,14 +81,33 @@ pub struct TerminalPlatform { impl TerminalPlatform { /// Takes over the controlling terminal: raw mode, alternate screen, mouse - /// and keyboard reporting. The terminal is restored on quit or panic. + /// and keyboard reporting. The terminal is restored on quit, panic or + /// `exit(3)`. `Platform::run` blocks until the application quits. pub fn new() -> Result { - let (main_sender, main_receiver) = mpsc::channel(); + 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(), @@ -102,6 +132,70 @@ impl TerminalPlatform { }) } + /// 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() } @@ -176,6 +270,9 @@ impl Platform for TerminalPlatform { 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 { @@ -194,23 +291,12 @@ impl Platform for TerminalPlatform { }; self.handle_message(message); } - - 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(); + self.shut_down(); } fn quit(&self) { self.quit_requested.set(true); + self.wake.wake(); } fn restart(&self, _binary_path: Option, _arguments: Vec) {} @@ -256,6 +342,7 @@ impl Platform for TerminalPlatform { self.tty.clone(), self.display.clone(), self.quit_requested.clone(), + self.wake.clone(), size, )?; *self.window.borrow_mut() = Some((handle, Rc::downgrade(&window.0))); diff --git a/crates/gpui_terminal/src/tty.rs b/crates/gpui_terminal/src/tty.rs index fbc986bfb89e9f..e0b7d23b3f8e40 100644 --- a/crates/gpui_terminal/src/tty.rs +++ b/crates/gpui_terminal/src/tty.rs @@ -9,7 +9,6 @@ use std::{ sync::{ Mutex, OnceLock, atomic::{AtomicI32, Ordering}, - mpsc, }, thread, time::Duration, @@ -18,7 +17,7 @@ use std::{ use anyhow::{Context as _, Result}; use gpui::{Keystroke, Modifiers, MouseButton, NavigationDirection}; -use crate::dispatcher::MainMessage; +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 @@ -109,6 +108,92 @@ pub(crate) enum TerminalEvent { 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 @@ -139,8 +224,12 @@ impl Tty { 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 }); - install_panic_hook(); + *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())?; @@ -166,7 +255,7 @@ impl Tty { }) } - pub(crate) fn spawn_input_thread(&self, sender: mpsc::Sender) -> Result<()> { + pub(crate) fn spawn_input_thread(&self, sender: MainSender) -> Result<()> { let reader = self .file .try_clone() @@ -207,9 +296,18 @@ pub(crate) fn restore_terminal() { ); libc::tcsetattr(restore.fd, libc::TCSANOW, &restore.original); } + if let Some(captured) = restore.captured { + captured.finish(); + } } -fn install_panic_hook() { +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(); @@ -217,24 +315,57 @@ fn install_panic_hook() { restore_terminal(); previous(info); })); + unsafe { libc::atexit(restore_terminal_at_exit) }; }); } const SIGNAL_RESIZE: u8 = b'w'; const SIGNAL_QUIT: u8 = b'q'; -extern "C" fn on_signal(signal: libc::c_int) { +const HANDLED_SIGNALS: [libc::c_int; 4] = + [libc::SIGWINCH, libc::SIGTERM, libc::SIGHUP, libc::SIGINT]; + +/// Handlers that were installed before ours, e.g. Node's libuv handlers when +/// GPUI is embedded. They keep running so the host still sees its signals. +static PREVIOUS_ACTIONS: 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 { - return; + 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) }; } - let byte = if signal == libc::SIGWINCH { - SIGNAL_RESIZE - } else { - SIGNAL_QUIT + + let Some((_, previous)) = PREVIOUS_ACTIONS + .get() + .and_then(|actions| actions.iter().find(|(handled, _)| *handled == signal)) + else { + return; }; - // 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) }; + let handler = previous.sa_sigaction; + // SIG_DFL would terminate without restoring the terminal; quitting does it. + 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 { @@ -246,17 +377,26 @@ fn install_signal_pipe() -> Result { libc::fcntl(fds[1], libc::F_SETFL, libc::O_NONBLOCK); } SIGNAL_PIPE_WRITE.store(fds[1], Ordering::Relaxed); - for signal in [libc::SIGWINCH, libc::SIGTERM, libc::SIGHUP, libc::SIGINT] { - unsafe { libc::signal(signal, on_signal as *const () as libc::sighandler_t) }; - } + PREVIOUS_ACTIONS.get_or_init(|| { + HANDLED_SIGNALS + .into_iter() + .map(|signal| { + let mut action = unsafe { std::mem::zeroed::() }; + let mut previous = 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); + libc::sigaction(signal, &action, &mut previous); + } + (signal, previous) + }) + .collect() + }); Ok(fds[0]) } -fn read_input( - mut reader: File, - signal_read: RawFd, - sender: &mpsc::Sender, -) -> Result<()> { +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]; diff --git a/crates/gpui_terminal/src/window.rs b/crates/gpui_terminal/src/window.rs index 2657450a304df1..e9b5b6e71d81dd 100644 --- a/crates/gpui_terminal/src/window.rs +++ b/crates/gpui_terminal/src/window.rs @@ -15,6 +15,7 @@ use gpui::{ }; use crate::{ + dispatcher::Wake, kitty::KittyPresenter, tty::{KeyEventKind, MouseEventKind, TerminalEvent, TerminalSize, Tty}, }; @@ -59,6 +60,7 @@ pub(crate) struct WindowInner { tty: Rc>, display: Rc, quit_requested: Rc>, + wake: Wake, state: RefCell, callbacks: RefCell, renderer: RefCell, @@ -74,6 +76,7 @@ impl TerminalWindow { tty: Rc>, display: Rc, quit_requested: Rc>, + wake: Wake, size: TerminalSize, ) -> anyhow::Result { let size = with_pixel_fallback(size); @@ -82,6 +85,7 @@ impl TerminalWindow { tty, display, quit_requested, + wake, state: RefCell::new(WindowState { size, scale_factor, @@ -131,6 +135,12 @@ fn with_pixel_fallback(mut size: TerminalSize) -> TerminalSize { } 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( @@ -542,13 +552,13 @@ impl PlatformWindow for TerminalWindow { let inner = Rc::downgrade(&self.0); Some(Rc::new(move || { if let Some(inner) = inner.upgrade() { - inner.frame_requested.set(true); + inner.request_frame(); } })) } fn schedule_frame(&self) { - self.0.frame_requested.set(true); + self.0.request_frame(); } fn on_request_frame(&self, callback: Box) { From 9411d2480ca795ba7068c99c62703a2e309a1fb2 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 20 Oct 2026 13:24:39 +0200 Subject: [PATCH 5/6] gpui_terminal: Forward only SIGWINCH to the previous handler Bun installs its own SIGTERM handler, which exits on the spot. Forwarding to it killed the process before the terminal was restored. Quit signals now only quit the app; the host exits after the terminal is restored. SIGWINCH is still forwarded so Node keeps seeing resizes. Discord: https://discord.com/channels/1422625037164351591/1562055543466496043 Session: ses_ea7105a47ffeNLpB02sEyo5t9g --- crates/gpui_terminal/src/tty.rs | 47 ++++++++++++++------------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/crates/gpui_terminal/src/tty.rs b/crates/gpui_terminal/src/tty.rs index e0b7d23b3f8e40..8c8b67e35aa645 100644 --- a/crates/gpui_terminal/src/tty.rs +++ b/crates/gpui_terminal/src/tty.rs @@ -322,12 +322,11 @@ fn install_restore_hooks() { const SIGNAL_RESIZE: u8 = b'w'; const SIGNAL_QUIT: u8 = b'q'; -const HANDLED_SIGNALS: [libc::c_int; 4] = - [libc::SIGWINCH, libc::SIGTERM, libc::SIGHUP, libc::SIGINT]; - -/// Handlers that were installed before ours, e.g. Node's libuv handlers when -/// GPUI is embedded. They keep running so the host still sees its signals. -static PREVIOUS_ACTIONS: OnceLock> = OnceLock::new(); +/// 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, @@ -345,14 +344,13 @@ extern "C" fn on_signal( unsafe { libc::write(fd, &byte as *const u8 as *const libc::c_void, 1) }; } - let Some((_, previous)) = PREVIOUS_ACTIONS - .get() - .and_then(|actions| actions.iter().find(|(handled, _)| *handled == signal)) - else { + if signal != libc::SIGWINCH { + return; + } + let Some(previous) = PREVIOUS_RESIZE_ACTION.get() else { return; }; let handler = previous.sa_sigaction; - // SIG_DFL would terminate without restoring the terminal; quitting does it. if handler == libc::SIG_DFL || handler == libc::SIG_IGN { return; } @@ -377,22 +375,17 @@ fn install_signal_pipe() -> Result { libc::fcntl(fds[1], libc::F_SETFL, libc::O_NONBLOCK); } SIGNAL_PIPE_WRITE.store(fds[1], Ordering::Relaxed); - PREVIOUS_ACTIONS.get_or_init(|| { - HANDLED_SIGNALS - .into_iter() - .map(|signal| { - let mut action = unsafe { std::mem::zeroed::() }; - let mut previous = 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); - libc::sigaction(signal, &action, &mut previous); - } - (signal, previous) - }) - .collect() - }); + 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]) } From 8152a6a875559c0a6196b87566861bd78c515966 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 20 Oct 2026 13:53:45 +0200 Subject: [PATCH 6/6] gpui_terminal: Send frames inline when shared memory is not reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Over SSH the terminal runs on another machine and cannot open our POSIX shared memory, so every `t=s` frame failed silently and the screen stayed empty. Detection, like kitty's icat, yazi and ratatui-image: - at window open, send a one pixel `a=q,t=s,f=24,S=3` query on a 3 byte shm object, then `CSI c` - OK → shared memory, unchanged path - error, a DA1 reply first (replies come in order), or no answer in 2 s → inline Inline frames: full RGBA frame, zlib fast (flate2), base64, 4096 byte chunks (`t=d,o=z`, `m=1`..`m=0`, `q=2`) inside the 2026 brackets. Each frame is followed by a one pixel `a=q` fence. No new frame renders while two fences wait for a reply, so SSH and TCP buffers cannot queue seconds of stale frames. A fence without a reply for 2 s stops counting. Parser: `ESC_G i=..;OK|error` becomes TerminalEvent::GraphicsReply and `CSI ? .. c` becomes TerminalEvent::DeviceAttributes. | | bytes per 1600x960 frame | |---|---| | shared memory | ~100 | | inline | ~120 KB (terminal_hello) | Tested with the fake kitty harness in local mode (shm chosen), in remote mode (inline), and with the app in Docker and the harness on the host, where /dev/shm really differs. Writes stay on the main thread; a slow link blocks it while it drains. Discord: https://discord.com/channels/1422625037164351591/1562061946524532766 Session: ses_ea6f90cc0ffej0S2RFLCkQxS5W --- Cargo.lock | 1 + crates/gpui_terminal/Cargo.toml | 1 + crates/gpui_terminal/src/gpui_terminal.rs | 1 + crates/gpui_terminal/src/kitty.rs | 362 +++++++++++++++++++--- crates/gpui_terminal/src/tty.rs | 49 ++- crates/gpui_terminal/src/window.rs | 12 +- 6 files changed, 374 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f72b84d354fd93..a6e087d146530a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7718,6 +7718,7 @@ version = "0.1.0" dependencies = [ "anyhow", "base64 0.22.1", + "flate2", "futures 0.3.32", "gpui", "gpui_macos", diff --git a/crates/gpui_terminal/Cargo.toml b/crates/gpui_terminal/Cargo.toml index c2f2e4cfc821e1..c2277931ef1fd0 100644 --- a/crates/gpui_terminal/Cargo.toml +++ b/crates/gpui_terminal/Cargo.toml @@ -14,6 +14,7 @@ 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 diff --git a/crates/gpui_terminal/src/gpui_terminal.rs b/crates/gpui_terminal/src/gpui_terminal.rs index a2560685e7a1cd..5efb3496c76189 100644 --- a/crates/gpui_terminal/src/gpui_terminal.rs +++ b/crates/gpui_terminal/src/gpui_terminal.rs @@ -11,6 +11,7 @@ //! │ //! ▼ //! 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 //! ``` diff --git a/crates/gpui_terminal/src/kitty.rs b/crates/gpui_terminal/src/kitty.rs index 3832dc1923c130..75f052c02440ce 100644 --- a/crates/gpui_terminal/src/kitty.rs +++ b/crates/gpui_terminal/src/kitty.rs @@ -1,14 +1,28 @@ //! Presents rendered frames with the kitty graphics protocol. //! -//! Each frame is a POSIX shared memory object (`t=s`) that the GPU readback -//! writes into directly, as RGBA, so the pty only carries a ~100 byte escape -//! code and the CPU copies each pixel once. The terminal maps the object, -//! reads it, then unlinks it. 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. +//! 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, fmt::Write as _, io, ptr}; +use std::{ + collections::VecDeque, + ffi::CString, + io::{self, Write as _}, + ptr, + time::{Duration, Instant}, +}; use anyhow::{Context as _, Result}; use base64::Engine as _; @@ -19,31 +33,82 @@ use crate::tty::Tty; /// 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: String, + command: Vec, + /// Reused render target for inline frames. + inline_pixels: Vec, + fences: VecDeque<(u32, Instant)>, + next_fence: u32, } -/// A mapped shared memory object the next frame is rendered into. +/// A frame the next scene is rendered into. pub(crate) struct Frame { - name: CString, - mapping: *mut libc::c_void, - length: usize, width: u32, height: u32, - presented: bool, + 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 Frame { +impl Drop for SharedMemory { fn drop(&mut self) { unsafe { libc::munmap(self.mapping, self.length) }; if !self.presented { @@ -53,54 +118,158 @@ impl Drop for Frame { } impl KittyPresenter { - pub(crate) fn new() -> Self { - Self { + /// 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: String::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); } } - /// Creates the shared memory object for an RGBA frame of `width` x `height`. + 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 { - // 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 length = width as usize * height as usize * 4; - let mapping = map_shared_memory(&name, length)?; + 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 { - name, - mapping, - length, width, height, - presented: false, + target, }) } /// Shows `frame` covering the grid from its top-left cell. - pub(crate) fn present(&mut self, tty: &mut Tty, mut frame: Frame) -> Result<()> { - 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()) }; + 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) + } - let encoded_name = base64::engine::general_purpose::STANDARD.encode(frame.name.as_bytes()); - self.command.clear(); - 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, frame.length, - )?; - frame.presented = true; - self.in_flight.push_back(frame.name.clone()); - drop(frame); - tty.write_all(self.command.as_bytes()) + 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, + }) } } @@ -112,6 +281,45 @@ impl Drop for KittyPresenter { } } +/// 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. @@ -168,3 +376,69 @@ fn allocate(fd: libc::c_int, length: usize) -> Result<()> { 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 index 8c8b67e35aa645..6b9476b3e9fe6e 100644 --- a/crates/gpui_terminal/src/tty.rs +++ b/crates/gpui_terminal/src/tty.rs @@ -102,6 +102,14 @@ pub(crate) enum TerminalEvent { 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, } @@ -596,12 +604,19 @@ impl InputParser { b']' | b'_' | b'P' => { // OSC, APC (kitty graphics replies) and DCS end with ST or BEL. for index in 2..bytes.len() { - if bytes[index] == 0x07 { - return Parsed::Event(index + 1, None); - } - if bytes[index] == 0x1b && bytes.get(index + 1) == Some(&b'\\') { - return Parsed::Event(index + 2, None); - } + 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 } @@ -693,6 +708,7 @@ impl InputParser { 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() { @@ -756,6 +772,21 @@ impl InputParser { } } +/// `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()) @@ -1118,7 +1149,8 @@ mod tests { 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[?1016;2$y\x1b[48;30;100;630;1000t\x1b[I\x1b[9", + 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); @@ -1126,6 +1158,9 @@ mod tests { 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, diff --git a/crates/gpui_terminal/src/window.rs b/crates/gpui_terminal/src/window.rs index e9b5b6e71d81dd..35ad78826b9b74 100644 --- a/crates/gpui_terminal/src/window.rs +++ b/crates/gpui_terminal/src/window.rs @@ -81,6 +81,7 @@ impl TerminalWindow { ) -> 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, @@ -102,7 +103,7 @@ impl TerminalWindow { DevicePixels(size.width as i32), DevicePixels(size.height as i32), ))?), - presenter: RefCell::new(KittyPresenter::new()), + presenter: RefCell::new(presenter), frame_requested: Cell::new(true), last_frame: Cell::new(None), }))) @@ -175,6 +176,9 @@ impl WindowInner { { 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()?; @@ -220,6 +224,12 @@ impl WindowInner { 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), } }