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