Skip to main content

gpui_libghostty/
terminal.rs

1use std::{
2    ffi::{CString, c_void},
3    path::PathBuf,
4    ptr::NonNull,
5    time::Duration,
6};
7
8use gpui::{
9    AppContext as _, Bounds, Context, Entity, FocusHandle, InteractiveElement as _, IntoElement,
10    KeyDownEvent, KeyUpEvent, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _,
11    Pixels, Render, ScrollDelta, ScrollWheelEvent, Styled as _, Task, Window, canvas, div,
12};
13use raw_window_handle::RawWindowHandle;
14
15use crate::native::{KeyAction, Modifiers, MouseButton, MouseState, NativeSurface};
16
17const TICK_INTERVAL: Duration = Duration::from_millis(8);
18
19/// Configuration for a terminal process rendered by libghostty.
20pub struct TerminalOptions {
21    pub command: String,
22    pub working_directory: PathBuf,
23    pub focus_on_spawn: bool,
24}
25
26impl TerminalOptions {
27    pub fn new(command: impl Into<String>, working_directory: impl Into<PathBuf>) -> Self {
28        Self {
29            command: command.into(),
30            working_directory: working_directory.into(),
31            focus_on_spawn: true,
32        }
33    }
34}
35
36/// A GPUI entity backed by Ghostty's native macOS Metal surface.
37pub struct Terminal {
38    surface: NativeSurface,
39    focus: FocusHandle,
40    bounds: Bounds<Pixels>,
41    tick_task: Option<Task<()>>,
42}
43
44impl Terminal {
45    /// Spawns the configured command and attaches its native surface to `window`.
46    pub fn spawn<T: 'static>(
47        options: TerminalOptions,
48        window: &mut Window,
49        cx: &mut Context<T>,
50    ) -> Result<Entity<Self>, String> {
51        let working_directory =
52            CString::new(options.working_directory.to_string_lossy().as_bytes()).map_err(|_| {
53                format!(
54                    "terminal working directory contains a NUL byte: {}",
55                    options.working_directory.display()
56                )
57            })?;
58        let command = CString::new(options.command)
59            .map_err(|_| "terminal command contains a NUL byte".to_owned())?;
60        let parent_view = appkit_view(window)?;
61        let surface = NativeSurface::new(parent_view, &working_directory, &command)
62            .map_err(|error| format!("initialize libghostty: {error}"))?;
63        let focus = cx.focus_handle();
64        if options.focus_on_spawn {
65            focus.focus(window, cx);
66        }
67        Ok(cx.new(|_| Self {
68            surface,
69            focus,
70            bounds: Bounds::default(),
71            tick_task: None,
72        }))
73    }
74
75    pub fn is_alive(&self) -> bool {
76        self.surface.is_alive()
77    }
78
79    pub fn focus<T>(&mut self, window: &mut Window, cx: &mut Context<T>) {
80        self.surface.set_visible(true);
81        self.surface.set_focus(true);
82        self.focus.focus(window, cx);
83    }
84
85    pub fn set_visible(&mut self, visible: bool) {
86        self.surface.set_visible(visible);
87        self.surface.set_focus(visible);
88    }
89
90    fn start_ticking(&mut self, cx: &mut Context<Self>) {
91        if self.tick_task.is_some() {
92            return;
93        }
94        self.surface.tick();
95        let terminal = cx.entity().downgrade();
96        self.tick_task = Some(cx.spawn(async move |_, cx| {
97            loop {
98                cx.background_executor().timer(TICK_INTERVAL).await;
99                let updated = terminal.update(cx, |terminal, cx| {
100                    if terminal.surface.needs_tick() {
101                        terminal.surface.tick();
102                        cx.notify();
103                    }
104                });
105                if updated.is_err() {
106                    break;
107                }
108            }
109        }));
110    }
111
112    fn update_frame(&mut self, bounds: Bounds<Pixels>) {
113        self.bounds = bounds;
114        self.surface.set_frame(
115            f64::from(f32::from(bounds.origin.x)),
116            f64::from(f32::from(bounds.origin.y)),
117            f64::from(f32::from(bounds.size.width)),
118            f64::from(f32::from(bounds.size.height)),
119        );
120        self.surface.set_visible(true);
121    }
122
123    fn key_down(&mut self, event: &KeyDownEvent) {
124        self.send_key(
125            if event.is_held {
126                KeyAction::Repeat
127            } else {
128                KeyAction::Press
129            },
130            &event.keystroke,
131        );
132    }
133
134    fn key_up(&mut self, event: &KeyUpEvent) {
135        self.send_key(KeyAction::Release, &event.keystroke);
136    }
137
138    fn send_key(&mut self, action: KeyAction, keystroke: &gpui::Keystroke) {
139        let (key, implied_shift) = unshifted_macos_key(&keystroke.key);
140        let Some(keycode) = mac_keycode(key) else {
141            if matches!(action, KeyAction::Press | KeyAction::Repeat)
142                && !keystroke.modifiers.control
143                && !keystroke.modifiers.alt
144                && !keystroke.modifiers.platform
145                && let Some(text) = keystroke.key_char.as_deref()
146                && let Ok(text) = CString::new(text)
147            {
148                self.surface.text(&text);
149            }
150            return;
151        };
152        let text = keystroke
153            .key_char
154            .as_deref()
155            .and_then(|text| CString::new(text).ok());
156        let unshifted = key.chars().next().map_or(0, u32::from);
157        let (active_modifiers, consumed_modifiers) =
158            key_modifiers(keystroke.modifiers, implied_shift, text.is_some());
159        let _ = self.surface.key(
160            action,
161            active_modifiers,
162            consumed_modifiers,
163            keycode,
164            text.as_deref(),
165            unshifted,
166        );
167    }
168
169    fn mouse_position(&mut self, position: gpui::Point<Pixels>, modifiers: gpui::Modifiers) {
170        let x = f64::from(f32::from(position.x - self.bounds.origin.x));
171        let y = f64::from(f32::from(position.y - self.bounds.origin.y));
172        self.surface.mouse_position(x, y, modifiers.into());
173    }
174
175    fn mouse_down(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
176        self.focus.focus(window, cx);
177        self.surface.set_focus(true);
178        self.mouse_position(event.position, event.modifiers);
179        self.surface.mouse_button(
180            MouseState::Press,
181            event.button.into(),
182            event.modifiers.into(),
183        );
184    }
185
186    fn mouse_up(&mut self, event: &MouseUpEvent) {
187        self.mouse_position(event.position, event.modifiers);
188        self.surface.mouse_button(
189            MouseState::Release,
190            event.button.into(),
191            event.modifiers.into(),
192        );
193    }
194
195    fn scroll(&mut self, event: &ScrollWheelEvent) {
196        self.mouse_position(event.position, event.modifiers);
197        let (x, y, precision) = match event.delta {
198            ScrollDelta::Pixels(delta) => (
199                f64::from(f32::from(delta.x)),
200                f64::from(f32::from(delta.y)),
201                true,
202            ),
203            ScrollDelta::Lines(delta) => (f64::from(delta.x), f64::from(delta.y), false),
204        };
205        self.surface.mouse_scroll(x, y, precision);
206    }
207}
208
209impl Render for Terminal {
210    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
211        self.start_ticking(cx);
212        let terminal = cx.entity().downgrade();
213        div()
214            .key_context("Terminal")
215            .track_focus(&self.focus)
216            .size_full()
217            .min_h_0()
218            .child(
219                canvas(
220                    move |bounds, _, cx| {
221                        let _ = terminal.update(cx, |terminal, _| terminal.update_frame(bounds));
222                    },
223                    |_, _, _, _| {},
224                )
225                .absolute()
226                .size_full(),
227            )
228            .on_key_down(cx.listener(|terminal, event, _, _| terminal.key_down(event)))
229            .on_key_up(cx.listener(|terminal, event, _, _| terminal.key_up(event)))
230            .on_mouse_move(cx.listener(|terminal, event: &MouseMoveEvent, _, _| {
231                terminal.mouse_position(event.position, event.modifiers);
232            }))
233            .on_mouse_down(
234                gpui::MouseButton::Left,
235                cx.listener(|terminal, event, window, cx| terminal.mouse_down(event, window, cx)),
236            )
237            .on_mouse_down(
238                gpui::MouseButton::Middle,
239                cx.listener(|terminal, event, window, cx| terminal.mouse_down(event, window, cx)),
240            )
241            .on_mouse_down(
242                gpui::MouseButton::Right,
243                cx.listener(|terminal, event, window, cx| terminal.mouse_down(event, window, cx)),
244            )
245            .on_mouse_up(
246                gpui::MouseButton::Left,
247                cx.listener(|terminal, event, _, _| terminal.mouse_up(event)),
248            )
249            .on_mouse_up(
250                gpui::MouseButton::Middle,
251                cx.listener(|terminal, event, _, _| terminal.mouse_up(event)),
252            )
253            .on_mouse_up(
254                gpui::MouseButton::Right,
255                cx.listener(|terminal, event, _, _| terminal.mouse_up(event)),
256            )
257            .on_scroll_wheel(cx.listener(|terminal, event, _, _| terminal.scroll(event)))
258    }
259}
260
261impl From<gpui::Modifiers> for Modifiers {
262    fn from(value: gpui::Modifiers) -> Self {
263        modifiers(value)
264    }
265}
266
267impl From<gpui::MouseButton> for MouseButton {
268    fn from(value: gpui::MouseButton) -> Self {
269        match value {
270            gpui::MouseButton::Left => Self::Left,
271            gpui::MouseButton::Right => Self::Right,
272            gpui::MouseButton::Middle => Self::Middle,
273            gpui::MouseButton::Navigate(_) => Self::Unknown,
274        }
275    }
276}
277
278fn appkit_view(window: &Window) -> Result<NonNull<c_void>, String> {
279    let handle = raw_window_handle::HasWindowHandle::window_handle(window)
280        .map_err(|error| format!("read native window handle: {error}"))?;
281    match handle.as_raw() {
282        RawWindowHandle::AppKit(handle) => Ok(handle.ns_view),
283        _ => Err("libghostty native surfaces are currently available only on macOS".to_owned()),
284    }
285}
286
287fn modifiers(value: gpui::Modifiers) -> Modifiers {
288    let mut result = Modifiers::empty();
289    if value.shift {
290        result.insert(Modifiers::SHIFT);
291    }
292    if value.control {
293        result.insert(Modifiers::CONTROL);
294    }
295    if value.alt {
296        result.insert(Modifiers::ALT);
297    }
298    if value.platform {
299        result.insert(Modifiers::SUPER);
300    }
301    result
302}
303
304fn key_modifiers(
305    mut value: gpui::Modifiers,
306    implied_shift: bool,
307    has_text: bool,
308) -> (Modifiers, Modifiers) {
309    value.shift |= implied_shift;
310    let active = modifiers(value);
311    let mut consumed = Modifiers::empty();
312    if has_text && value.shift {
313        consumed.insert(Modifiers::SHIFT);
314    }
315    (active, consumed)
316}
317
318fn unshifted_macos_key(key: &str) -> (&str, bool) {
319    match key {
320        "!" => ("1", true),
321        "@" => ("2", true),
322        "#" => ("3", true),
323        "$" => ("4", true),
324        "%" => ("5", true),
325        "^" => ("6", true),
326        "&" => ("7", true),
327        "*" => ("8", true),
328        "(" => ("9", true),
329        ")" => ("0", true),
330        "_" => ("-", true),
331        "+" => ("=", true),
332        "{" => ("[", true),
333        "}" => ("]", true),
334        "|" => ("\\", true),
335        ":" => (";", true),
336        "\"" => ("'", true),
337        "<" => (",", true),
338        ">" => (".", true),
339        "?" => ("/", true),
340        "~" => ("`", true),
341        _ => (key, false),
342    }
343}
344
345fn mac_keycode(key: &str) -> Option<u32> {
346    Some(match key {
347        "a" => 0,
348        "s" => 1,
349        "d" => 2,
350        "f" => 3,
351        "h" => 4,
352        "g" => 5,
353        "z" => 6,
354        "x" => 7,
355        "c" => 8,
356        "v" => 9,
357        "b" => 11,
358        "q" => 12,
359        "w" => 13,
360        "e" => 14,
361        "r" => 15,
362        "y" => 16,
363        "t" => 17,
364        "1" => 18,
365        "2" => 19,
366        "3" => 20,
367        "4" => 21,
368        "6" => 22,
369        "5" => 23,
370        "=" => 24,
371        "9" => 25,
372        "7" => 26,
373        "-" => 27,
374        "8" => 28,
375        "0" => 29,
376        "]" => 30,
377        "o" => 31,
378        "u" => 32,
379        "[" => 33,
380        "i" => 34,
381        "p" => 35,
382        "enter" | "return" => 36,
383        "l" => 37,
384        "j" => 38,
385        "'" => 39,
386        "k" => 40,
387        ";" => 41,
388        "\\" => 42,
389        "," => 43,
390        "/" => 44,
391        "n" => 45,
392        "m" => 46,
393        "." => 47,
394        "tab" => 48,
395        "space" => 49,
396        "`" => 50,
397        "backspace" => 51,
398        "escape" => 53,
399        "f17" => 64,
400        "f18" => 79,
401        "f19" => 80,
402        "f20" => 90,
403        "f5" => 96,
404        "f6" => 97,
405        "f7" => 98,
406        "f3" => 99,
407        "f8" => 100,
408        "f9" => 101,
409        "f11" => 103,
410        "f13" => 105,
411        "f16" => 106,
412        "f14" => 107,
413        "f10" => 109,
414        "f12" => 111,
415        "f15" => 113,
416        "home" => 115,
417        "pageup" | "page_up" | "page-up" => 116,
418        "delete" => 117,
419        "f4" => 118,
420        "end" => 119,
421        "f2" => 120,
422        "pagedown" | "page_down" | "page-down" => 121,
423        "left" => 123,
424        "right" => 124,
425        "down" => 125,
426        "up" => 126,
427        _ => return None,
428    })
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn keycode_mapping_covers_terminal_navigation_and_repeat_keys() {
437        for key in ["j", "k", "up", "down", "pageup", "pagedown", "escape"] {
438            assert!(mac_keycode(key).is_some(), "missing keycode for {key}");
439        }
440    }
441
442    #[test]
443    fn shifted_punctuation_uses_a_physical_key_event() {
444        for (shifted, unshifted) in [
445            ("!", "1"),
446            ("*", "8"),
447            ("+", "="),
448            ("{", "["),
449            ("|", "\\"),
450            (":", ";"),
451            ("\"", "'"),
452            ("?", "/"),
453            ("~", "`"),
454        ] {
455            let (key, implied_shift) = unshifted_macos_key(shifted);
456            let (active, consumed) = key_modifiers(gpui::Modifiers::default(), implied_shift, true);
457            assert_eq!(key, unshifted);
458            assert!(implied_shift);
459            assert!(mac_keycode(key).is_some());
460            assert_eq!(active, Modifiers::SHIFT);
461            assert_eq!(consumed, Modifiers::SHIFT);
462        }
463    }
464}