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