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