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
19pub 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
36pub struct Terminal {
38 surface: NativeSurface,
39 focus: FocusHandle,
40 bounds: Bounds<Pixels>,
41 tick_task: Option<Task<()>>,
42}
43
44impl Terminal {
45 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}