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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}