makepad/libs/terminal_core/src/terminal.rs
2026-04-01 12:19:03 +02:00

1804 lines
63 KiB
Rust

use crate::cell::{Color, StyleFlags};
use crate::color::{Palette, Rgb};
use crate::parser::{Action, Parser};
use crate::screen::{Cursor, CursorShape, Screen};
/// Terminal modes
#[derive(Clone, Debug, Default)]
pub struct TerminalMode {
pub cursor_keys: bool, // DECCKM: cursor keys send ESC O vs ESC [
pub autowrap: bool, // DECAWM: auto-wrap at right margin
pub cursor_visible: bool, // DECTCEM: cursor visible
pub synchronized_update: bool, // DEC 2026: synchronized output mode
pub alt_screen: bool, // Alternate screen buffer active
pub bracketed_paste: bool, // Bracketed paste mode
pub linefeed_newline: bool, // LNM: LF also does CR
pub origin: bool, // DECOM: origin mode
pub insert: bool, // IRM: insert mode
// Mouse modes
pub mouse_tracking: MouseMode,
pub mouse_sgr: bool, // SGR extended mouse coordinates
pub focus_events: bool, // Focus in/out reporting
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MouseMode {
#[default]
None,
X10, // Button press only
Normal, // Button press/release
ButtonEvent, // + drag
AnyEvent, // + all motion
}
impl TerminalMode {
fn new() -> Self {
Self {
autowrap: true,
cursor_visible: true,
..Default::default()
}
}
}
pub struct Terminal {
pub primary: Screen,
pub alternate: Screen,
pub active: ScreenKind,
pub modes: TerminalMode,
pub palette: Palette,
pub default_fg: Rgb,
pub default_bg: Rgb,
pub cursor_color: Option<Rgb>,
pub title: String,
bell_pending: bool,
parser: Parser,
actions_buf: Vec<Action>,
outbound: Vec<u8>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ScreenKind {
Primary,
Alternate,
}
impl Terminal {
pub fn new(cols: usize, rows: usize) -> Self {
Self {
primary: Screen::new(cols, rows, true),
alternate: Screen::new(cols, rows, false),
active: ScreenKind::Primary,
modes: TerminalMode::new(),
palette: Palette::default(),
default_fg: Rgb::new(0xd9, 0xdc, 0xda),
default_bg: Rgb::new(0x1d, 0x1f, 0x21),
cursor_color: None,
title: String::new(),
bell_pending: false,
parser: Parser::new(),
actions_buf: Vec::with_capacity(64),
outbound: Vec::with_capacity(64),
}
}
pub fn screen(&self) -> &Screen {
match self.active {
ScreenKind::Primary => &self.primary,
ScreenKind::Alternate => &self.alternate,
}
}
pub fn screen_mut(&mut self) -> &mut Screen {
match self.active {
ScreenKind::Primary => &mut self.primary,
ScreenKind::Alternate => &mut self.alternate,
}
}
pub fn cursor(&self) -> &Cursor {
&self.screen().cursor
}
pub fn cols(&self) -> usize {
self.screen().cols()
}
pub fn rows(&self) -> usize {
self.screen().rows()
}
/// Process raw bytes from PTY output.
pub fn process_bytes(&mut self, bytes: &[u8]) {
self.actions_buf.clear();
self.parser.process(bytes, &mut self.actions_buf);
// Process collected actions
let actions: Vec<Action> = std::mem::take(&mut self.actions_buf);
for action in &actions {
self.handle_action(action);
}
self.actions_buf = actions;
}
/// Take terminal-generated reply bytes (DSR/DA/CPR/etc.) to send back to the PTY.
pub fn take_outbound(&mut self) -> Vec<u8> {
std::mem::take(&mut self.outbound)
}
pub fn take_bell(&mut self) -> bool {
std::mem::take(&mut self.bell_pending)
}
fn push_outbound(&mut self, bytes: &[u8]) {
self.outbound.extend_from_slice(bytes);
}
fn handle_action(&mut self, action: &Action) {
match action {
Action::Print(c) => self.print(*c),
Action::Execute(b) => self.execute(*b),
Action::CsiDispatch { params, final_char } => {
self.csi_dispatch(params, *final_char);
}
Action::EscDispatch {
intermediates,
intermediates_len,
final_char,
} => {
self.esc_dispatch(&intermediates[..*intermediates_len], *final_char);
}
Action::OscDispatch { command } => {
self.osc_dispatch(command);
}
}
}
fn print(&mut self, c: char) {
if self.modes.insert {
self.screen_mut().insert_blanks(1);
}
self.screen_mut().write_char(c);
}
fn execute(&mut self, byte: u8) {
match byte {
0x07 => {
// BEL — bell/notification
self.bell_pending = true;
}
0x08 => {
// BS — backspace
self.screen_mut().do_backspace();
}
0x09 => {
// HT — horizontal tab
self.screen_mut().do_tab();
}
0x0a | 0x0b | 0x0c => {
// LF, VT, FF — line feed
self.screen_mut().do_linefeed();
if self.modes.linefeed_newline {
self.screen_mut().do_carriage_return();
}
}
0x0d => {
// CR — carriage return
self.screen_mut().do_carriage_return();
}
0x0e => {
// SO — shift out (G1 charset) — ignore for now
}
0x0f => {
// SI — shift in (G0 charset) — ignore for now
}
_ => {}
}
}
fn csi_dispatch(&mut self, params: &crate::parser::CsiParams, final_char: u8) {
let is_private = params.has_intermediate(b'?');
match final_char {
// CUU — Cursor Up
b'A' => {
let n = params.get(0, 1) as usize;
let screen = self.screen_mut();
let new_y = screen.cursor.y.saturating_sub(n);
let new_y = if screen.cursor.y >= screen.scroll_top {
new_y.max(screen.scroll_top)
} else {
new_y
};
screen.cursor.y = new_y;
screen.cursor.pending_wrap = false;
}
// CUD — Cursor Down
b'B' => {
let n = params.get(0, 1) as usize;
let screen = self.screen_mut();
let limit = if screen.cursor.y < screen.scroll_bottom {
screen.scroll_bottom - 1
} else {
screen.rows() - 1
};
screen.cursor.y = (screen.cursor.y + n).min(limit);
screen.cursor.pending_wrap = false;
}
// CUF — Cursor Forward (right)
b'C' => {
let n = params.get(0, 1) as usize;
let screen = self.screen_mut();
screen.cursor.x = (screen.cursor.x + n).min(screen.cols() - 1);
screen.cursor.pending_wrap = false;
}
// CUB — Cursor Back (left)
b'D' => {
let n = params.get(0, 1) as usize;
let screen = self.screen_mut();
screen.cursor.x = screen.cursor.x.saturating_sub(n);
screen.cursor.pending_wrap = false;
}
// CNL — Cursor Next Line
b'E' => {
let n = params.get(0, 1) as usize;
let screen = self.screen_mut();
let limit = screen.rows() - 1;
screen.cursor.y = (screen.cursor.y + n).min(limit);
screen.cursor.x = 0;
screen.cursor.pending_wrap = false;
}
// CPL — Cursor Previous Line
b'F' => {
let n = params.get(0, 1) as usize;
let screen = self.screen_mut();
screen.cursor.y = screen.cursor.y.saturating_sub(n);
screen.cursor.x = 0;
screen.cursor.pending_wrap = false;
}
// CHA — Cursor Horizontal Absolute / HPA
b'G' | b'`' => {
let col = params.get(0, 1) as usize;
let screen = self.screen_mut();
screen.cursor.x = (col.saturating_sub(1)).min(screen.cols() - 1);
screen.cursor.pending_wrap = false;
}
// CUP — Cursor Position / HVP
b'H' | b'f' => {
let row = params.get(0, 1) as usize;
let col = params.get(1, 1) as usize;
let screen = self.screen_mut();
screen.move_cursor_to(col.saturating_sub(1), row.saturating_sub(1));
}
// ED — Erase in Display
b'J' => {
let mode = params.get(0, 0);
self.screen_mut().erase_display(mode);
}
// EL — Erase in Line
b'K' => {
let mode = params.get(0, 0);
self.screen_mut().erase_line(mode);
}
// IL — Insert Lines
b'L' => {
let n = params.get(0, 1) as usize;
self.screen_mut().insert_lines(n);
}
// DL — Delete Lines
b'M' => {
let n = params.get(0, 1) as usize;
self.screen_mut().delete_lines(n);
}
// DCH — Delete Characters
b'P' => {
let n = params.get(0, 1) as usize;
self.screen_mut().delete_chars(n);
}
// SU — Scroll Up
b'S' if !is_private => {
let n = params.get(0, 1) as usize;
self.screen_mut().scroll_up(n);
}
// SD — Scroll Down
b'T' if !is_private => {
let n = params.get(0, 1) as usize;
self.screen_mut().scroll_down(n);
}
// ECH — Erase Characters
b'X' => {
let n = params.get(0, 1) as usize;
self.screen_mut().erase_chars(n);
}
// ICH — Insert Blank Characters
b'@' => {
let n = params.get(0, 1) as usize;
self.screen_mut().insert_blanks(n);
}
// VPA — Vertical Position Absolute
b'd' => {
let row = params.get(0, 1) as usize;
let screen = self.screen_mut();
screen.cursor.y = (row.saturating_sub(1)).min(screen.rows() - 1);
screen.cursor.pending_wrap = false;
}
// SGR — Select Graphic Rendition
b'm' => {
self.handle_sgr(params);
}
// DSR — Device Status Report
b'n' if !is_private => {
match params.get(0, 0) {
5 => {
// Report terminal OK.
self.push_outbound(b"\x1b[0n");
}
6 => {
// CPR — Report cursor position (1-based row/col).
let screen = self.screen();
let row = screen.cursor.y + 1;
let col = screen.cursor.x + 1;
let reply = format!("\x1b[{};{}R", row, col);
self.push_outbound(reply.as_bytes());
}
_ => {}
}
}
// DEC-specific DSR / DECXCPR
b'n' if is_private => {
if params.get(0, 0) == 6 {
let screen = self.screen();
let row = screen.cursor.y + 1;
let col = screen.cursor.x + 1;
let reply = format!("\x1b[?{};{}R", row, col);
self.push_outbound(reply.as_bytes());
}
}
// DECSTBM — Set Top and Bottom Margins
b'r' if !is_private => {
let top = params.get(0, 1) as usize;
let bottom = params.get(1, 0) as usize;
self.screen_mut().set_scroll_region(top, bottom);
// Move cursor to home
self.screen_mut().move_cursor_to(0, 0);
}
// DECSC / SCOSC — Save Cursor Position
b's' if !is_private => {
self.screen_mut().save_cursor();
}
// DECRC / SCORC — Restore Cursor Position
b'u' if !is_private => {
self.screen_mut().restore_cursor();
}
// DA — Device Attributes
b'c' if !is_private => {
if params.has_intermediate(b'>') {
// Secondary DA.
self.push_outbound(b"\x1b[>0;0;0c");
} else {
// Primary DA: VT100 with Advanced Video Option.
self.push_outbound(b"\x1b[?1;2c");
}
}
// DECSET / DECRST — DEC Private Mode Set/Reset
b'h' if is_private => {
for i in 0..params.len {
self.set_dec_mode(params.params[i], true);
}
}
b'l' if is_private => {
for i in 0..params.len {
self.set_dec_mode(params.params[i], false);
}
}
// SM — Set Mode (ANSI)
b'h' if !is_private => {
for i in 0..params.len {
self.set_ansi_mode(params.params[i], true);
}
}
// RM — Reset Mode (ANSI)
b'l' if !is_private => {
for i in 0..params.len {
self.set_ansi_mode(params.params[i], false);
}
}
// Cursor style (DECSCUSR)
b'q' if params.has_intermediate(b' ') => {
let shape = params.get(0, 0);
self.screen_mut().cursor.shape = match shape {
0 | 1 => CursorShape::Block,
2 => CursorShape::Block,
3 | 4 => CursorShape::Underline,
5 | 6 => CursorShape::Bar,
_ => CursorShape::Block,
};
}
_ => {
// Unhandled CSI sequence — ignore
}
}
}
fn set_dec_mode(&mut self, mode: u16, enable: bool) {
match mode {
1 => self.modes.cursor_keys = enable, // DECCKM
7 => self.modes.autowrap = enable, // DECAWM
12 => {
// Cursor blink — we track visibility, not blink per se
}
25 => self.modes.cursor_visible = enable, // DECTCEM
6 => {
// DECOM — origin mode
self.modes.origin = enable;
self.screen_mut().move_cursor_to(0, 0);
}
47 => {
// Alt screen buffer (no save cursor, no clear)
self.switch_screen(enable, false, false);
}
1000 => {
self.modes.mouse_tracking = if enable {
MouseMode::Normal
} else {
MouseMode::None
};
}
1002 => {
self.modes.mouse_tracking = if enable {
MouseMode::ButtonEvent
} else {
MouseMode::None
};
}
1003 => {
self.modes.mouse_tracking = if enable {
MouseMode::AnyEvent
} else {
MouseMode::None
};
}
1004 => self.modes.focus_events = enable,
1006 => self.modes.mouse_sgr = enable,
1049 => {
// Alt screen buffer (save cursor + clear)
self.switch_screen(enable, true, true);
}
2004 => self.modes.bracketed_paste = enable,
2026 => self.modes.synchronized_update = enable,
_ => {
// Unknown DEC mode — ignore
}
}
}
fn set_ansi_mode(&mut self, mode: u16, enable: bool) {
match mode {
4 => self.modes.insert = enable, // IRM
20 => self.modes.linefeed_newline = enable, // LNM
_ => {}
}
}
fn switch_screen(&mut self, to_alt: bool, save_cursor: bool, clear: bool) {
if to_alt && self.active == ScreenKind::Primary {
if save_cursor {
self.primary.save_cursor();
}
self.active = ScreenKind::Alternate;
self.modes.alt_screen = true;
if clear {
self.alternate.erase_display(2);
self.alternate.move_cursor_to(0, 0);
}
} else if !to_alt && self.active == ScreenKind::Alternate {
self.active = ScreenKind::Primary;
self.modes.alt_screen = false;
if save_cursor {
self.primary.restore_cursor();
}
}
}
fn esc_dispatch(&mut self, intermediates: &[u8], final_char: u8) {
match (intermediates, final_char) {
// DECSC — Save Cursor
([], b'7') => self.screen_mut().save_cursor(),
// DECRC — Restore Cursor
([], b'8') => self.screen_mut().restore_cursor(),
// RI — Reverse Index (cursor up, scroll if at top)
([], b'M') => {
let screen = self.screen_mut();
if screen.cursor.y == screen.scroll_top {
screen.scroll_down(1);
} else if screen.cursor.y > 0 {
screen.cursor.y -= 1;
}
screen.cursor.pending_wrap = false;
}
// IND — Index (cursor down, scroll if at bottom)
([], b'D') => {
self.screen_mut().do_linefeed();
}
// NEL — Next Line
([], b'E') => {
self.screen_mut().do_linefeed();
self.screen_mut().do_carriage_return();
}
// HTS — Horizontal Tab Set
([], b'H') => {
let x = self.screen().cursor.x;
if x < self.screen().cols() {
self.screen_mut().tabstops[x] = true;
}
}
// RIS — Reset to Initial State
([], b'c') => {
let cols = self.cols();
let rows = self.rows();
*self = Terminal::new(cols, rows);
}
_ => {
// Unhandled ESC sequence
}
}
}
fn osc_dispatch(&mut self, command: &[u8]) {
// OSC format: "N;data" where N is the command number
let s = match std::str::from_utf8(command) {
Ok(s) => s,
Err(_) => return,
};
let (num_str, data) = match s.find(';') {
Some(pos) => (&s[..pos], &s[pos + 1..]),
None => (s, ""),
};
let num: u16 = match num_str.parse() {
Ok(n) => n,
Err(_) => return,
};
match num {
// Window title
0 | 2 => {
self.title = data.to_string();
}
// Icon name (ignore, we use title)
1 => {}
// Set color palette entries: "index;spec(;index;spec...)"
4 => {
let mut parts = data.split(';');
while let (Some(idx_str), Some(spec)) = (parts.next(), parts.next()) {
let Ok(idx) = idx_str.parse::<usize>() else {
continue;
};
if idx >= 256 {
continue;
}
if let Some(rgb) = Self::parse_osc_color(spec) {
self.palette.colors[idx] = rgb;
}
}
}
// Default foreground/background/cursor colors.
// "?" queries the current value; otherwise set the color.
10 => {
if data == "?" {
let c = self.default_fg;
let reply = format!(
"\x1b]10;rgb:{:04x}/{:04x}/{:04x}\x1b\\",
c.r as u16 * 257,
c.g as u16 * 257,
c.b as u16 * 257
);
self.push_outbound(reply.as_bytes());
} else if let Some(rgb) = Self::parse_osc_color(data) {
self.default_fg = rgb;
}
}
11 => {
if data == "?" {
let c = self.default_bg;
let reply = format!(
"\x1b]11;rgb:{:04x}/{:04x}/{:04x}\x1b\\",
c.r as u16 * 257,
c.g as u16 * 257,
c.b as u16 * 257
);
self.push_outbound(reply.as_bytes());
} else if let Some(rgb) = Self::parse_osc_color(data) {
self.default_bg = rgb;
}
}
12 => {
if data == "?" {
let c = self.cursor_color.unwrap_or(self.default_fg);
let reply = format!(
"\x1b]12;rgb:{:04x}/{:04x}/{:04x}\x1b\\",
c.r as u16 * 257,
c.g as u16 * 257,
c.b as u16 * 257
);
self.push_outbound(reply.as_bytes());
} else {
self.cursor_color = Self::parse_osc_color(data);
}
}
// Reset palette entries (empty means all).
104 => {
let defaults = Palette::default_palette();
if data.trim().is_empty() {
self.palette = defaults;
} else {
for idx_str in data.split(';') {
let Ok(idx) = idx_str.parse::<usize>() else {
continue;
};
if idx < 256 {
self.palette.colors[idx] = defaults.colors[idx];
}
}
}
}
// Reset default fg/bg/cursor colors.
110 => self.default_fg = Rgb::new(0xd9, 0xdc, 0xda),
111 => self.default_bg = Rgb::new(0x1d, 0x1f, 0x21),
112 => self.cursor_color = None,
_ => {
// Other OSC — ignore for now
}
}
}
fn parse_osc_color(spec: &str) -> Option<Rgb> {
if let Some(hex) = spec.strip_prefix('#') {
if hex.len() == 6 {
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
return Some(Rgb::new(r, g, b));
}
return None;
}
if let Some(rest) = spec.strip_prefix("rgb:") {
let mut it = rest.split('/');
let r = Self::parse_osc_hex_component(it.next()?)?;
let g = Self::parse_osc_hex_component(it.next()?)?;
let b = Self::parse_osc_hex_component(it.next()?)?;
return Some(Rgb::new(r, g, b));
}
None
}
fn parse_osc_hex_component(comp: &str) -> Option<u8> {
if comp.is_empty() || comp.len() > 4 {
return None;
}
let value = u16::from_str_radix(comp, 16).ok()?;
let max = (1u32 << (comp.len() as u32 * 4)) - 1;
if max == 0 {
return None;
}
Some(((value as u32 * 255) / max) as u8)
}
fn parse_sgr_extended_color(
params: &crate::parser::CsiParams,
i: usize,
) -> Option<(Color, usize)> {
let mode_idx = i + 1;
if mode_idx >= params.len {
return None;
}
match params.params[mode_idx] {
5 => {
let idx_idx = mode_idx + 1;
if idx_idx < params.len {
Some((
Color::Palette(params.params[idx_idx].min(255) as u8),
idx_idx,
))
} else {
None
}
}
2 => {
// Colon form may include a color-space id before RGB, e.g. 38:2::R:G:B.
let rgb_start = if params.has_colon && mode_idx + 4 < params.len {
let color_space = params.params[mode_idx + 1];
if color_space <= 1 {
mode_idx + 2
} else {
mode_idx + 1
}
} else {
mode_idx + 1
};
let b_idx = rgb_start + 2;
if b_idx < params.len {
Some((
Color::Rgb(
params.params[rgb_start].min(255) as u8,
params.params[rgb_start + 1].min(255) as u8,
params.params[b_idx].min(255) as u8,
),
b_idx,
))
} else {
None
}
}
_ => None,
}
}
fn handle_sgr(&mut self, params: &crate::parser::CsiParams) {
if params.len == 0 {
// SGR with no params = reset
self.screen_mut().cursor.style.reset();
return;
}
let mut i = 0;
while i < params.len {
let p = params.params[i];
match p {
0 => self.screen_mut().cursor.style.reset(),
1 => self.screen_mut().cursor.style.flags.set(StyleFlags::BOLD),
2 => self.screen_mut().cursor.style.flags.set(StyleFlags::FAINT),
3 => self.screen_mut().cursor.style.flags.set(StyleFlags::ITALIC),
4 => self.screen_mut().cursor.style.flags.set_underline(1),
5 | 6 => self.screen_mut().cursor.style.flags.set(StyleFlags::BLINK),
7 => self
.screen_mut()
.cursor
.style
.flags
.set(StyleFlags::INVERSE),
8 => self
.screen_mut()
.cursor
.style
.flags
.set(StyleFlags::INVISIBLE),
9 => self
.screen_mut()
.cursor
.style
.flags
.set(StyleFlags::STRIKETHROUGH),
21 => self.screen_mut().cursor.style.flags.set_underline(2), // double underline
22 => {
self.screen_mut().cursor.style.flags.clear(StyleFlags::BOLD);
self.screen_mut()
.cursor
.style
.flags
.clear(StyleFlags::FAINT);
}
23 => self
.screen_mut()
.cursor
.style
.flags
.clear(StyleFlags::ITALIC),
24 => self.screen_mut().cursor.style.flags.set_underline(0),
25 => self
.screen_mut()
.cursor
.style
.flags
.clear(StyleFlags::BLINK),
27 => self
.screen_mut()
.cursor
.style
.flags
.clear(StyleFlags::INVERSE),
28 => self
.screen_mut()
.cursor
.style
.flags
.clear(StyleFlags::INVISIBLE),
29 => self
.screen_mut()
.cursor
.style
.flags
.clear(StyleFlags::STRIKETHROUGH),
// Standard foreground colors (30-37)
30..=37 => {
self.screen_mut().cursor.style.fg = Color::Palette((p - 30) as u8);
}
// Default foreground
39 => self.screen_mut().cursor.style.fg = Color::Default,
// Standard background colors (40-47)
40..=47 => {
self.screen_mut().cursor.style.bg = Color::Palette((p - 40) as u8);
}
// Default background
49 => self.screen_mut().cursor.style.bg = Color::Default,
// Extended foreground (38;5;N or 38;2;R;G;B)
38 => {
if let Some((fg, new_i)) = Self::parse_sgr_extended_color(params, i) {
self.screen_mut().cursor.style.fg = fg;
i = new_i;
}
}
// Extended background (48;5;N or 48;2;R;G;B)
48 => {
if let Some((bg, new_i)) = Self::parse_sgr_extended_color(params, i) {
self.screen_mut().cursor.style.bg = bg;
i = new_i;
}
}
// Extended underline color (58;...)
58 => {
if let Some((_ul, new_i)) = Self::parse_sgr_extended_color(params, i) {
// Underline color is not stored yet; consume params to keep parsing in sync.
i = new_i;
}
}
// Reset underline color (ignored for now).
59 => {}
// Bright foreground (90-97)
90..=97 => {
self.screen_mut().cursor.style.fg = Color::Palette((p - 90 + 8) as u8);
}
// Bright background (100-107)
100..=107 => {
self.screen_mut().cursor.style.bg = Color::Palette((p - 100 + 8) as u8);
}
53 => self
.screen_mut()
.cursor
.style
.flags
.set(StyleFlags::OVERLINE),
55 => self
.screen_mut()
.cursor
.style
.flags
.clear(StyleFlags::OVERLINE),
_ => {} // Unknown SGR — ignore
}
i += 1;
}
}
/// Resize the terminal
pub fn resize(&mut self, cols: usize, rows: usize) {
self.primary.resize(cols, rows);
self.alternate.resize(cols, rows);
}
/// Encode a key event as bytes to send to the PTY.
/// Returns the byte sequence, or None if the key isn't handled.
pub fn encode_key(
&self,
key_code: KeyCode,
text: &str,
shift: bool,
ctrl: bool,
alt: bool,
) -> Option<Vec<u8>> {
// For text input, handle Ctrl+key and Alt+key
if !text.is_empty() && key_code == KeyCode::None {
if ctrl {
// Ctrl+A..Z maps to 0x01..0x1a
let c = text.chars().next()?;
if c >= 'a' && c <= 'z' {
return Some(vec![(c as u8) - b'a' + 1]);
}
if c >= 'A' && c <= 'Z' {
return Some(vec![(c as u8) - b'A' + 1]);
}
}
if alt {
let mut bytes = vec![0x1b];
bytes.extend_from_slice(text.as_bytes());
return Some(bytes);
}
return Some(text.as_bytes().to_vec());
}
// Special keys
let modifier = modifier_param(shift, ctrl, alt);
match key_code {
KeyCode::Return => Some(vec![0x0d]),
KeyCode::Tab => {
if shift {
Some(b"\x1b[Z".to_vec())
} else {
Some(vec![0x09])
}
}
KeyCode::Backspace => {
if alt {
Some(vec![0x1b, 0x7f])
} else {
Some(vec![0x7f])
}
}
KeyCode::Escape => Some(vec![0x1b]),
KeyCode::Delete => {
if modifier > 0 {
Some(format!("\x1b[3;{}~", modifier).into_bytes())
} else {
Some(b"\x1b[3~".to_vec())
}
}
KeyCode::Up => Some(cursor_key(b'A', modifier, self.modes.cursor_keys)),
KeyCode::Down => Some(cursor_key(b'B', modifier, self.modes.cursor_keys)),
KeyCode::Right => Some(cursor_key(b'C', modifier, self.modes.cursor_keys)),
KeyCode::Left => Some(cursor_key(b'D', modifier, self.modes.cursor_keys)),
KeyCode::Home => Some(cursor_key(b'H', modifier, self.modes.cursor_keys)),
KeyCode::End => Some(cursor_key(b'F', modifier, self.modes.cursor_keys)),
KeyCode::PageUp => {
if modifier > 0 {
Some(format!("\x1b[5;{}~", modifier).into_bytes())
} else {
Some(b"\x1b[5~".to_vec())
}
}
KeyCode::PageDown => {
if modifier > 0 {
Some(format!("\x1b[6;{}~", modifier).into_bytes())
} else {
Some(b"\x1b[6~".to_vec())
}
}
KeyCode::Insert => Some(b"\x1b[2~".to_vec()),
KeyCode::F1 => Some(func_key(b'P', 11, modifier)),
KeyCode::F2 => Some(func_key(b'Q', 12, modifier)),
KeyCode::F3 => Some(func_key(b'R', 13, modifier)),
KeyCode::F4 => Some(func_key(b'S', 14, modifier)),
KeyCode::F5 => {
if modifier > 0 {
Some(format!("\x1b[15;{}~", modifier).into_bytes())
} else {
Some(b"\x1b[15~".to_vec())
}
}
KeyCode::F6 => {
if modifier > 0 {
Some(format!("\x1b[17;{}~", modifier).into_bytes())
} else {
Some(b"\x1b[17~".to_vec())
}
}
KeyCode::F7 => {
if modifier > 0 {
Some(format!("\x1b[18;{}~", modifier).into_bytes())
} else {
Some(b"\x1b[18~".to_vec())
}
}
KeyCode::F8 => {
if modifier > 0 {
Some(format!("\x1b[19;{}~", modifier).into_bytes())
} else {
Some(b"\x1b[19~".to_vec())
}
}
KeyCode::F9 => {
if modifier > 0 {
Some(format!("\x1b[20;{}~", modifier).into_bytes())
} else {
Some(b"\x1b[20~".to_vec())
}
}
KeyCode::F10 => {
if modifier > 0 {
Some(format!("\x1b[21;{}~", modifier).into_bytes())
} else {
Some(b"\x1b[21~".to_vec())
}
}
KeyCode::F11 => {
if modifier > 0 {
Some(format!("\x1b[23;{}~", modifier).into_bytes())
} else {
Some(b"\x1b[23~".to_vec())
}
}
KeyCode::F12 => {
if modifier > 0 {
Some(format!("\x1b[24;{}~", modifier).into_bytes())
} else {
Some(b"\x1b[24~".to_vec())
}
}
_ => None,
}
}
}
/// Key codes that encode_key understands (maps to platform key events)
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KeyCode {
None,
Return,
Tab,
Backspace,
Escape,
Delete,
Up,
Down,
Left,
Right,
Home,
End,
PageUp,
PageDown,
Insert,
F1,
F2,
F3,
F4,
F5,
F6,
F7,
F8,
F9,
F10,
F11,
F12,
}
fn modifier_param(shift: bool, ctrl: bool, alt: bool) -> u8 {
let mut m = 0u8;
if shift {
m |= 1;
}
if alt {
m |= 2;
}
if ctrl {
m |= 4;
}
if m > 0 {
m + 1
} else {
0
}
}
fn cursor_key(ch: u8, modifier: u8, app_cursor: bool) -> Vec<u8> {
if modifier > 0 {
format!("\x1b[1;{}{}", modifier, ch as char).into_bytes()
} else if app_cursor {
vec![0x1b, b'O', ch]
} else {
vec![0x1b, b'[', ch]
}
}
fn func_key(ss3_char: u8, csi_num: u16, modifier: u8) -> Vec<u8> {
if modifier > 0 {
format!("\x1b[{};{}~", csi_num, modifier).into_bytes()
} else {
vec![0x1b, b'O', ss3_char]
}
}
#[cfg(test)]
mod tests {
use super::{Color, Terminal};
fn write_line(screen: &mut crate::screen::Screen, ch: char) {
screen.write_char(ch);
screen.do_linefeed();
screen.do_carriage_return();
}
#[test]
fn dsr_cursor_position_reply() {
let mut terminal = Terminal::new(80, 24);
terminal.process_bytes(b"\x1b[12;34H");
terminal.process_bytes(b"\x1b[6n");
assert_eq!(terminal.take_outbound(), b"\x1b[12;34R".to_vec());
}
#[test]
fn da_primary_reply() {
let mut terminal = Terminal::new(80, 24);
terminal.process_bytes(b"\x1b[c");
assert_eq!(terminal.take_outbound(), b"\x1b[?1;2c".to_vec());
}
#[test]
fn sgr_truecolor_applies_to_cell_style() {
let mut terminal = Terminal::new(80, 24);
terminal.process_bytes(b"\x1b[38;2;10;20;30mX");
let cell = terminal.screen().grid.cell(0, 0);
assert_eq!(cell.style.fg, Color::Rgb(10, 20, 30));
}
#[test]
fn osc_updates_default_colors() {
let mut terminal = Terminal::new(80, 24);
terminal.process_bytes(b"\x1b]10;#112233\x07");
terminal.process_bytes(b"\x1b]11;rgb:44/55/66\x07");
assert_eq!(terminal.default_fg.r, 0x11);
assert_eq!(terminal.default_fg.g, 0x22);
assert_eq!(terminal.default_fg.b, 0x33);
assert_eq!(terminal.default_bg.r, 0x44);
assert_eq!(terminal.default_bg.g, 0x55);
assert_eq!(terminal.default_bg.b, 0x66);
}
#[test]
fn osc_with_st_terminator_is_applied() {
let mut terminal = Terminal::new(80, 24);
terminal.process_bytes(b"\x1b]10;#abcdef\x1b\\");
assert_eq!(terminal.default_fg.r, 0xab);
assert_eq!(terminal.default_fg.g, 0xcd);
assert_eq!(terminal.default_fg.b, 0xef);
}
#[test]
fn bell_sets_pending_flag() {
let mut terminal = Terminal::new(80, 24);
terminal.process_bytes(b"\x07");
assert!(terminal.take_bell());
assert!(!terminal.take_bell());
}
#[test]
fn osc_bel_terminator_does_not_ring_bell() {
let mut terminal = Terminal::new(80, 24);
terminal.process_bytes(b"\x1b]2;build log\x07");
assert_eq!(terminal.title, "build log");
assert!(!terminal.take_bell());
}
#[test]
fn dcs_with_c1_st_terminator_does_not_block_following_output() {
let mut terminal = Terminal::new(80, 24);
terminal.process_bytes(b"\x1bP=1s\x9c");
terminal.process_bytes(b"ok");
let screen = terminal.screen();
assert_eq!(screen.grid.cell(0, 0).codepoint, 'o');
assert_eq!(screen.grid.cell(1, 0).codepoint, 'k');
}
#[test]
fn resize_grow_pulls_scrollback_if_at_bottom() {
let mut terminal = Terminal::new(4, 3);
write_line(&mut terminal.primary, 'A');
write_line(&mut terminal.primary, 'B');
write_line(&mut terminal.primary, 'C');
write_line(&mut terminal.primary, 'D');
terminal.primary.write_char('E');
// A and B scrolled off; C D E visible at rows 0-2.
assert_eq!(terminal.primary.scrollback_len(), 2);
terminal.resize(4, 5);
let screen = terminal.screen();
// Cursor was at the bottom, so scrollback is pulled into the grid.
assert_eq!(screen.grid.cell(0, 0).codepoint, 'A');
assert_eq!(screen.grid.cell(0, 1).codepoint, 'B');
assert_eq!(screen.grid.cell(0, 2).codepoint, 'C');
assert_eq!(screen.grid.cell(0, 3).codepoint, 'D');
assert_eq!(screen.grid.cell(0, 4).codepoint, 'E');
// Scrollback consumed
assert_eq!(screen.scrollback_len(), 0);
// Cursor moves down
assert_eq!(screen.cursor.y, 4);
}
/// On grow, scrollback is pulled to keep content anchored to the bottom
/// (matching macOS Terminal). The TUI app receives SIGWINCH and redraws.
#[test]
#[test]
fn resize_shrink_bottom_anchors_when_text_below_cursor() {
let mut terminal = Terminal::new(4, 5);
write_line(&mut terminal.primary, 'A');
write_line(&mut terminal.primary, 'B');
write_line(&mut terminal.primary, 'C');
write_line(&mut terminal.primary, 'D');
terminal.primary.write_char('E');
// Text extends to row 4 but cursor is at row 1.
// Because there is text below the cursor, shrinking should
// bottom-anchor: push top rows to scrollback, keep bottom rows.
terminal.primary.move_cursor_to(0, 1);
terminal.resize(4, 3);
let screen = terminal.screen();
assert_eq!(screen.grid.cell(0, 0).codepoint, 'C');
assert_eq!(screen.grid.cell(0, 1).codepoint, 'D');
assert_eq!(screen.grid.cell(0, 2).codepoint, 'E');
assert_eq!(screen.scrollback_len(), 2);
assert_eq!(screen.cursor.y, 0);
}
#[test]
fn resize_grow_pulls_scrollback_when_screen_was_full() {
let mut terminal = Terminal::new(4, 3);
write_line(&mut terminal.primary, 'A');
write_line(&mut terminal.primary, 'B');
write_line(&mut terminal.primary, 'C');
write_line(&mut terminal.primary, 'D');
terminal.primary.write_char('E');
// A and B scrolled off; C D E visible at rows 0-2.
assert_eq!(terminal.primary.scrollback_len(), 2);
// Move cursor to top (TUI apps can place cursor anywhere)
terminal.primary.move_cursor_to(0, 0);
terminal.resize(4, 5);
let screen = terminal.screen();
// Screen was full, so scrollback is pulled regardless of cursor position.
assert_eq!(screen.grid.cell(0, 0).codepoint, 'A');
assert_eq!(screen.grid.cell(0, 1).codepoint, 'B');
assert_eq!(screen.grid.cell(0, 2).codepoint, 'C');
assert_eq!(screen.grid.cell(0, 3).codepoint, 'D');
assert_eq!(screen.grid.cell(0, 4).codepoint, 'E');
// Scrollback consumed
assert_eq!(screen.scrollback_len(), 0);
// Cursor shifted down by pulled count
assert_eq!(screen.cursor.y, 2);
}
// ---------------------------------------------------------------
// Codex-like TUI simulation tests
// ---------------------------------------------------------------
/// Helper: creates a terminal with shell output that scrolls (creating
/// scrollback), then draws a Codex-like TUI using cursor positioning.
/// Returns the terminal with cursor at the input prompt (middle of screen).
fn setup_codex_tui(cols: u16, rows: u16) -> Terminal {
let mut t = Terminal::new(cols as usize, rows as usize);
// Phase 1: shell output fills the screen and creates scrollback.
for i in 0..60u32 {
let line = format!("shell line {}\r\n", i);
t.process_bytes(line.as_bytes());
}
// Phase 2: Codex draws its TUI with cursor positioning.
// Set scroll region for content area (rows 3 to rows-2, 1-based).
t.process_bytes(format!("\x1b[3;{}r", rows - 2).as_bytes());
// Header on row 1 (1-based) with blue bg.
t.process_bytes(b"\x1b[1;1H\x1b[44m");
let hdr = format!("{:<width$}", "=== Codex ===", width = cols as usize);
t.process_bytes(hdr.as_bytes());
t.process_bytes(b"\x1b[0m");
// Separator on row 2.
t.process_bytes(b"\x1b[2;1H");
for _ in 0..cols {
t.process_bytes(b"-");
}
// Content in the scroll region.
for r in 3..=(rows - 2) {
t.process_bytes(format!("\x1b[{};1H", r).as_bytes());
let content = format!("content {}", r);
t.process_bytes(content.as_bytes());
}
// Input prompt on row rows-1 (1-based).
t.process_bytes(format!("\x1b[{};1H> ", rows - 1).as_bytes());
// Status bar on row rows (1-based) with green bg.
t.process_bytes(format!("\x1b[{};1H\x1b[42m", rows).as_bytes());
let status = format!("{:<width$}", "status: ok", width = cols as usize);
t.process_bytes(status.as_bytes());
t.process_bytes(b"\x1b[0m");
// Cursor back to input prompt area.
t.process_bytes(format!("\x1b[{};3H", rows - 1).as_bytes());
t
}
/// Helper: read a grid row as a trimmed string.
fn grid_row_text(terminal: &Terminal, row: usize) -> String {
let screen = terminal.screen();
let cols = screen.cols();
let mut s = String::new();
for col in 0..cols {
s.push(screen.grid.cell(col, row).codepoint);
}
s.trim_end().to_string()
}
fn snapshot_grid(terminal: &Terminal) -> Vec<Vec<char>> {
let screen = terminal.screen();
let rows = screen.rows();
let cols = screen.cols();
let mut out = Vec::with_capacity(rows);
for row in 0..rows {
let mut line = Vec::with_capacity(cols);
for col in 0..cols {
line.push(screen.grid.cell(col, row).codepoint);
}
out.push(line);
}
out
}
fn setup_makepad_tui_like_with_hi(cols: u16, rows: u16) -> Terminal {
let mut t = Terminal::new(cols as usize, rows as usize);
// Seed deep pre-TUI history with ls-like output so resize behavior is
// exercised against realistic prior content (as in a shell session).
for i in 0..220u32 {
let line = format!(
"-rw-r--r-- 1 user staff {:>6} Mar 3 12:{:02} file_{:03}.txt\r\n",
1024 + i,
i % 60,
i
);
t.process_bytes(line.as_bytes());
}
let scroll_bottom = rows - 4;
t.process_bytes(format!("\x1b[1;{}r", scroll_bottom).as_bytes());
// Header block in scroll region.
t.process_bytes(b"\x1b[1;1H+--------------------------------------------+");
t.process_bytes(b"\x1b[2;1H| >_ TUI Test (v0.1.0) |");
t.process_bytes(b"\x1b[3;1H| |");
t.process_bytes(b"\x1b[4;1H| model: fake-model /model to change |");
t.process_bytes(b"\x1b[5;1H| directory: ~/makepad |");
t.process_bytes(b"\x1b[6;1H+--------------------------------------------+");
// Simulate "hi" + 10 streamed responses in the scroll region.
t.process_bytes(format!("\x1b[{};1H", scroll_bottom).as_bytes());
t.process_bytes(b"\x1b[K> hi\n");
for i in 1..=10 {
let line = format!("\x1b[K[{}/10] hi\n", i);
t.process_bytes(line.as_bytes());
}
// Pinned prompt/status area.
let prompt_row = rows - 1;
let status_row = rows;
t.process_bytes(format!("\x1b[{};1H\x1b[K> ", prompt_row).as_bytes());
t.process_bytes(format!("\x1b[{};1H\x1b[Kfake-model - 100% left\n", status_row).as_bytes());
t.process_bytes(format!("\x1b[{};3H", prompt_row).as_bytes());
t
}
fn render_like_tui_resize(terminal: &mut Terminal, rows: u16) {
let scroll_bottom = rows - 4;
let working_row = rows - 3;
let prompt_row = rows - 2;
let blank_row = rows - 1;
let status_row = rows;
terminal.process_bytes(b"\x1b[r");
terminal.process_bytes(format!("\x1b[{working_row};1H\x1b[K").as_bytes());
terminal.process_bytes(format!("\x1b[{prompt_row};1H\x1b[K> ").as_bytes());
terminal.process_bytes(format!("\x1b[{blank_row};1H\x1b[K").as_bytes());
terminal
.process_bytes(format!("\x1b[{status_row};1H\x1b[Kfake-model - 100% left").as_bytes());
terminal.process_bytes(format!("\x1b[1;{scroll_bottom}r").as_bytes());
terminal.process_bytes(format!("\x1b[{prompt_row};3H").as_bytes());
}
fn render_like_codex_resize(terminal: &mut Terminal, cols: u16, rows: u16) {
// Codex-style redraw on SIGWINCH: re-establish region and fixed rows.
terminal.process_bytes(format!("\x1b[3;{}r", rows - 2).as_bytes());
terminal.process_bytes(b"\x1b[1;1H\x1b[K");
let hdr = format!("{:<width$}", "=== Codex ===", width = cols as usize);
terminal.process_bytes(hdr.as_bytes());
terminal.process_bytes(b"\x1b[2;1H\x1b[K");
let sep = "-".repeat(cols as usize);
terminal.process_bytes(sep.as_bytes());
for r in 3..=(rows - 2) {
terminal.process_bytes(format!("\x1b[{};1H\x1b[K", r).as_bytes());
let content = format!("content {}", r);
terminal.process_bytes(content.as_bytes());
}
terminal.process_bytes(format!("\x1b[{};1H\x1b[K> ", rows - 1).as_bytes());
terminal.process_bytes(format!("\x1b[{};1H\x1b[K", rows).as_bytes());
let status = format!("{:<width$}", "status: ok", width = cols as usize);
terminal.process_bytes(status.as_bytes());
terminal.process_bytes(format!("\x1b[{};3H", rows - 1).as_bytes());
}
fn assert_codex_frame_invariants(terminal: &Terminal, cols: u16, rows: u16) {
let screen = terminal.screen();
let row0 = grid_row_text(terminal, 0);
assert!(
row0.starts_with("=== Codex ==="),
"header row corrupted: '{}'",
row0
);
let row1 = grid_row_text(terminal, 1);
assert!(
row1.starts_with("---"),
"separator row corrupted: '{}'",
row1
);
let prompt_row = rows as usize - 2;
let status_row = rows as usize - 1;
let prompt = grid_row_text(terminal, prompt_row);
let status = grid_row_text(terminal, status_row);
assert!(
prompt.starts_with(">"),
"prompt row corrupted at {}: '{}'",
prompt_row,
prompt
);
assert!(
status.starts_with("status: ok"),
"status row corrupted at {}: '{}'",
status_row,
status
);
assert_eq!(screen.cols(), cols as usize);
assert_eq!(screen.rows(), rows as usize);
assert_eq!(screen.cursor.y, prompt_row);
assert_eq!(screen.cursor.x, 2);
}
#[test]
fn codex_tui_grid_positions_stable_after_width_resize() {
let mut t = setup_codex_tui(80, 24);
// Verify initial layout.
assert!(grid_row_text(&t, 0).starts_with("=== Codex ==="));
assert!(grid_row_text(&t, 1).starts_with("---"));
assert!(grid_row_text(&t, 2).starts_with("content 3"));
assert!(grid_row_text(&t, 22).starts_with("> "));
assert!(grid_row_text(&t, 23).starts_with("status: ok"));
// Cursor at input prompt.
assert_eq!(t.screen().cursor.y, 22); // row 23 is 0-based 22
let sb_before = t.primary.scrollback_len();
// Resize wider — only cols change.
t.resize(100, 24);
// Grid content must stay at the SAME row positions.
assert!(
grid_row_text(&t, 0).starts_with("=== Codex ==="),
"row 0 after resize: '{}'",
grid_row_text(&t, 0)
);
assert!(
grid_row_text(&t, 1).starts_with("---"),
"row 1 after resize: '{}'",
grid_row_text(&t, 1)
);
assert!(
grid_row_text(&t, 22).starts_with("> "),
"row 22 after resize: '{}'",
grid_row_text(&t, 22)
);
assert!(
grid_row_text(&t, 23).starts_with("status: ok"),
"row 23 after resize: '{}'",
grid_row_text(&t, 23)
);
// Cursor y must not shift.
assert_eq!(t.screen().cursor.y, 22);
// used_rows must cover the full screen (content drawn to bottom).
assert_eq!(t.screen().used_rows(), 24);
// Scrollback may change size (reflow) but must not be pulled into grid.
assert!(
t.primary.scrollback_len() <= sb_before,
"scrollback should not grow: was {}, now {}",
sb_before,
t.primary.scrollback_len()
);
}
#[test]
#[test]
fn codex_tui_grid_positions_stable_after_narrower_resize() {
let mut t = setup_codex_tui(80, 24);
// Resize narrower.
t.resize(60, 24);
// Grid content must stay at same rows (truncated horizontally, not shifted).
assert!(
grid_row_text(&t, 0).starts_with("=== Codex ==="),
"row 0: '{}'",
grid_row_text(&t, 0)
);
assert!(
grid_row_text(&t, 22).starts_with("> "),
"row 22: '{}'",
grid_row_text(&t, 22)
);
// Status bar text truncated to 60 cols but still on row 23.
assert!(
grid_row_text(&t, 23).starts_with("status: ok"),
"row 23: '{}'",
grid_row_text(&t, 23)
);
assert_eq!(t.screen().cursor.y, 22);
assert_eq!(t.screen().used_rows(), 24);
}
#[test]
fn codex_tui_used_rows_reflects_content_not_cursor() {
let mut t = setup_codex_tui(80, 24);
// Cursor is at row 22 (input prompt), but content extends to row 23.
assert_eq!(t.screen().cursor.y, 22);
assert_eq!(t.screen().used_rows(), 24); // content on all 24 rows
// After resize, used_rows must still cover all content, not snap to cursor.
t.resize(100, 24);
assert!(
t.screen().used_rows() >= 24,
"used_rows={} should be >= 24 after width resize",
t.screen().used_rows()
);
// The viewport total (scrollback + used_rows) should be enough to scroll to bottom.
let total = t.primary.scrollback_len() + t.screen().used_rows();
assert!(
total >= 24,
"total_lines={} should be >= viewport rows",
total
);
}
#[test]
fn codex_tui_styles_preserved_after_reflow() {
let mut t = setup_codex_tui(80, 24);
// Row 0 has blue bg (header).
let header_bg = t.screen().grid.cell(0, 0).style.bg;
assert_ne!(header_bg, Color::Default, "header should have colored bg");
// Row 23 has green bg (status bar).
let status_bg = t.screen().grid.cell(0, 23).style.bg;
assert_ne!(status_bg, Color::Default, "status should have colored bg");
// Resize wider.
t.resize(100, 24);
// Content cells should preserve their styles.
let new_header_bg = t.screen().grid.cell(0, 0).style.bg;
assert_eq!(
new_header_bg, header_bg,
"header bg should be preserved: {:?} vs {:?}",
new_header_bg, header_bg
);
let new_status_bg = t.screen().grid.cell(0, 23).style.bg;
assert_eq!(
new_status_bg, status_bg,
"status bg should be preserved: {:?} vs {:?}",
new_status_bg, status_bg
);
}
#[test]
fn scrollback_reflows_when_width_changes() {
let mut t = Terminal::new(20, 5);
// Write a long line that wraps at 20 cols.
let long_line = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; // 36 chars
for ch in long_line.chars() {
t.primary.write_char(ch);
}
t.primary.do_linefeed();
t.primary.do_carriage_return();
t.primary.write_char('X');
// At 20 cols, "ABCDE..." wraps into 2 rows. Then newline, then X.
// Row 0: ABCDEFGHIJKLMNOPQRST (wrapped)
// Row 1: UVWXYZ0123456789 (not wrapped)
// Row 2: X
assert_eq!(t.screen().grid.cell(0, 0).codepoint, 'A');
assert!(t.screen().grid.line_wrapped[0]); // first row is wrapped
// Scroll the content off into scrollback so we can test scrollback reflow.
for _ in 0..5 {
t.primary.do_linefeed();
t.primary.do_carriage_return();
}
// Now those rows are in scrollback.
assert!(t.primary.scrollback_len() >= 2);
let sb = t.primary.scrollback();
// First scrollback row should be the wrapped 'A..T' row.
assert_eq!(sb[0][0].codepoint, 'A');
// Resize wider — the wrapped line should unwrap in scrollback.
t.resize(40, 5);
let sb = t.primary.scrollback();
// The 36-char line should now fit in one 40-col row in scrollback.
assert_eq!(sb.len(), t.primary.scrollback_len());
// Find the row with 'A'.
let a_row = sb.iter().position(|row| row[0].codepoint == 'A').unwrap();
assert_eq!(
sb[a_row][35].codepoint, '9',
"full line should be in one row"
);
// Should NOT be wrapped anymore.
assert!(
!t.primary.scrollback_wrapped[a_row],
"unwrapped line should not be marked wrapped"
);
}
#[test]
fn visible_wrapped_prompt_rows_do_not_reflow_on_width_growth() {
let mut t = Terminal::new(10, 4);
t.process_bytes(b"wheregmis@MacBookPro makepad % wheregmis@MacBookPro makepad % ");
let before_row_0 = grid_row_text(&t, 0);
let before_row_1 = grid_row_text(&t, 1);
assert!(
!before_row_0.is_empty(),
"expected first prompt row to have text"
);
assert!(
!before_row_1.is_empty(),
"expected wrapped continuation row to have text"
);
assert!(
t.screen().grid.line_wrapped[0],
"expected prompt to wrap before resize"
);
t.resize(20, 4);
assert_eq!(
grid_row_text(&t, 0),
before_row_0,
"visible top row should keep its original content after width growth"
);
assert_eq!(
grid_row_text(&t, 1),
before_row_1,
"visible continuation row should remain separate after width growth"
);
}
/// Shrink pushes top rows into scrollback to keep cursor visible,
/// anchoring content to the bottom (matches macOS Terminal).
#[test]
/// Shrink + grow round-trip restores content from scrollback.
#[test]
#[test]
#[test]
fn makepad_tui_hi_up_down_resize_restores_framebuffer() {
let mut t = setup_makepad_tui_like_with_hi(80, 20);
render_like_tui_resize(&mut t, 20);
let before = snapshot_grid(&t);
let before_cursor = (t.screen().cursor.x, t.screen().cursor.y);
for r in 21..=40 {
t.resize(80, r);
render_like_tui_resize(&mut t, r as u16);
}
for r in (20..40).rev() {
t.resize(80, r);
render_like_tui_resize(&mut t, r as u16);
}
let after = snapshot_grid(&t);
let after_cursor = (t.screen().cursor.x, t.screen().cursor.y);
if after != before {
let first_diff = before
.iter()
.zip(after.iter())
.enumerate()
.find(|(_, (a, b))| a != b)
.map(|(idx, _)| idx)
.unwrap_or(0);
let before_line: String = before[first_diff].iter().collect();
let after_line: String = after[first_diff].iter().collect();
panic!(
"grid should round-trip after up/down resize; first differing row={} before='{}' after='{}'",
first_diff,
before_line.trim_end(),
after_line.trim_end()
);
}
assert_eq!(
after_cursor, before_cursor,
"cursor should round-trip after up/down resize"
);
}
#[test]
fn codex_tui_up_down_resize_restores_framebuffer() {
let mut t = setup_codex_tui(80, 20);
render_like_codex_resize(&mut t, 80, 20);
let before = snapshot_grid(&t);
let before_cursor = (t.screen().cursor.x, t.screen().cursor.y);
for r in 21..=40 {
t.resize(80, r);
render_like_codex_resize(&mut t, 80, r as u16);
}
for r in (20..40).rev() {
t.resize(80, r);
render_like_codex_resize(&mut t, 80, r as u16);
}
let after = snapshot_grid(&t);
let after_cursor = (t.screen().cursor.x, t.screen().cursor.y);
if after != before {
let first_diff = before
.iter()
.zip(after.iter())
.enumerate()
.find(|(_, (a, b))| a != b)
.map(|(idx, _)| idx)
.unwrap_or(0);
let before_line: String = before[first_diff].iter().collect();
let after_line: String = after[first_diff].iter().collect();
panic!(
"codex grid should round-trip after up/down resize; first differing row={} before='{}' after='{}'",
first_diff,
before_line.trim_end(),
after_line.trim_end()
);
}
assert_eq!(
after_cursor, before_cursor,
"codex cursor should round-trip after up/down resize"
);
}
#[test]
fn codex_tui_stress_resize_redraw_matches_fresh_frame() {
let mut t = setup_codex_tui(80, 20);
render_like_codex_resize(&mut t, 80, 20);
assert_codex_frame_invariants(&t, 80, 20);
let sequence: &[(u16, u16)] =
&[(100, 34), (72, 18), (120, 40), (64, 16), (90, 28), (80, 20)];
for _cycle in 0..3 {
for (cols, rows) in sequence {
t.resize(*cols as usize, *rows as usize);
render_like_codex_resize(&mut t, *cols, *rows);
assert_codex_frame_invariants(&t, *cols, *rows);
}
}
// Final 80x20 redraw should match a fresh terminal at 80x20 exactly.
t.resize(80, 20);
render_like_codex_resize(&mut t, 80, 20);
let final_snapshot = snapshot_grid(&t);
let mut fresh = setup_codex_tui(80, 20);
render_like_codex_resize(&mut fresh, 80, 20);
let fresh_snapshot = snapshot_grid(&fresh);
assert_eq!(
final_snapshot, fresh_snapshot,
"stress resize/redraw should converge to fresh codex frame"
);
}
}