Skip to main content

vtcode_ghostty_vt_sys/
lib.rs

1use anyhow::{Context, Result, anyhow};
2use libloading::Library;
3use std::ffi::c_void;
4use std::os::raw::{c_int, c_uint};
5use std::path::{Path, PathBuf};
6use std::sync::OnceLock;
7
8#[derive(Debug, Clone, Copy)]
9pub struct GhosttyRenderRequest {
10    pub cols: u16,
11    pub rows: u16,
12    pub scrollback_lines: usize,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct GhosttyRenderOutput {
17    pub screen_contents: String,
18    pub scrollback: String,
19}
20
21pub fn render_terminal_snapshot(
22    request: GhosttyRenderRequest,
23    vt_stream: &[u8],
24) -> Result<GhosttyRenderOutput> {
25    if vt_stream.is_empty() {
26        return Ok(GhosttyRenderOutput {
27            screen_contents: String::new(),
28            scrollback: String::new(),
29        });
30    }
31
32    platform::render_terminal_snapshot(request, vt_stream)
33}
34
35fn unavailable_error() -> anyhow::Error {
36    anyhow!("Ghostty VT library is unavailable; falling back to legacy_vt100")
37}
38
39#[cfg(any(target_os = "linux", target_os = "macos"))]
40mod platform {
41    use super::{
42        Context, GhosttyRenderOutput, GhosttyRenderRequest, Library, OnceLock, Path, PathBuf,
43        Result, anyhow, c_int, c_uint, c_void, unavailable_error,
44    };
45    use std::cmp;
46    use std::mem;
47
48    const GHOSTTY_SUCCESS: c_int = 0;
49    const GHOSTTY_OUT_OF_SPACE: c_int = -3;
50
51    const GHOSTTY_CELL_DATA_CODEPOINT: c_uint = 1;
52    const GHOSTTY_CELL_DATA_WIDE: c_uint = 3;
53    const GHOSTTY_CELL_DATA_HAS_TEXT: c_uint = 4;
54    const GHOSTTY_CELL_WIDE_SPACER_TAIL: c_uint = 2;
55    const GHOSTTY_CELL_WIDE_SPACER_HEAD: c_uint = 3;
56    const GHOSTTY_ROW_DATA_WRAP: c_uint = 1;
57    const GHOSTTY_POINT_TAG_ACTIVE: c_uint = 0;
58    const GHOSTTY_POINT_TAG_SCREEN: c_uint = 2;
59    const GHOSTTY_TERMINAL_DATA_SCROLLBAR: c_uint = 9;
60
61    type GhosttyTerminal = *mut c_void;
62    type GhosttyCell = u64;
63    type GhosttyRow = u64;
64    type GhosttyResult = c_int;
65    type GhosttyTerminalNew = unsafe extern "C" fn(
66        allocator: *const GhosttyAllocator,
67        terminal: *mut GhosttyTerminal,
68        options: GhosttyTerminalOptions,
69    ) -> GhosttyResult;
70    type GhosttyTerminalFree = unsafe extern "C" fn(terminal: GhosttyTerminal);
71    type GhosttyTerminalGet = unsafe extern "C" fn(
72        terminal: GhosttyTerminal,
73        data: c_uint,
74        out: *mut c_void,
75    ) -> GhosttyResult;
76    type GhosttyTerminalGridRef = unsafe extern "C" fn(
77        terminal: GhosttyTerminal,
78        point: GhosttyPoint,
79        out_ref: *mut GhosttyGridRef,
80    ) -> GhosttyResult;
81    type GhosttyTerminalVtWrite =
82        unsafe extern "C" fn(terminal: GhosttyTerminal, data: *const u8, len: usize);
83    type GhosttyGridRefCell = unsafe extern "C" fn(
84        ref_: *const GhosttyGridRef,
85        out_cell: *mut GhosttyCell,
86    ) -> GhosttyResult;
87    type GhosttyGridRefRow = unsafe extern "C" fn(
88        ref_: *const GhosttyGridRef,
89        out_row: *mut GhosttyRow,
90    ) -> GhosttyResult;
91    type GhosttyGridRefGraphemes = unsafe extern "C" fn(
92        ref_: *const GhosttyGridRef,
93        buf: *mut u32,
94        buf_len: usize,
95        out_len: *mut usize,
96    ) -> GhosttyResult;
97    type GhosttyCellGet =
98        unsafe extern "C" fn(cell: GhosttyCell, data: c_uint, out: *mut c_void) -> GhosttyResult;
99    type GhosttyRowGet =
100        unsafe extern "C" fn(row: GhosttyRow, data: c_uint, out: *mut c_void) -> GhosttyResult;
101
102    #[repr(C)]
103    struct GhosttyAllocator {
104        _unused: [u8; 0],
105    }
106
107    #[repr(C)]
108    #[derive(Clone, Copy)]
109    struct GhosttyTerminalOptions {
110        cols: u16,
111        rows: u16,
112        max_scrollback: usize,
113    }
114
115    #[repr(C)]
116    #[derive(Default, Clone, Copy)]
117    struct GhosttyGridRef {
118        size: usize,
119        node: *mut c_void,
120        x: u16,
121        y: u16,
122    }
123
124    #[repr(C)]
125    #[derive(Default, Clone, Copy)]
126    struct GhosttyPointCoordinate {
127        x: u16,
128        y: u32,
129    }
130
131    #[repr(C)]
132    #[derive(Clone, Copy)]
133    union GhosttyPointValue {
134        coordinate: GhosttyPointCoordinate,
135        padding: [u64; 2],
136    }
137
138    impl Default for GhosttyPointValue {
139        fn default() -> Self {
140            Self { padding: [0; 2] }
141        }
142    }
143
144    #[repr(C)]
145    #[derive(Default, Clone, Copy)]
146    struct GhosttyPoint {
147        tag: c_uint,
148        value: GhosttyPointValue,
149    }
150
151    #[repr(C)]
152    #[derive(Default, Clone, Copy)]
153    struct GhosttyTerminalScrollbar {
154        total: u64,
155        offset: u64,
156        len: u64,
157    }
158
159    #[derive(Debug)]
160    struct GhosttyApi {
161        _library: Library,
162        terminal_new: GhosttyTerminalNew,
163        terminal_free: GhosttyTerminalFree,
164        terminal_get: GhosttyTerminalGet,
165        terminal_grid_ref: GhosttyTerminalGridRef,
166        terminal_vt_write: GhosttyTerminalVtWrite,
167        grid_ref_cell: GhosttyGridRefCell,
168        grid_ref_row: GhosttyGridRefRow,
169        grid_ref_graphemes: GhosttyGridRefGraphemes,
170        cell_get: GhosttyCellGet,
171        row_get: GhosttyRowGet,
172    }
173
174    struct TerminalHandle<'a> {
175        api: &'a GhosttyApi,
176        raw: GhosttyTerminal,
177    }
178
179    impl Drop for TerminalHandle<'_> {
180        fn drop(&mut self) {
181            if self.raw.is_null() {
182                return;
183            }
184            // SAFETY: `raw` is owned by this handle and was returned by `ghostty_terminal_new`.
185            unsafe { (self.api.terminal_free)(self.raw) };
186        }
187    }
188
189    pub(super) fn render_terminal_snapshot(
190        request: GhosttyRenderRequest,
191        vt_stream: &[u8],
192    ) -> Result<GhosttyRenderOutput> {
193        let api = GhosttyApi::load()?;
194        render_snapshot_with_api(api, request, vt_stream)
195    }
196
197    impl GhosttyApi {
198        fn load() -> Result<&'static Self> {
199            static API: OnceLock<std::result::Result<GhosttyApi, String>> = OnceLock::new();
200
201            match API.get_or_init(|| {
202                Self::load_from_dirs(runtime_library_dirs()).map_err(|error| error.to_string())
203            }) {
204                Ok(api) => Ok(api),
205                Err(error) => Err(anyhow!(error.clone())),
206            }
207        }
208
209        fn load_from_dirs(dirs: Vec<PathBuf>) -> Result<Self> {
210            let candidates = candidate_library_paths_from_dirs(dirs);
211            if candidates.is_empty() {
212                return Err(unavailable_error());
213            }
214
215            let mut errors = Vec::new();
216            for candidate in candidates {
217                match Self::load_from_path(&candidate) {
218                    Ok(api) => return Ok(api),
219                    Err(error) => errors.push(format!("{}: {error}", candidate.display())),
220                }
221            }
222
223            Err(anyhow!("{} ({})", unavailable_error(), errors.join("; ")))
224        }
225
226        fn load_from_path(path: &Path) -> Result<Self> {
227            // SAFETY: Loading the packaged Ghostty runtime library is inherently unsafe. The path
228            // comes from VT Code-controlled package locations and the symbols are validated below.
229            let library = unsafe { Library::new(path) }
230                .with_context(|| format!("failed to load {}", path.display()))?;
231
232            Ok(Self {
233                terminal_new: load_symbol(&library, b"ghostty_terminal_new\0")?,
234                terminal_free: load_symbol(&library, b"ghostty_terminal_free\0")?,
235                terminal_get: load_symbol(&library, b"ghostty_terminal_get\0")?,
236                terminal_grid_ref: load_symbol(&library, b"ghostty_terminal_grid_ref\0")?,
237                terminal_vt_write: load_symbol(&library, b"ghostty_terminal_vt_write\0")?,
238                grid_ref_cell: load_symbol(&library, b"ghostty_grid_ref_cell\0")?,
239                grid_ref_row: load_symbol(&library, b"ghostty_grid_ref_row\0")?,
240                grid_ref_graphemes: load_symbol(&library, b"ghostty_grid_ref_graphemes\0")?,
241                cell_get: load_symbol(&library, b"ghostty_cell_get\0")?,
242                row_get: load_symbol(&library, b"ghostty_row_get\0")?,
243                _library: library,
244            })
245        }
246    }
247
248    impl TerminalHandle<'_> {
249        fn new(api: &GhosttyApi, request: GhosttyRenderRequest) -> Result<TerminalHandle<'_>> {
250            let mut raw = std::ptr::null_mut();
251            let options = GhosttyTerminalOptions {
252                cols: request.cols,
253                rows: request.rows,
254                max_scrollback: request.scrollback_lines,
255            };
256
257            // SAFETY: `raw` points to writable storage and `options` matches the upstream layout.
258            ensure_success(
259                unsafe { (api.terminal_new)(std::ptr::null(), &mut raw, options) },
260                "failed to create Ghostty terminal",
261            )?;
262            if raw.is_null() {
263                return Err(anyhow!(
264                    "failed to create Ghostty terminal: Ghostty returned null"
265                ));
266            }
267
268            Ok(TerminalHandle { api, raw })
269        }
270    }
271
272    fn render_region(
273        api: &GhosttyApi,
274        terminal: GhosttyTerminal,
275        tag: c_uint,
276        row_count: u32,
277        cols: u16,
278    ) -> Result<String> {
279        let row_count_hint = usize::try_from(row_count).unwrap_or(usize::MAX);
280        let col_count_hint = usize::from(cols);
281        let capacity = row_count_hint.saturating_mul(col_count_hint.saturating_add(1));
282        let mut output = String::with_capacity(capacity);
283
284        for row in 0..row_count {
285            let row_start = output.len();
286
287            for col in 0..cols {
288                let point = grid_point(tag, col, row);
289                let mut grid_ref = sized::<GhosttyGridRef>();
290
291                // SAFETY: `terminal` is valid, `point` is initialized, and `grid_ref` points to
292                // writable storage for the returned opaque reference.
293                let ref_result = unsafe { (api.terminal_grid_ref)(terminal, point, &mut grid_ref) };
294                if ref_result != GHOSTTY_SUCCESS {
295                    output.push(' ');
296                    continue;
297                }
298
299                let mut cell = 0;
300                // SAFETY: `grid_ref` is initialized and `cell` points to writable storage.
301                let cell_result = unsafe { (api.grid_ref_cell)(&grid_ref, &mut cell) };
302                if cell_result != GHOSTTY_SUCCESS {
303                    output.push(' ');
304                    continue;
305                }
306
307                append_cell_text(api, &mut output, &grid_ref, cell)?;
308            }
309
310            let wrap = row_wraps(api, terminal, tag, row)?;
311            trim_trailing_spaces(&mut output, row_start);
312            if !wrap && row + 1 < row_count {
313                output.push('\n');
314            }
315        }
316
317        Ok(output)
318    }
319
320    fn append_cell_text(
321        api: &GhosttyApi,
322        output: &mut String,
323        grid_ref: &GhosttyGridRef,
324        cell: GhosttyCell,
325    ) -> Result<()> {
326        let mut wide = 0u32;
327        // SAFETY: `wide` points to writable storage for the requested cell width field.
328        ensure_success(
329            unsafe { (api.cell_get)(cell, GHOSTTY_CELL_DATA_WIDE, (&mut wide as *mut u32).cast()) },
330            "failed to read Ghostty cell width",
331        )?;
332
333        if wide == GHOSTTY_CELL_WIDE_SPACER_TAIL || wide == GHOSTTY_CELL_WIDE_SPACER_HEAD {
334            return Ok(());
335        }
336
337        let mut has_text = false;
338        // SAFETY: `has_text` points to writable storage for the requested boolean field.
339        ensure_success(
340            unsafe {
341                (api.cell_get)(
342                    cell,
343                    GHOSTTY_CELL_DATA_HAS_TEXT,
344                    (&mut has_text as *mut bool).cast(),
345                )
346            },
347            "failed to read Ghostty cell text flag",
348        )?;
349
350        if !has_text {
351            output.push(' ');
352            return Ok(());
353        }
354
355        let mut grapheme_len = 0usize;
356        // SAFETY: Passing a null buffer is the documented way to query grapheme length.
357        let grapheme_result = unsafe {
358            (api.grid_ref_graphemes)(grid_ref, std::ptr::null_mut(), 0, &mut grapheme_len)
359        };
360        if grapheme_result == GHOSTTY_OUT_OF_SPACE && grapheme_len > 0 {
361            let mut codepoints = vec![0u32; grapheme_len];
362            // SAFETY: `codepoints` provides writable storage for the reported grapheme length.
363            ensure_success(
364                unsafe {
365                    (api.grid_ref_graphemes)(
366                        grid_ref,
367                        codepoints.as_mut_ptr(),
368                        codepoints.len(),
369                        &mut grapheme_len,
370                    )
371                },
372                "failed to read Ghostty grapheme cluster",
373            )?;
374
375            for codepoint in codepoints.into_iter().take(grapheme_len) {
376                push_codepoint(output, codepoint);
377            }
378            return Ok(());
379        }
380
381        let mut codepoint = 0u32;
382        // SAFETY: `codepoint` points to writable storage for the requested codepoint field.
383        ensure_success(
384            unsafe {
385                (api.cell_get)(
386                    cell,
387                    GHOSTTY_CELL_DATA_CODEPOINT,
388                    (&mut codepoint as *mut u32).cast(),
389                )
390            },
391            "failed to read Ghostty codepoint",
392        )?;
393
394        push_codepoint(output, codepoint);
395        Ok(())
396    }
397
398    fn row_wraps(
399        api: &GhosttyApi,
400        terminal: GhosttyTerminal,
401        tag: c_uint,
402        row: u32,
403    ) -> Result<bool> {
404        let mut grid_ref = sized::<GhosttyGridRef>();
405        // SAFETY: `grid_ref` points to writable storage for the requested row reference.
406        let ref_result =
407            unsafe { (api.terminal_grid_ref)(terminal, grid_point(tag, 0, row), &mut grid_ref) };
408        if ref_result != GHOSTTY_SUCCESS {
409            return Ok(false);
410        }
411
412        let mut grid_row = 0;
413        // SAFETY: `grid_row` points to writable storage for the row handle.
414        let row_result = unsafe { (api.grid_ref_row)(&grid_ref, &mut grid_row) };
415        if row_result != GHOSTTY_SUCCESS {
416            return Ok(false);
417        }
418
419        let mut wrap = false;
420        // SAFETY: `wrap` points to writable storage for the requested row field.
421        let wrap_result = unsafe {
422            (api.row_get)(
423                grid_row,
424                GHOSTTY_ROW_DATA_WRAP,
425                (&mut wrap as *mut bool).cast(),
426            )
427        };
428        if wrap_result != GHOSTTY_SUCCESS {
429            return Ok(false);
430        }
431
432        Ok(wrap)
433    }
434
435    fn runtime_library_dirs() -> Vec<PathBuf> {
436        let mut roots = Vec::new();
437        if let Ok(current_exe) = std::env::current_exe()
438            && let Some(exe_dir) = current_exe.parent()
439        {
440            push_unique(&mut roots, exe_dir.join("ghostty-vt"));
441            push_unique(&mut roots, exe_dir.to_path_buf());
442        }
443        roots
444    }
445
446    fn render_snapshot_with_api(
447        api: &GhosttyApi,
448        request: GhosttyRenderRequest,
449        vt_stream: &[u8],
450    ) -> Result<GhosttyRenderOutput> {
451        let terminal = TerminalHandle::new(api, request)?;
452
453        // SAFETY: `terminal.raw` is valid for the duration of the call and the slice pointer/len
454        // pair remains valid across the FFI call.
455        unsafe { (api.terminal_vt_write)(terminal.raw, vt_stream.as_ptr(), vt_stream.len()) };
456
457        let screen_contents = render_region(
458            api,
459            terminal.raw,
460            GHOSTTY_POINT_TAG_ACTIVE,
461            u32::from(request.rows),
462            request.cols,
463        )?;
464
465        let total_rows = query_total_rows(api, terminal.raw, request.rows)?;
466        let scrollback = render_region(
467            api,
468            terminal.raw,
469            GHOSTTY_POINT_TAG_SCREEN,
470            total_rows,
471            request.cols,
472        )?;
473
474        Ok(GhosttyRenderOutput {
475            screen_contents,
476            scrollback,
477        })
478    }
479
480    fn query_total_rows(api: &GhosttyApi, terminal: GhosttyTerminal, rows: u16) -> Result<u32> {
481        let mut scrollbar = GhosttyTerminalScrollbar::default();
482        // SAFETY: `scrollbar` points to writable storage for the requested result type.
483        ensure_success(
484            unsafe {
485                (api.terminal_get)(
486                    terminal,
487                    GHOSTTY_TERMINAL_DATA_SCROLLBAR,
488                    (&mut scrollbar as *mut GhosttyTerminalScrollbar).cast(),
489                )
490            },
491            "failed to query Ghostty scrollbar state",
492        )?;
493
494        let total_rows = cmp::max(scrollbar.total, u64::from(rows));
495        u32::try_from(total_rows)
496            .map_err(|_| anyhow!("Ghostty screen too large to render: {total_rows}"))
497    }
498
499    fn candidate_library_paths_from_dirs(dirs: Vec<PathBuf>) -> Vec<PathBuf> {
500        let mut paths = Vec::new();
501
502        for dir in dirs {
503            let Ok(entries) = std::fs::read_dir(&dir) else {
504                continue;
505            };
506
507            let mut preferred = Vec::new();
508            let mut versioned = Vec::new();
509
510            for entry in entries.flatten() {
511                let path = entry.path();
512                if !path.is_file() {
513                    continue;
514                }
515
516                let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
517                    continue;
518                };
519                if !is_runtime_library_name(name) {
520                    continue;
521                }
522
523                if is_preferred_runtime_library_name(name) {
524                    preferred.push(path);
525                } else {
526                    versioned.push(path);
527                }
528            }
529
530            preferred.sort();
531            versioned.sort();
532            paths.extend(preferred);
533            paths.extend(versioned);
534        }
535
536        paths
537    }
538
539    fn is_runtime_library_name(name: &str) -> bool {
540        if cfg!(target_os = "macos") {
541            name.starts_with("libghostty-vt") && name.ends_with(".dylib")
542        } else {
543            name.starts_with("libghostty-vt") && name.contains(".so")
544        }
545    }
546
547    fn is_preferred_runtime_library_name(name: &str) -> bool {
548        if cfg!(target_os = "macos") {
549            name == "libghostty-vt.dylib"
550        } else {
551            name == "libghostty-vt.so"
552        }
553    }
554
555    fn grid_point(tag: c_uint, x: u16, y: u32) -> GhosttyPoint {
556        GhosttyPoint {
557            tag,
558            value: GhosttyPointValue {
559                coordinate: GhosttyPointCoordinate { x, y },
560            },
561        }
562    }
563
564    fn ensure_success(result: GhosttyResult, context: &str) -> Result<()> {
565        if result == GHOSTTY_SUCCESS {
566            Ok(())
567        } else {
568            Err(anyhow!("{context}: Ghostty returned {result}"))
569        }
570    }
571
572    fn push_codepoint(output: &mut String, codepoint: u32) {
573        if let Some(ch) = char::from_u32(codepoint) {
574            output.push(ch);
575        } else {
576            output.push(char::REPLACEMENT_CHARACTER);
577        }
578    }
579
580    fn trim_trailing_spaces(output: &mut String, floor: usize) {
581        while output.len() > floor && output.as_bytes().last().copied() == Some(b' ') {
582            let _ = output.pop();
583        }
584    }
585
586    fn push_unique(values: &mut Vec<PathBuf>, value: PathBuf) {
587        if !values.iter().any(|existing| existing == &value) {
588            values.push(value);
589        }
590    }
591
592    fn sized<T: Default>() -> T {
593        let mut value = T::default();
594        // SAFETY: All callers use FFI structs whose first field is the `size` field expected by
595        // Ghostty. This mirrors the upstream `sized!` helper from `libghostty-rs`.
596        unsafe {
597            let size_ptr = (&mut value as *mut T).cast::<usize>();
598            *size_ptr = mem::size_of::<T>();
599        }
600        value
601    }
602
603    fn load_symbol<T: Copy>(library: &Library, name: &[u8]) -> Result<T> {
604        // SAFETY: The symbol names and types match the upstream `libghostty-rs` bindings.
605        let symbol = unsafe { library.get::<T>(name) }
606            .with_context(|| format!("missing symbol {}", String::from_utf8_lossy(name)))?;
607        Ok(*symbol)
608    }
609
610    #[cfg(test)]
611    fn test_asset_dirs() -> Vec<PathBuf> {
612        let mut dirs = Vec::new();
613        if let Some(asset_dir) = option_env!("VTCODE_GHOSTTY_VT_TEST_ASSET_DIR")
614            .filter(|value| !value.is_empty())
615            .map(PathBuf::from)
616        {
617            push_unique(&mut dirs, asset_dir.join("lib"));
618            push_unique(&mut dirs, asset_dir);
619        }
620        dirs
621    }
622
623    #[cfg(test)]
624    fn real_ghostty_available() -> bool {
625        !test_asset_dirs().is_empty() && GhosttyApi::load_from_dirs(test_asset_dirs()).is_ok()
626    }
627
628    #[cfg(test)]
629    fn render_with_test_assets(
630        request: GhosttyRenderRequest,
631        vt_stream: &[u8],
632    ) -> Result<GhosttyRenderOutput> {
633        let api = GhosttyApi::load_from_dirs(test_asset_dirs())?;
634        render_snapshot_with_api(&api, request, vt_stream)
635    }
636
637    #[cfg(test)]
638    mod tests {
639        use super::{GhosttyApi, candidate_library_paths_from_dirs, real_ghostty_available};
640        use crate::{GhosttyRenderOutput, GhosttyRenderRequest};
641
642        #[test]
643        fn empty_dirs_report_unavailable() {
644            let error = GhosttyApi::load_from_dirs(Vec::new()).expect_err("missing dirs must fail");
645            assert!(error.to_string().contains("legacy_vt100"));
646        }
647
648        #[test]
649        fn candidate_library_paths_prioritize_unversioned_names() {
650            let temp = tempfile::tempdir().expect("tempdir");
651            let root = temp.path();
652            let preferred = if cfg!(target_os = "macos") {
653                root.join("libghostty-vt.dylib")
654            } else {
655                root.join("libghostty-vt.so")
656            };
657            let versioned = if cfg!(target_os = "macos") {
658                root.join("libghostty-vt.0.1.0.dylib")
659            } else {
660                root.join("libghostty-vt.so.0.1.0")
661            };
662            std::fs::write(&preferred, b"").expect("preferred");
663            std::fs::write(&versioned, b"").expect("versioned");
664
665            let candidates = candidate_library_paths_from_dirs(vec![root.to_path_buf()]);
666            assert_eq!(candidates.first(), Some(&preferred));
667            assert_eq!(candidates.get(1), Some(&versioned));
668        }
669
670        #[test]
671        fn renders_plain_text_when_test_assets_are_available() {
672            if !real_ghostty_available() {
673                return;
674            }
675
676            let output = super::render_with_test_assets(
677                GhosttyRenderRequest {
678                    cols: 5,
679                    rows: 1,
680                    scrollback_lines: 16,
681                },
682                b"hello",
683            )
684            .expect("plain text should render");
685
686            assert_eq!(
687                output,
688                GhosttyRenderOutput {
689                    screen_contents: "hello".to_string(),
690                    scrollback: "hello".to_string(),
691                }
692            );
693        }
694
695        #[test]
696        fn wrapped_rows_do_not_insert_newlines_when_test_assets_are_available() {
697            if !real_ghostty_available() {
698                return;
699            }
700
701            let output = super::render_with_test_assets(
702                GhosttyRenderRequest {
703                    cols: 5,
704                    rows: 2,
705                    scrollback_lines: 16,
706                },
707                b"helloworld",
708            )
709            .expect("wrapped text should render");
710
711            assert_eq!(output.screen_contents, "helloworld");
712        }
713
714        #[test]
715        fn trims_trailing_spaces_when_test_assets_are_available() {
716            if !real_ghostty_available() {
717                return;
718            }
719
720            let output = super::render_with_test_assets(
721                GhosttyRenderRequest {
722                    cols: 6,
723                    rows: 1,
724                    scrollback_lines: 16,
725                },
726                b"hi   ",
727            )
728            .expect("trailing spaces should trim");
729
730            assert_eq!(output.screen_contents, "hi");
731        }
732
733        #[test]
734        fn wide_cells_skip_spacers_when_test_assets_are_available() {
735            if !real_ghostty_available() {
736                return;
737            }
738
739            let output = super::render_with_test_assets(
740                GhosttyRenderRequest {
741                    cols: 4,
742                    rows: 1,
743                    scrollback_lines: 16,
744                },
745                "你a".as_bytes(),
746            )
747            .expect("wide glyphs should render");
748
749            assert_eq!(output.screen_contents, "你a");
750        }
751
752        #[test]
753        fn scrollback_renders_full_screen_when_test_assets_are_available() {
754            if !real_ghostty_available() {
755                return;
756            }
757
758            let output = super::render_with_test_assets(
759                GhosttyRenderRequest {
760                    cols: 5,
761                    rows: 1,
762                    scrollback_lines: 16,
763                },
764                b"one\r\ntwo",
765            )
766            .expect("scrollback should render");
767
768            assert_eq!(output.screen_contents, "two");
769            assert_eq!(output.scrollback, "one\ntwo");
770        }
771    }
772}
773
774#[cfg(not(any(target_os = "linux", target_os = "macos")))]
775mod platform {
776    use super::{GhosttyRenderOutput, GhosttyRenderRequest, Result, unavailable_error};
777
778    pub(super) fn render_terminal_snapshot(
779        _request: GhosttyRenderRequest,
780        _vt_stream: &[u8],
781    ) -> Result<GhosttyRenderOutput> {
782        Err(unavailable_error())
783    }
784}
785
786#[cfg(test)]
787mod tests {
788    use super::{GhosttyRenderOutput, GhosttyRenderRequest, render_terminal_snapshot};
789
790    #[test]
791    fn empty_vt_stream_returns_empty_snapshot() {
792        let output = render_terminal_snapshot(
793            GhosttyRenderRequest {
794                cols: 80,
795                rows: 24,
796                scrollback_lines: 1000,
797            },
798            &[],
799        )
800        .expect("empty VT stream should not require Ghostty");
801
802        assert_eq!(
803            output,
804            GhosttyRenderOutput {
805                screen_contents: String::new(),
806                scrollback: String::new(),
807            }
808        );
809    }
810}