ref:main
//! Terminal state management
//!
//! Manages the Alacritty terminal emulator instance, PTY lifecycle,
//! VTE parsing, and rendering. Adapted from godot-alacritty with
//! all Godot types removed for use via JNI.
use alacritty_terminal::event::{Event, EventListener};
use alacritty_terminal::grid::{Dimensions, Scroll};
use alacritty_terminal::index::{Column, Line};
use alacritty_terminal::sync::FairMutex;
use alacritty_terminal::term::test::TermSize;
use alacritty_terminal::term::{Config as TermConfig, Term};
use alacritty_terminal::vte::ansi::Processor;
use crate::backend::{self, BackendConfig, TerminalSession};
use crate::renderer::{CursorStyle, TerminalRenderer};
use std::sync::{mpsc, Arc};
// GLFW key constants (used by Minecraft/LWJGL)
pub mod keys {
pub const KEY_ESCAPE: i32 = 256;
pub const KEY_ENTER: i32 = 257;
pub const KEY_TAB: i32 = 258;
pub const KEY_BACKSPACE: i32 = 259;
pub const KEY_INSERT: i32 = 260;
pub const KEY_DELETE: i32 = 261;
pub const KEY_RIGHT: i32 = 262;
pub const KEY_LEFT: i32 = 263;
pub const KEY_DOWN: i32 = 264;
pub const KEY_UP: i32 = 265;
pub const KEY_PAGEUP: i32 = 266;
pub const KEY_PAGEDOWN: i32 = 267;
pub const KEY_HOME: i32 = 268;
pub const KEY_END: i32 = 269;
pub const KEY_F1: i32 = 290;
pub const KEY_F2: i32 = 291;
pub const KEY_F3: i32 = 292;
pub const KEY_F4: i32 = 293;
pub const KEY_F5: i32 = 294;
pub const KEY_F6: i32 = 295;
pub const KEY_F7: i32 = 296;
pub const KEY_F8: i32 = 297;
pub const KEY_F9: i32 = 298;
pub const KEY_F10: i32 = 299;
pub const KEY_F11: i32 = 300;
pub const KEY_F12: i32 = 301;
// Modifier bits (matching GLFW modifier flags)
pub const MOD_SHIFT: i32 = 0x0001;
pub const MOD_CTRL: i32 = 0x0002;
pub const MOD_ALT: i32 = 0x0004;
}
/// Terminal event types that can be polled by the Java side
#[derive(Debug)]
pub enum TerminalEvent {
TitleChanged(String),
Bell,
ProcessExited(i32),
}
/// Event listener that receives terminal events via channel
struct ChannelEventListener {
sender: mpsc::Sender<Event>,
}
impl EventListener for ChannelEventListener {
fn send_event(&self, event: Event) {
let _ = self.sender.send(event);
}
}
/// The main terminal state struct, holding all components.
///
/// The session (`Box<dyn TerminalSession>`) is the raw byte pipe provided by
/// a backend. `TerminalState` still owns `Term`, `Processor`, and `Renderer`
/// — it reads bytes from the session and feeds them through the VTE parser.
pub struct TerminalState {
// Terminal emulation
term: Arc<FairMutex<Term<ChannelEventListener>>>,
session: Option<Box<dyn TerminalSession>>,
event_receiver: mpsc::Receiver<Event>,
// Renderer
renderer: TerminalRenderer,
// VTE parser and I/O buffer
vte_parser: Processor,
read_buffer: Vec<u8>,
// State tracking
running: bool,
current_title: String,
// Cursor blink
cursor_style: i32,
cursor_blink: bool,
cursor_blink_timer: f64,
cursor_visible: bool,
// Terminal dimensions
cols: i32,
rows: i32,
// Total bytes read (Windows EOF detection)
total_bytes_read: usize,
// Pending events for Java to poll
pending_events: Vec<TerminalEvent>,
}
impl TerminalState {
/// Create a new terminal using the specified backend.
///
/// If `backend_name` is empty or "plain", uses `PlainShellBackend`.
/// The backend spawns a session (the raw byte pipe); `TerminalState`
/// owns the `Term`, `Processor`, and `Renderer` on top.
pub fn new(
cols: i32,
rows: i32,
font_size: f32,
shell: &str,
working_dir: &str,
backend_name: &str,
) -> Result<Self, String> {
let registry = backend::BackendRegistry::new();
let name = if backend_name.is_empty() { "plain" } else { backend_name };
let backend = registry
.get(name)
.ok_or_else(|| format!("Unknown backend: {}", name))?;
let config = BackendConfig {
cols: cols as u16,
rows: rows as u16,
font_size,
shell: shell.to_string(),
working_dir: working_dir.to_string(),
image: None,
memory_limit: None,
cpu_limit: None,
network_enabled: None,
};
let session = backend.spawn(&config)?;
Self::with_session(session, cols, rows, font_size)
}
/// Create a terminal with a pre-spawned session.
///
/// This is the core constructor — it builds the `Term`, `Processor`,
/// and `Renderer`, then attaches the session for I/O.
pub fn with_session(
session: Box<dyn TerminalSession>,
cols: i32,
rows: i32,
font_size: f32,
) -> Result<Self, String> {
let (sender, receiver) = mpsc::channel();
let event_listener = ChannelEventListener { sender };
let size = TermSize::new(cols as usize, rows as usize);
let config = TermConfig::default();
let term = Term::new(config, &size, event_listener);
let term = Arc::new(FairMutex::new(term));
let renderer = TerminalRenderer::new(cols as u32, rows as u32, font_size)?;
Ok(Self {
term,
session: Some(session),
event_receiver: receiver,
renderer,
vte_parser: Processor::new(),
read_buffer: vec![0u8; 8192],
running: true,
current_title: String::new(),
cursor_style: 0,
cursor_blink: true,
cursor_blink_timer: 0.0,
cursor_visible: true,
cols,
rows,
total_bytes_read: 0,
pending_events: Vec::new(),
})
}
/// Create a headless terminal (no PTY) for testing.
/// Use `feed_bytes()` to inject content.
#[cfg(test)]
pub fn new_headless(cols: i32, rows: i32, font_size: f32) -> Result<Self, String> {
let (sender, receiver) = mpsc::channel();
let event_listener = ChannelEventListener { sender };
let size = TermSize::new(cols as usize, rows as usize);
let config = TermConfig::default();
let term = Term::new(config, &size, event_listener);
let term = Arc::new(FairMutex::new(term));
let renderer = TerminalRenderer::new(cols as u32, rows as u32, font_size)?;
Ok(Self {
term,
session: None,
event_receiver: receiver,
renderer,
vte_parser: Processor::new(),
read_buffer: vec![0u8; 8192],
running: true,
current_title: String::new(),
cursor_style: 0,
cursor_blink: false,
cursor_blink_timer: 0.0,
cursor_visible: true,
cols,
rows,
total_bytes_read: 0,
pending_events: Vec::new(),
})
}
/// Feed raw bytes into the terminal's VTE parser (for headless testing).
#[cfg(test)]
pub fn feed_bytes(&mut self, data: &[u8]) {
let mut term = self.term.lock();
self.vte_parser.advance(&mut *term, data);
}
/// Poll PTY for output data and process it through VTE parser.
/// Returns true if the terminal is still alive.
pub fn poll_pty(&mut self) -> bool {
if !self.running {
return false;
}
// Process terminal events
self.process_terminal_events();
// Read from PTY
self.read_pty_output();
self.running
}
/// Send text input to the terminal
pub fn send_text(&mut self, text: &str) {
if !self.running {
return;
}
if let Some(ref mut session) = self.session {
let bytes = text.as_bytes();
if let Err(e) = session.write(bytes) {
log::error!("Failed to write to session: {}", e);
}
self.cursor_blink_timer = 0.0;
self.cursor_visible = true;
}
}
/// Send a key input to the terminal
pub fn send_key(&mut self, keycode: i32, modifiers: i32) {
if !self.running {
return;
}
if let Some(sequence) = keycode_to_sequence(keycode, modifiers) {
self.send_text(&sequence);
}
}
/// Render terminal content to the pixel buffer.
/// Returns true if the buffer has changed since the last call.
pub fn render(&mut self) -> bool {
let cursor_style = CursorStyle::from(self.cursor_style);
let term = self.term.lock();
self.renderer
.render_with_cursor(&*term, cursor_style, self.cursor_visible);
drop(term);
self.renderer.take_dirty()
}
/// Get the pixel buffer
pub fn pixel_buffer(&self) -> &[u8] {
self.renderer.pixel_buffer()
}
/// Get pixel dimensions [width, height, cell_width, cell_height]
pub fn dimensions(&self) -> [i32; 4] {
let (w, h) = self.renderer.dimensions();
let (cw, ch) = self.renderer.cell_size();
[w as i32, h as i32, cw as i32, ch as i32]
}
/// Resize the terminal
pub fn resize(&mut self, cols: i32, rows: i32) {
if cols <= 0 || rows <= 0 {
return;
}
self.cols = cols;
self.rows = rows;
// Resize renderer
if let Err(e) = self.renderer.resize(cols as u32, rows as u32) {
log::error!("Failed to resize renderer: {}", e);
return;
}
// Resize session
if let Some(ref mut session) = self.session {
if let Err(e) = session.resize(cols as u16, rows as u16) {
log::error!("Failed to resize session: {}", e);
}
}
// Resize terminal grid
let mut term = self.term.lock();
let size = TermSize::new(cols as usize, rows as usize);
term.resize(size);
}
/// Scroll the terminal
pub fn scroll(&mut self, delta: i32) {
if delta != 0 {
let mut term = self.term.lock();
term.scroll_display(Scroll::Delta(delta));
}
}
/// Check if the terminal is still alive
pub fn is_alive(&self) -> bool {
self.running
}
/// Update cursor blink state
pub fn update_cursor_blink(&mut self, delta_seconds: f64) {
if self.cursor_blink {
self.cursor_blink_timer += delta_seconds;
let half_period = 0.5; // 1 second cycle
if self.cursor_blink_timer >= half_period {
self.cursor_blink_timer -= half_period;
self.cursor_visible = !self.cursor_visible;
}
} else {
self.cursor_visible = true;
}
}
/// Get the current terminal content as text
pub fn get_content(&self) -> String {
let term = self.term.lock();
let mut content = String::new();
let grid = term.grid();
for line_idx in 0..grid.screen_lines() {
let row = &grid[Line(line_idx as i32)];
for col in 0..grid.columns() {
content.push(row[Column(col)].c);
}
content.push('\n');
}
content
}
// --- Private methods ---
/// Process terminal events from the channel
fn process_terminal_events(&mut self) {
let events: Vec<Event> = self.event_receiver.try_iter().collect();
for event in events {
match event {
Event::Title(title) => {
if self.current_title != title {
self.current_title = title.clone();
self.pending_events
.push(TerminalEvent::TitleChanged(title));
}
}
Event::Bell => {
self.pending_events.push(TerminalEvent::Bell);
}
Event::Exit => {
self.pending_events.push(TerminalEvent::ProcessExited(0));
self.running = false;
}
_ => {}
}
}
}
/// Read output from the session and process through VTE parser.
/// The session handles non-blocking I/O internally.
fn read_pty_output(&mut self) {
let read_result = if let Some(ref mut session) = self.session {
match session.read(&mut self.read_buffer) {
Ok(n) => Some(n),
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => None,
Err(e) => {
log::error!("Session read error: {}", e);
None
}
}
} else {
return;
};
match read_result {
Some(0) => {
self.pending_events.push(TerminalEvent::ProcessExited(0));
self.running = false;
}
Some(n) => {
self.total_bytes_read += n;
// Process bytes through VTE parser
let mut term = self.term.lock();
self.vte_parser
.advance(&mut *term, &self.read_buffer[..n]);
}
None => {}
}
}
}
impl Drop for TerminalState {
fn drop(&mut self) {
// Session is dropped automatically, which kills the child process
self.running = false;
}
}
/// Convert GLFW keycode to terminal escape sequence
pub fn keycode_to_sequence(keycode: i32, modifiers: i32) -> Option<String> {
use keys::*;
let ctrl = (modifiers & MOD_CTRL) != 0;
let shift = (modifiers & MOD_SHIFT) != 0;
let alt = (modifiers & MOD_ALT) != 0;
// Build modifier code for CSI sequences
let modifier_code =
1 + if shift { 1 } else { 0 } + if alt { 2 } else { 0 } + if ctrl { 4 } else { 0 };
let has_modifiers = modifier_code > 1;
let sequence = match keycode {
// Arrow keys with modifier support
KEY_UP if has_modifiers => format!("\x1b[1;{}A", modifier_code),
KEY_DOWN if has_modifiers => format!("\x1b[1;{}B", modifier_code),
KEY_RIGHT if has_modifiers => format!("\x1b[1;{}C", modifier_code),
KEY_LEFT if has_modifiers => format!("\x1b[1;{}D", modifier_code),
KEY_UP => "\x1b[A".into(),
KEY_DOWN => "\x1b[B".into(),
KEY_RIGHT => "\x1b[C".into(),
KEY_LEFT => "\x1b[D".into(),
// Basic keys
KEY_ENTER => "\r".into(),
KEY_TAB if shift => "\x1b[Z".into(),
KEY_TAB => "\t".into(),
KEY_BACKSPACE => "\x7f".into(),
KEY_ESCAPE => "\x1b".into(),
// Navigation keys with modifier support
KEY_HOME if has_modifiers => format!("\x1b[1;{}H", modifier_code),
KEY_END if has_modifiers => format!("\x1b[1;{}F", modifier_code),
KEY_HOME => "\x1b[H".into(),
KEY_END => "\x1b[F".into(),
KEY_PAGEUP if has_modifiers => format!("\x1b[5;{}~", modifier_code),
KEY_PAGEDOWN if has_modifiers => format!("\x1b[6;{}~", modifier_code),
KEY_PAGEUP => "\x1b[5~".into(),
KEY_PAGEDOWN => "\x1b[6~".into(),
KEY_INSERT if has_modifiers => format!("\x1b[2;{}~", modifier_code),
KEY_INSERT => "\x1b[2~".into(),
KEY_DELETE if has_modifiers => format!("\x1b[3;{}~", modifier_code),
KEY_DELETE => "\x1b[3~".into(),
// Function keys F1-F4 (SS3 format)
KEY_F1 if has_modifiers => format!("\x1b[1;{}P", modifier_code),
KEY_F2 if has_modifiers => format!("\x1b[1;{}Q", modifier_code),
KEY_F3 if has_modifiers => format!("\x1b[1;{}R", modifier_code),
KEY_F4 if has_modifiers => format!("\x1b[1;{}S", modifier_code),
KEY_F1 => "\x1bOP".into(),
KEY_F2 => "\x1bOQ".into(),
KEY_F3 => "\x1bOR".into(),
KEY_F4 => "\x1bOS".into(),
// Function keys F5-F12 (CSI format)
KEY_F5 if has_modifiers => format!("\x1b[15;{}~", modifier_code),
KEY_F6 if has_modifiers => format!("\x1b[17;{}~", modifier_code),
KEY_F7 if has_modifiers => format!("\x1b[18;{}~", modifier_code),
KEY_F8 if has_modifiers => format!("\x1b[19;{}~", modifier_code),
KEY_F9 if has_modifiers => format!("\x1b[20;{}~", modifier_code),
KEY_F10 if has_modifiers => format!("\x1b[21;{}~", modifier_code),
KEY_F11 if has_modifiers => format!("\x1b[23;{}~", modifier_code),
KEY_F12 if has_modifiers => format!("\x1b[24;{}~", modifier_code),
KEY_F5 => "\x1b[15~".into(),
KEY_F6 => "\x1b[17~".into(),
KEY_F7 => "\x1b[18~".into(),
KEY_F8 => "\x1b[19~".into(),
KEY_F9 => "\x1b[20~".into(),
KEY_F10 => "\x1b[21~".into(),
KEY_F11 => "\x1b[23~".into(),
KEY_F12 => "\x1b[24~".into(),
// Ctrl+letter combinations (GLFW: A=65, Z=90)
k if ctrl && (65..=90).contains(&k) => String::from(char::from((k - 64) as u8)),
// Alt+letter sends ESC prefix followed by the letter
k if alt && (65..=90).contains(&k) => {
let c = if shift { k as u8 } else { k as u8 + 32 }; // lowercase if not shift
format!("\x1b{}", c as char)
}
// Alt+number (0-9 = 48-57)
k if alt && (48..=57).contains(&k) => {
format!("\x1b{}", k as u8 as char)
}
_ => return None,
};
Some(sequence)
}
#[cfg(test)]
mod tests {
use super::keys::*;
use super::*;
#[test]
fn test_arrow_keys() {
assert_eq!(keycode_to_sequence(KEY_UP, 0), Some("\x1b[A".into()));
assert_eq!(keycode_to_sequence(KEY_DOWN, 0), Some("\x1b[B".into()));
assert_eq!(keycode_to_sequence(KEY_RIGHT, 0), Some("\x1b[C".into()));
assert_eq!(keycode_to_sequence(KEY_LEFT, 0), Some("\x1b[D".into()));
}
#[test]
fn test_arrow_keys_with_modifiers() {
// Shift+Up should be \x1b[1;2A
assert_eq!(
keycode_to_sequence(KEY_UP, MOD_SHIFT),
Some("\x1b[1;2A".into())
);
// Ctrl+Up should be \x1b[1;5A
assert_eq!(
keycode_to_sequence(KEY_UP, MOD_CTRL),
Some("\x1b[1;5A".into())
);
// Ctrl+Shift+Up should be \x1b[1;6A
assert_eq!(
keycode_to_sequence(KEY_UP, MOD_CTRL | MOD_SHIFT),
Some("\x1b[1;6A".into())
);
}
#[test]
fn test_basic_keys() {
assert_eq!(keycode_to_sequence(KEY_ENTER, 0), Some("\r".into()));
assert_eq!(keycode_to_sequence(KEY_TAB, 0), Some("\t".into()));
assert_eq!(
keycode_to_sequence(KEY_TAB, MOD_SHIFT),
Some("\x1b[Z".into())
);
assert_eq!(keycode_to_sequence(KEY_BACKSPACE, 0), Some("\x7f".into()));
assert_eq!(keycode_to_sequence(KEY_ESCAPE, 0), Some("\x1b".into()));
}
#[test]
fn test_function_keys() {
assert_eq!(keycode_to_sequence(KEY_F1, 0), Some("\x1bOP".into()));
assert_eq!(keycode_to_sequence(KEY_F2, 0), Some("\x1bOQ".into()));
assert_eq!(keycode_to_sequence(KEY_F5, 0), Some("\x1b[15~".into()));
assert_eq!(keycode_to_sequence(KEY_F12, 0), Some("\x1b[24~".into()));
}
#[test]
fn test_ctrl_letter() {
// Ctrl+C = 0x03
assert_eq!(
keycode_to_sequence(67, MOD_CTRL),
Some("\x03".to_string())
);
// Ctrl+A = 0x01
assert_eq!(
keycode_to_sequence(65, MOD_CTRL),
Some("\x01".to_string())
);
// Ctrl+Z = 0x1A
assert_eq!(
keycode_to_sequence(90, MOD_CTRL),
Some("\x1a".to_string())
);
}
#[test]
fn test_alt_letter() {
// Alt+a = ESC a
assert_eq!(
keycode_to_sequence(65, MOD_ALT),
Some("\x1ba".to_string())
);
// Alt+Shift+A = ESC A
assert_eq!(
keycode_to_sequence(65, MOD_ALT | MOD_SHIFT),
Some("\x1bA".to_string())
);
}
#[test]
fn test_navigation_keys() {
assert_eq!(keycode_to_sequence(KEY_HOME, 0), Some("\x1b[H".into()));
assert_eq!(keycode_to_sequence(KEY_END, 0), Some("\x1b[F".into()));
assert_eq!(keycode_to_sequence(KEY_PAGEUP, 0), Some("\x1b[5~".into()));
assert_eq!(
keycode_to_sequence(KEY_PAGEDOWN, 0),
Some("\x1b[6~".into())
);
assert_eq!(keycode_to_sequence(KEY_INSERT, 0), Some("\x1b[2~".into()));
assert_eq!(keycode_to_sequence(KEY_DELETE, 0), Some("\x1b[3~".into()));
}
#[test]
fn test_unknown_key() {
assert_eq!(keycode_to_sequence(9999, 0), None);
}
#[test]
fn test_terminal_create_and_render() {
// Create a terminal with a real shell
let state = TerminalState::new(80, 24, 14.0, "", "", "");
assert!(state.is_ok(), "Terminal should create successfully");
let mut state = state.unwrap();
// Should be alive
assert!(state.is_alive());
// Dimensions should be valid
let dims = state.dimensions();
assert!(dims[0] > 0, "Pixel width should be positive");
assert!(dims[1] > 0, "Pixel height should be positive");
assert!(dims[2] > 0, "Cell width should be positive");
assert!(dims[3] > 0, "Cell height should be positive");
// Poll PTY - shell should be starting
let alive = state.poll_pty();
assert!(alive, "Terminal should still be alive after first poll");
// Render should produce pixel data
state.render();
let pixels = state.pixel_buffer();
assert!(!pixels.is_empty(), "Pixel buffer should not be empty");
assert_eq!(
pixels.len(),
(dims[0] * dims[1] * 4) as usize,
"Pixel buffer should match dimensions"
);
// After rendering, there should be some non-zero pixels
// (at least the background should be painted)
let has_nonzero = pixels.iter().any(|&b| b != 0);
assert!(has_nonzero, "Pixel buffer should contain rendered content");
}
#[test]
fn test_terminal_send_text() {
let mut state = TerminalState::new(80, 24, 14.0, "", "", "").unwrap();
// Send text should not panic
state.send_text("echo hello\n");
// Terminal should still be alive
assert!(state.is_alive());
// Render should produce valid pixel data with non-zero content
state.render();
let pixels = state.pixel_buffer();
let has_nonzero = pixels.iter().any(|&b| b != 0);
assert!(has_nonzero, "Pixel buffer should have rendered content");
}
#[test]
fn test_terminal_resize() {
let mut state = TerminalState::new(80, 24, 14.0, "", "", "").unwrap();
let original_dims = state.dimensions();
// Resize to larger
state.resize(120, 40);
let new_dims = state.dimensions();
assert!(
new_dims[0] > original_dims[0],
"Width should increase after resize"
);
assert!(
new_dims[1] > original_dims[1],
"Height should increase after resize"
);
// Cell size should remain the same
assert_eq!(new_dims[2], original_dims[2], "Cell width unchanged");
assert_eq!(new_dims[3], original_dims[3], "Cell height unchanged");
}
#[test]
fn test_terminal_send_key() {
let mut state = TerminalState::new(80, 24, 14.0, "", "", "").unwrap();
// Wait for shell to start
std::thread::sleep(std::time::Duration::from_millis(100));
state.poll_pty();
// Send some key sequences
state.send_key(KEY_ENTER, 0); // Enter
state.send_key(KEY_UP, 0); // Up arrow
state.send_key(67, MOD_CTRL); // Ctrl+C
// Should still be alive (Ctrl+C sends SIGINT to the foreground process,
// which is the shell, and shells handle SIGINT)
std::thread::sleep(std::time::Duration::from_millis(100));
state.poll_pty();
assert!(state.is_alive());
}
#[test]
fn test_terminal_scroll() {
let mut state = TerminalState::new(80, 24, 14.0, "", "", "").unwrap();
// Scroll should not crash on empty terminal
state.scroll(5);
state.scroll(-5);
assert!(state.is_alive());
}
#[test]
fn test_headless_terminal_feed_and_render() {
// Create headless terminal — no PTY, just VTE parsing + rendering
let mut state = TerminalState::new_headless(80, 24, 14.0).unwrap();
// Feed "Hello, World!" followed by newline (as raw terminal output)
state.feed_bytes(b"Hello, World!\r\n");
// Render
let dirty = state.render();
assert!(dirty, "First render should be dirty");
let pixels = state.pixel_buffer();
let dims = state.dimensions();
let (pw, ph) = (dims[0] as usize, dims[1] as usize);
assert_eq!(pixels.len(), pw * ph * 4);
// Count foreground pixels (brighter than background)
// ABGR format: [A, B, G, R]. Background is ~(25, 25, 30), foreground text is ~(230, 230, 230)
let fg_pixel_count = pixels
.chunks(4)
.filter(|p| p[0] > 100 || p[1] > 100 || p[2] > 100)
.count();
assert!(
fg_pixel_count > 100,
"After feeding 'Hello, World!', should have many foreground pixels (got {})",
fg_pixel_count
);
}
#[test]
fn test_headless_terminal_ansi_colors() {
let mut state = TerminalState::new_headless(80, 24, 14.0).unwrap();
// Feed ANSI red text: ESC[31m RED ESC[0m
state.feed_bytes(b"\x1b[31mRED\x1b[0m");
state.render();
let pixels = state.pixel_buffer();
// Look for red-ish pixels in ABGR format: [A, B, G, R]
// RGBA: high R (p[0]), low G (p[1]), low B (p[2])
let red_pixels = pixels
.chunks(4)
.filter(|p| p[0] > 150 && p[1] < 50 && p[2] < 50)
.count();
assert!(
red_pixels > 10,
"After feeding red ANSI text, should have red pixels (got {})",
red_pixels
);
}
#[test]
fn test_headless_terminal_cursor_rendering() {
let mut state = TerminalState::new_headless(80, 24, 14.0).unwrap();
// Empty terminal should show cursor at top-left
state.render();
let pixels = state.pixel_buffer();
let dims = state.dimensions();
let (cell_w, cell_h) = (dims[2] as usize, dims[3] as usize);
// Cursor is at (0, 0) — check the first cell area for cursor-colored pixels
// Cursor color is (0, 255, 127) = bright green
// RGBA format: [R, G, B, A] — green channel is at idx+1
let mut cursor_pixels = 0;
for y in 0..cell_h {
for x in 0..cell_w {
let idx = (y * dims[0] as usize + x) * 4;
if idx + 3 < pixels.len() {
let g = pixels[idx + 1]; // RGBA: G at offset 1
// Cursor is green-ish (0, 255, 127)
if g > 200 {
cursor_pixels += 1;
}
}
}
}
assert!(
cursor_pixels > 5,
"First cell should contain cursor pixels (got {})",
cursor_pixels
);
}
#[test]
fn test_headless_terminal_multiline() {
let mut state = TerminalState::new_headless(80, 24, 14.0).unwrap();
// Feed multiple lines
state.feed_bytes(b"Line 1\r\nLine 2\r\nLine 3\r\n");
state.render();
let pixels = state.pixel_buffer();
let dims = state.dimensions();
let cell_h = dims[3] as usize;
// Check that rows 0, 1, 2 all have foreground content
// RGBA format: [R, G, B, A] — check R channel (idx+0) for brightness
for row in 0..3 {
let row_start_y = row * cell_h;
let row_end_y = (row + 1) * cell_h;
let mut row_fg = 0;
for y in row_start_y..row_end_y {
for x in 0..dims[0] as usize {
let idx = (y * dims[0] as usize + x) * 4;
if idx + 3 < pixels.len() && pixels[idx] > 100 {
row_fg += 1;
}
}
}
assert!(
row_fg > 10,
"Row {} should have foreground pixels (got {})",
row,
row_fg
);
}
}
#[test]
fn test_headless_terminal_resize_and_render() {
let mut state = TerminalState::new_headless(40, 12, 14.0).unwrap();
state.feed_bytes(b"Small terminal");
state.render();
let dims_small = state.dimensions();
let px_small = state.pixel_buffer().len();
// Resize to larger
state.resize(80, 24);
state.render();
let dims_large = state.dimensions();
let px_large = state.pixel_buffer().len();
assert!(dims_large[0] > dims_small[0]);
assert!(dims_large[1] > dims_small[1]);
assert!(px_large > px_small);
}
#[test]
fn test_headless_pixel_buffer_format() {
let mut state = TerminalState::new_headless(10, 5, 14.0).unwrap();
state.feed_bytes(b"X");
state.render();
let pixels = state.pixel_buffer();
let dims = state.dimensions();
// In ABGR format, byte 0 of each pixel is alpha = 255 (fully opaque)
for (i, chunk) in pixels.chunks(4).enumerate() {
assert_eq!(
chunk[3], 255, // RGBA: alpha at offset 3
"Pixel {} alpha (ABGR byte 0) should be 255 (got {})",
i, chunk[3]
);
}
// Total size should match dimensions
assert_eq!(
pixels.len(),
(dims[0] * dims[1] * 4) as usize
);
}
}