git.lucas.co / cce-terminal
terminal emulator
git clone https://git.lucas.co/cce-terminal.git

commitafaea5338692701c02006406e56cf4c569c3ec27
authorLucas Galante <[email protected]>
date2026-08-10 18:21
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"]);
+    }
+}