terminal emulator
git clone https://git.lucas.co/cce-terminal.git
Initial PTY spike: shell on a pty, rendered through cce-ui
Spawns $SHELL on an openpty pair (TERM=dumb, slave as controlling
terminal), pumps master output through a reader thread into the engine's
message channel, renders a plain-text scrollback as display-list text
(Berkeley Mono via the fontconfig `terminal` alias), and encodes key
presses back to the pty. TIOCSWINSZ tracks resize; wheel scrolls the
scrollback; EOF on the master exits the app.
term::Screen is a deliberate VT stub (CR/LF/BS/TAB, CSI/OSC swallowing,
erase-line, clear-screen) — next step replaces it with alacritty_terminal
and a real cell grid. Known gaps: no colors, no selection/clipboard, no
alt modifier (KeyEvent lacks one), cell metrics estimated (0.60 em).
Co-Authored-By: Claude Fable 5 <[email protected]>
.gitignore | 1 +
Cargo.toml | 10 +++
src/main.rs | 281 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/pty.rs | 113 ++++++++++++++++++++++++
src/term.rs | 206 ++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 611 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..b99657a
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "cce-terminal"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+cce-ui = { path = "../cce-ui" }
+wayland-client = { version = "0.31", features = ["system"] }
+calloop = "0.13.0"
+libc = "0.2"
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..dea319f
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,281 @@
+//! cce-terminal — terminal emulator for the cce desktop.
+//!
+//! PTY-spike state: spawns `$SHELL` on a pty (TERM=dumb), pumps output through
+//! a reader thread into the engine's message channel, renders a scrollback of
+//! plain text lines, and encodes key input back to the pty. The VT layer is a
+//! deliberate stub ([`term::Screen`]) — the planned next step replaces it with
+//! `alacritty_terminal` and a real cell grid.
+
+mod pty;
+mod term;
+
+use std::io::{Read, Write};
+
+use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+use cce_ui::scene::layout::Rect;
+use cce_ui::scene::paint::{DisplayList, PaintCtx};
+use cce_ui::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey};
+use wayland_client::QueueHandle;
+
+const FONT_SIZE: f32 = 14.0;
+/// Toolkit conventions: ceil(font_size × 1.2) line height (`text_leaf_height`),
+/// 0.60 × font_size mono advance (`estimate_label_width_helper`).
+const LINE_H: f32 = 17.0;
+const CELL_W: f32 = FONT_SIZE * 0.60;
+const FG: [u8; 3] = [0xd8, 0xd8, 0xde];
+const CURSOR: [f32; 4] = [0.80, 0.80, 0.85, 0.35];
+const INIT_W: u32 = 840;
+const INIT_H: u32 = 520;
+
+#[derive(Clone)]
+enum Msg {
+ Pty(Vec<u8>),
+ PtyClosed,
+}
+
+struct TerminalApp {
+ screen: term::Screen,
+ pty: pty::Pty,
+ writer: std::fs::File,
+ font: String,
+ pad: f32,
+ cols: u16,
+ rows: u16,
+ /// Lines scrolled up from the bottom of the scrollback.
+ scroll_offset: usize,
+}
+
+impl TerminalApp {
+ fn grid_for(&self, w: f32, h: f32) -> (u16, u16) {
+ let cols = (((w - 2.0 * self.pad) / CELL_W).floor() as i64).clamp(2, u16::MAX as i64);
+ let rows = (((h - 2.0 * self.pad) / LINE_H).floor() as i64).clamp(1, u16::MAX as i64);
+ (cols as u16, rows as u16)
+ }
+
+ fn write_pty(&mut self, bytes: &[u8]) {
+ let _ = self.writer.write_all(bytes);
+ }
+}
+
+/// Terminal byte encoding for a key press. `None` = nothing to send (bare
+/// modifiers, unmapped chords). No alt-as-ESC yet: `KeyEvent` carries no alt
+/// modifier — a toolkit extension when the real VT layer lands.
+fn encode_key(ev: &KeyEvent) -> Option<Vec<u8>> {
+ match &ev.logical_key {
+ Key::Named(k) => {
+ let b: &[u8] = match k {
+ NamedKey::Enter => b"\r",
+ NamedKey::Backspace => b"\x7f",
+ NamedKey::Tab => b"\t",
+ NamedKey::Escape => b"\x1b",
+ NamedKey::Space => b" ",
+ NamedKey::ArrowUp => b"\x1b[A",
+ NamedKey::ArrowDown => b"\x1b[B",
+ NamedKey::ArrowRight => b"\x1b[C",
+ NamedKey::ArrowLeft => b"\x1b[D",
+ NamedKey::Home => b"\x1b[H",
+ NamedKey::End => b"\x1b[F",
+ NamedKey::PageUp => b"\x1b[5~",
+ NamedKey::PageDown => b"\x1b[6~",
+ NamedKey::Delete => b"\x1b[3~",
+ _ => return None,
+ };
+ Some(b.to_vec())
+ }
+ Key::Character(s) => {
+ if ev.ctrl {
+ match s.chars().next()?.to_ascii_lowercase() {
+ c @ 'a'..='z' => Some(vec![c as u8 - b'a' + 1]),
+ '[' => Some(vec![0x1b]),
+ _ => None,
+ }
+ } else if let Some(t) = &ev.text {
+ Some(t.as_bytes().to_vec())
+ } else {
+ Some(s.as_bytes().to_vec())
+ }
+ }
+ }
+}
+
+impl Application for TerminalApp {
+ type Message = Msg;
+
+ fn new(
+ _qh: &QueueHandle<EngineState<Self>>,
+ sender: calloop::channel::Sender<Self::Message>,
+ ) -> Self {
+ let pad = cce_ui::layout::backplate_padding();
+ let cols = (((INIT_W as f32 - 2.0 * pad) / CELL_W) as i64).clamp(2, u16::MAX as i64) as u16;
+ let rows = (((INIT_H as f32 - 2.0 * pad) / LINE_H) as i64).clamp(1, u16::MAX as i64) as u16;
+
+ let pty = pty::spawn_shell(cols, rows).expect("cce-terminal: failed to spawn shell on pty");
+ let writer = pty.dup_handle().expect("cce-terminal: pty dup failed");
+ let mut reader = pty.dup_handle().expect("cce-terminal: pty dup failed");
+ std::thread::spawn(move || {
+ let mut buf = [0u8; 8192];
+ loop {
+ match reader.read(&mut buf) {
+ Ok(0) => break,
+ Ok(n) => {
+ if sender.send(Msg::Pty(buf[..n].to_vec())).is_err() {
+ return;
+ }
+ }
+ Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
+ // EIO when the last slave fd closes: the normal exit path.
+ Err(_) => break,
+ }
+ }
+ let _ = sender.send(Msg::PtyClosed);
+ });
+
+ // Index 6 of the preferred-fonts tuple is the `terminal` alias
+ // (~/.config/fontconfig/fonts.conf), falling back to Noto Sans Mono.
+ let font = cce_ui::layout::read_preferred_fonts().6;
+
+ TerminalApp {
+ screen: term::Screen::new(),
+ pty,
+ writer,
+ font,
+ pad,
+ cols,
+ rows,
+ scroll_offset: 0,
+ }
+ }
+
+ fn settings(&self) -> WindowSettings {
+ WindowSettings {
+ title: "cce-terminal".to_string(),
+ app_id: "cce-terminal".to_string(),
+ width: INIT_W,
+ height: INIT_H,
+ fullscreen: false,
+ min_size: Some((240, 140)),
+ }
+ }
+
+ fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool) {
+ match msg {
+ Msg::Pty(bytes) => {
+ self.screen.feed(&bytes);
+ self.scroll_offset = 0;
+ *needs_rebuild = true;
+ }
+ Msg::PtyClosed => {
+ let _ = self.pty.child.wait();
+ *exit = true;
+ }
+ }
+ }
+
+ fn tick(&mut self, _dt: f32, _needs_rebuild: &mut bool) {}
+
+ fn handle_resize(&mut self, width: f32, height: f32, _scale: f64) {
+ let (cols, rows) = self.grid_for(width, height);
+ if (cols, rows) != (self.cols, self.rows) {
+ self.cols = cols;
+ self.rows = rows;
+ self.pty.resize(cols, rows);
+ }
+ }
+
+ fn display_list(&mut self, size: LogicalSize, _scale: f64) -> Option<DisplayList> {
+ let (w, h) = (size.width, size.height);
+ let mut pc = PaintCtx::new();
+
+ // The window plate, per the DE convention (page-low color at backplate
+ // opacity, config corner radius, rolled perimeter).
+ let mut plate = cce_ui::color::page_low_color();
+ if plate[3] > 0.001 {
+ plate[3] = cce_ui::color::active_backplate_opacity();
+ }
+ let radius = cce_ui::colors::backplate_corner_radius();
+ let frame = Rect { x: 0.0, y: 0.0, width: w, height: h };
+ pc.plate(frame, (radius, radius, radius, radius), plate, cce_ui::layout::bevel_width());
+
+ let (cols, rows) = self.grid_for(w, h);
+ let bounds = [self.pad, 0.0, w - self.pad, h];
+ let visible = self.screen.visible(rows as usize, self.scroll_offset);
+ for (i, line) in visible.iter().enumerate() {
+ if line.is_empty() {
+ continue;
+ }
+ let text: String = line.iter().take(cols as usize).collect();
+ pc.text_with(
+ text,
+ self.pad,
+ self.pad + i as f32 * LINE_H,
+ FONT_SIZE,
+ FG,
+ Some(self.font.clone()),
+ Some(bounds),
+ );
+ }
+
+ // Block cursor, only while viewing the live bottom of the scrollback.
+ if self.scroll_offset == 0 && !visible.is_empty() {
+ let row = visible.len() - 1;
+ let cx = self.pad + self.screen.cursor_col.min(cols as usize) as f32 * CELL_W;
+ let cy = self.pad + row as f32 * LINE_H;
+ pc.quad(Rect { x: cx, y: cy, width: CELL_W, height: LINE_H }, CURSOR);
+ }
+
+ Some(pc.finish())
+ }
+
+ fn display_list_text(&self) -> bool {
+ true
+ }
+
+ fn handle_pointer_move(&mut self, _pos: LogicalPosition, _needs_rebuild: &mut bool) {}
+
+ fn handle_mouse_input(
+ &mut self,
+ _button: MouseButton,
+ _state: ElementState,
+ _pos: LogicalPosition,
+ _needs_rebuild: &mut bool,
+ ) -> Option<Self::Message> {
+ None
+ }
+
+ fn handle_mouse_wheel(
+ &mut self,
+ delta: &MouseScrollDelta,
+ _pos: LogicalPosition,
+ needs_rebuild: &mut bool,
+ ) {
+ let lines = (delta.notches_y() * 3.0).round() as i64;
+ let max = self.screen.line_count().saturating_sub(1);
+ let next = (self.scroll_offset as i64 + lines).clamp(0, max as i64) as usize;
+ if next != self.scroll_offset {
+ self.scroll_offset = next;
+ *needs_rebuild = true;
+ }
+ }
+
+ fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
+ if event.state != ElementState::Pressed {
+ return None;
+ }
+ if let Some(bytes) = encode_key(event) {
+ self.write_pty(&bytes);
+ if self.scroll_offset != 0 {
+ self.scroll_offset = 0;
+ *needs_rebuild = true;
+ }
+ }
+ None
+ }
+
+ fn clear_color(&self) -> [f32; 4] {
+ [0.0, 0.0, 0.0, 0.0]
+ }
+}
+
+fn main() {
+ cce_ui::engine::run::<TerminalApp>();
+}
diff --git a/src/pty.rs b/src/pty.rs
new file mode 100644
index 0000000..f207a03
--- /dev/null
+++ b/src/pty.rs
@@ -0,0 +1,113 @@
+//! PTY plumbing: openpty, shell spawn with the slave as controlling terminal,
+//! and dup'd master handles for the reader thread / key-input writes.
+
+use std::fs::File;
+use std::io;
+use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
+use std::os::unix::process::CommandExt;
+use std::process::{Child, Command, Stdio};
+
+pub struct Pty {
+ pub master: OwnedFd,
+ pub child: Child,
+}
+
+/// Open a pty pair and spawn `$SHELL` on the slave side, in its own session
+/// with the slave as controlling terminal. The slave fd is fully handed to the
+/// child (stdin/stdout/stderr) and closed in the parent, so EOF on the master
+/// is the child-exit signal.
+pub fn spawn_shell(cols: u16, rows: u16) -> io::Result<Pty> {
+ let mut master: libc::c_int = -1;
+ let mut slave: libc::c_int = -1;
+ let ws = libc::winsize { ws_row: rows, ws_col: cols, ws_xpixel: 0, ws_ypixel: 0 };
+ let ret = unsafe {
+ libc::openpty(&mut master, &mut slave, std::ptr::null_mut(), std::ptr::null(), &ws)
+ };
+ if ret != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ let master = unsafe { OwnedFd::from_raw_fd(master) };
+ let slave = unsafe { OwnedFd::from_raw_fd(slave) };
+ unsafe { libc::fcntl(master.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) };
+
+ let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
+ let mut cmd = Command::new(&shell);
+ // TERM=dumb for the spike: the screen model only understands a VT subset,
+ // so keep prompts/tools from emitting full escape traffic. Switches to a
+ // real terminfo entry once a proper VT layer (alacritty_terminal) lands.
+ cmd.env("TERM", "dumb")
+ .stdin(Stdio::from(slave.try_clone()?))
+ .stdout(Stdio::from(slave.try_clone()?))
+ .stderr(Stdio::from(slave));
+ unsafe {
+ cmd.pre_exec(|| {
+ if libc::setsid() < 0 {
+ return Err(io::Error::last_os_error());
+ }
+ // stdin IS the slave after the Stdio wiring above.
+ if libc::ioctl(0, libc::TIOCSCTTY as libc::c_ulong, 0) < 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(())
+ });
+ }
+ let child = cmd.spawn()?;
+ Ok(Pty { master, child })
+}
+
+impl Pty {
+ pub fn resize(&self, cols: u16, rows: u16) {
+ let ws = libc::winsize { ws_row: rows, ws_col: cols, ws_xpixel: 0, ws_ypixel: 0 };
+ unsafe { libc::ioctl(self.master.as_raw_fd(), libc::TIOCSWINSZ, &ws) };
+ }
+
+ /// A dup of the master as a `File` (own fd, CLOEXEC) — one for the reader
+ /// thread, one for key-input writes.
+ pub fn dup_handle(&self) -> io::Result<File> {
+ let fd = unsafe { libc::fcntl(self.master.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 3) };
+ if fd < 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(unsafe { File::from_raw_fd(fd) })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::{Read, Write};
+ use std::time::{Duration, Instant};
+
+ /// Full round trip through a real shell: keystroke bytes in on the master,
+ /// command output back out — the headless equivalent of typing into the
+ /// window (the GUI path is `handle_key_input` → the same master fd).
+ #[test]
+ fn shell_round_trip() {
+ let mut pty = spawn_shell(80, 24).expect("openpty/spawn");
+ let mut writer = pty.dup_handle().unwrap();
+ let mut reader = pty.dup_handle().unwrap();
+ writer.write_all(b"printf 'RT-%s\\n' OK; exit\r").unwrap();
+
+ let deadline = Instant::now() + Duration::from_secs(10);
+ let mut out = Vec::new();
+ let mut buf = [0u8; 4096];
+ while Instant::now() < deadline {
+ match reader.read(&mut buf) {
+ Ok(0) | Err(_) => break, // EOF/EIO: shell exited
+ Ok(n) => {
+ out.extend_from_slice(&buf[..n]);
+ if String::from_utf8_lossy(&out).contains("RT-OK") {
+ break;
+ }
+ }
+ }
+ }
+ assert!(
+ String::from_utf8_lossy(&out).contains("RT-OK"),
+ "no round-trip output; got: {:?}",
+ String::from_utf8_lossy(&out)
+ );
+ let _ = pty.child.kill();
+ let _ = pty.child.wait();
+ }
+}
diff --git a/src/term.rs b/src/term.rs
new file mode 100644
index 0000000..14d1647
--- /dev/null
+++ b/src/term.rs
@@ -0,0 +1,206 @@
+//! Spike-grade screen model: a scrollback of logical lines with a cursor
+//! column, fed raw PTY bytes. Understands the control bytes a `TERM=dumb`
+//! shell actually emits (\r \n \b \t BEL) plus enough of ESC/CSI/OSC to
+//! swallow stray sequences and honor erase-line / clear-screen. This is a
+//! placeholder for a real VT layer (alacritty_terminal) — deliberately no
+//! grid, no colors, no cursor addressing.
+
+const MAX_SCROLLBACK: usize = 5000;
+
+enum EscState {
+ Ground,
+ Esc,
+ /// ESC ( ) # % charset selectors: swallow exactly one following byte.
+ EscTakeOne,
+ Csi { params: String },
+ Osc { esc_pending: bool },
+}
+
+pub struct Screen {
+ /// Logical lines, oldest first; the last line is where the cursor lives.
+ lines: Vec<Vec<char>>,
+ pub cursor_col: usize,
+ esc: EscState,
+ /// Incomplete UTF-8 tail carried across feeds.
+ pending: Vec<u8>,
+}
+
+impl Screen {
+ pub fn new() -> Self {
+ Screen { lines: vec![Vec::new()], cursor_col: 0, esc: EscState::Ground, pending: Vec::new() }
+ }
+
+ pub fn line_count(&self) -> usize {
+ self.lines.len()
+ }
+
+ /// The last `rows` lines, ending `offset` lines above the bottom.
+ pub fn visible(&self, rows: usize, offset: usize) -> &[Vec<char>] {
+ let n = self.lines.len();
+ let offset = offset.min(n.saturating_sub(1));
+ let end = n - offset;
+ let start = end.saturating_sub(rows);
+ &self.lines[start..end]
+ }
+
+ pub fn feed(&mut self, bytes: &[u8]) {
+ let mut buf = std::mem::take(&mut self.pending);
+ buf.extend_from_slice(bytes);
+ let mut rest = buf.as_slice();
+ loop {
+ match std::str::from_utf8(rest) {
+ Ok(s) => {
+ for c in s.chars() {
+ self.advance(c);
+ }
+ break;
+ }
+ Err(e) => {
+ let (valid, tail) = rest.split_at(e.valid_up_to());
+ // Unwrap is fine: split at valid_up_to is valid by construction.
+ for c in std::str::from_utf8(valid).unwrap().chars() {
+ self.advance(c);
+ }
+ match e.error_len() {
+ // Incomplete sequence at the end: keep for the next feed.
+ None => {
+ self.pending = tail.to_vec();
+ break;
+ }
+ // Invalid bytes mid-stream: emit U+FFFD and continue after.
+ Some(n) => {
+ self.advance('\u{fffd}');
+ rest = &tail[n..];
+ }
+ }
+ }
+ }
+ }
+ }
+
+ fn advance(&mut self, c: char) {
+ match &mut self.esc {
+ EscState::Ground => match c {
+ '\u{1b}' => self.esc = EscState::Esc,
+ '\n' => self.newline(),
+ '\r' => self.cursor_col = 0,
+ '\u{8}' => self.cursor_col = self.cursor_col.saturating_sub(1),
+ '\t' => self.cursor_col = (self.cursor_col / 8 + 1) * 8,
+ c if (c as u32) < 0x20 || c == '\u{7f}' => {}
+ c => self.put(c),
+ },
+ EscState::Esc => match c {
+ '[' => self.esc = EscState::Csi { params: String::new() },
+ ']' => self.esc = EscState::Osc { esc_pending: false },
+ '(' | ')' | '#' | '%' => self.esc = EscState::EscTakeOne,
+ _ => self.esc = EscState::Ground,
+ },
+ EscState::EscTakeOne => self.esc = EscState::Ground,
+ EscState::Csi { params } => match c {
+ '\u{20}'..='\u{3f}' => params.push(c),
+ // Final byte: act on the few we honor, swallow the rest.
+ '\u{40}'..='\u{7e}' => {
+ let params = std::mem::take(params);
+ self.esc = EscState::Ground;
+ match c {
+ 'K' => {
+ let col = self.cursor_col;
+ let line = self.lines.last_mut().unwrap();
+ line.truncate(col);
+ }
+ 'J' if params.starts_with('2') || params.starts_with('3') => {
+ self.lines = vec![Vec::new()];
+ self.cursor_col = 0;
+ }
+ _ => {}
+ }
+ }
+ _ => self.esc = EscState::Ground,
+ },
+ EscState::Osc { esc_pending } => match c {
+ '\u{7}' => self.esc = EscState::Ground,
+ '\u{1b}' => *esc_pending = true,
+ '\\' if *esc_pending => self.esc = EscState::Ground,
+ _ => *esc_pending = false,
+ },
+ }
+ }
+
+ fn newline(&mut self) {
+ self.lines.push(Vec::new());
+ if self.lines.len() > MAX_SCROLLBACK {
+ let excess = self.lines.len() - MAX_SCROLLBACK;
+ self.lines.drain(..excess);
+ }
+ }
+
+ fn put(&mut self, c: char) {
+ let line = self.lines.last_mut().unwrap();
+ while line.len() < self.cursor_col {
+ line.push(' ');
+ }
+ if self.cursor_col < line.len() {
+ line[self.cursor_col] = c;
+ } else {
+ line.push(c);
+ }
+ self.cursor_col += 1;
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn text(s: &Screen) -> Vec<String> {
+ s.lines.iter().map(|l| l.iter().collect()).collect()
+ }
+
+ #[test]
+ fn plain_lines() {
+ let mut s = Screen::new();
+ s.feed(b"hello\r\nworld");
+ assert_eq!(text(&s), vec!["hello", "world"]);
+ assert_eq!(s.cursor_col, 5);
+ }
+
+ #[test]
+ fn carriage_return_overwrites() {
+ let mut s = Screen::new();
+ s.feed(b"abcdef\rXY");
+ assert_eq!(text(&s), vec!["XYcdef"]);
+ }
+
+ #[test]
+ fn csi_swallowed_and_erase_line() {
+ let mut s = Screen::new();
+ s.feed(b"ab\x1b[31mcd\x1b[0m");
+ assert_eq!(text(&s), vec!["abcd"]);
+ s.feed(b"\rZ\x1b[K");
+ assert_eq!(text(&s), vec!["Z"]);
+ }
+
+ #[test]
+ fn osc_swallowed() {
+ let mut s = Screen::new();
+ s.feed(b"\x1b]0;title\x07ok\x1b]2;t\x1b\\!");
+ assert_eq!(text(&s), vec!["ok!"]);
+ }
+
+ #[test]
+ fn split_utf8_across_feeds() {
+ let mut s = Screen::new();
+ let bytes = "héllo".as_bytes();
+ s.feed(&bytes[..2]); // 'h' + first byte of é
+ s.feed(&bytes[2..]);
+ assert_eq!(text(&s), vec!["héllo"]);
+ }
+
+ #[test]
+ fn backspace_and_tab() {
+ let mut s = Screen::new();
+ s.feed(b"ab\x08X\tY");
+ // 'X' overwrites 'b' at col 1, tab jumps to col 8, 'Y' lands there.
+ assert_eq!(text(&s), vec!["aX Y"]);
+ }
+}