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

commit914449ab8eb045ac63ebddea28c6469cf4711a09
parentafaea53386
authorLucas Galante <[email protected]>
date2026-08-10 18:33
Swap the VT stub for alacritty_terminal: real grid, colors, TUIs

term::Screen is gone; VT emulation is now alacritty_terminal 0.26's Term
fed by its vte parser (TERM=alacritty + COLORTERM=truecolor, terminfo
verified present). Rendering walks renderable_content once per frame,
batching cell backgrounds into merged quads and foreground text into
per-style runs (bold→weight 700, italic, dim, inverse, underline/
strikeout as 1px quads, wide chars end runs so advance drift can't
accumulate). colors.rs owns the 269-entry palette (base16-default ANSI,
xterm cube, gray ramp, named specials) with OSC 4/10/11 overrides; bg
quads convert sRGB→linear for the geometry pipeline. Default background
stays transparent so the DE plate shows through.

Term events route through the engine message channel: PtyWrite responses,
OSC titles via the settings().title poll, color/text-area size queries
answered. Keys honor DECCKM app-cursor mode and snap the view to the
bottom; the wheel scrolls history (fraction-accumulating) or synthesizes
arrows on the alternate screen; the cursor renders block/beam/underline
plus a hollow outline when unfocused. New: `-e <cmd>` runs a command
instead of $SHELL.

Live-verified: SGR attribute/truecolor sweep and htop (alt screen, row
bars, bottom-pinned key bar) both render correctly; 8 headless tests.

Co-Authored-By: Claude Fable 5 <[email protected]>

 Cargo.toml    |   1 +
 src/colors.rs | 134 +++++++++++++++
 src/main.rs   | 532 +++++++++++++++++++++++++++++++++++++++++++++++++++-------
 src/pty.rs    |  30 ++--
 src/term.rs   | 206 -----------------------
 5 files changed, 627 insertions(+), 276 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index b99657a..f06de0e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,3 +8,4 @@ cce-ui = { path = "../cce-ui" }
 wayland-client = { version = "0.31", features = ["system"] }
 calloop = "0.13.0"
 libc = "0.2"
+alacritty_terminal = "0.26.0"
diff --git a/src/colors.rs b/src/colors.rs
new file mode 100644
index 0000000..f3238a1
--- /dev/null
+++ b/src/colors.rs
@@ -0,0 +1,134 @@
+//! Terminal color resolution: the default 269-entry palette (16 ANSI + 6×6×6
+//! cube + grayscale ramp + named specials) and the mapping from a cell's
+//! `vte::ansi::Color` through runtime overrides (OSC 4/10/11) to concrete RGB.
+
+use alacritty_terminal::term::color::Colors;
+use alacritty_terminal::vte::ansi::{Color, NamedColor, Rgb};
+
+pub const FOREGROUND: Rgb = Rgb { r: 0xd8, g: 0xd8, b: 0xde };
+pub const BACKGROUND: Rgb = Rgb { r: 0x22, g: 0x26, b: 0x2e };
+
+/// Base16 default-dark ANSI colors (alacritty's classic defaults) — muted
+/// enough to sit on the DE plate.
+const ANSI: [Rgb; 16] = [
+    Rgb { r: 0x18, g: 0x18, b: 0x18 }, // black
+    Rgb { r: 0xac, g: 0x42, b: 0x42 }, // red
+    Rgb { r: 0x90, g: 0xa9, b: 0x59 }, // green
+    Rgb { r: 0xf4, g: 0xbf, b: 0x75 }, // yellow
+    Rgb { r: 0x6a, g: 0x9f, b: 0xb5 }, // blue
+    Rgb { r: 0xaa, g: 0x75, b: 0x9f }, // magenta
+    Rgb { r: 0x75, g: 0xb5, b: 0xaa }, // cyan
+    Rgb { r: 0xd8, g: 0xd8, b: 0xd8 }, // white
+    Rgb { r: 0x6b, g: 0x6b, b: 0x6b }, // bright black
+    Rgb { r: 0xc5, g: 0x55, b: 0x55 }, // bright red
+    Rgb { r: 0xaa, g: 0xc4, b: 0x74 }, // bright green
+    Rgb { r: 0xfe, g: 0xca, b: 0x88 }, // bright yellow
+    Rgb { r: 0x82, g: 0xb8, b: 0xc8 }, // bright blue
+    Rgb { r: 0xc2, g: 0x8c, b: 0xb8 }, // bright magenta
+    Rgb { r: 0x93, g: 0xd3, b: 0xc3 }, // bright cyan
+    Rgb { r: 0xf8, g: 0xf8, b: 0xf8 }, // bright white
+];
+
+fn scale(rgb: Rgb, f: f32) -> Rgb {
+    Rgb {
+        r: (rgb.r as f32 * f) as u8,
+        g: (rgb.g as f32 * f) as u8,
+        b: (rgb.b as f32 * f) as u8,
+    }
+}
+
+/// Default color for a palette index (0..269), matching alacritty's layout:
+/// 0–15 ANSI, 16–231 the xterm 6×6×6 cube, 232–255 the grayscale ramp, then
+/// the `NamedColor` specials (256 = Foreground …).
+pub fn default_color(index: usize) -> Rgb {
+    match index {
+        0..=15 => ANSI[index],
+        16..=231 => {
+            let i = index - 16;
+            let comp = |v: usize| if v == 0 { 0 } else { (55 + v * 40) as u8 };
+            Rgb { r: comp(i / 36), g: comp((i / 6) % 6), b: comp(i % 6) }
+        }
+        232..=255 => {
+            let v = (8 + (index - 232) * 10) as u8;
+            Rgb { r: v, g: v, b: v }
+        }
+        i if i == NamedColor::Foreground as usize => FOREGROUND,
+        i if i == NamedColor::Background as usize => BACKGROUND,
+        i if i == NamedColor::Cursor as usize => FOREGROUND,
+        i if i == NamedColor::BrightForeground as usize => FOREGROUND,
+        i if i == NamedColor::DimForeground as usize => scale(FOREGROUND, 0.66),
+        // DimBlack..=DimWhite
+        i if (NamedColor::DimBlack as usize..=NamedColor::DimWhite as usize).contains(&i) => {
+            scale(ANSI[i - NamedColor::DimBlack as usize], 0.66)
+        }
+        _ => FOREGROUND,
+    }
+}
+
+/// Palette index → RGB through the terminal's runtime overrides.
+pub fn indexed(index: usize, overrides: &Colors) -> Rgb {
+    overrides[index].unwrap_or_else(|| default_color(index))
+}
+
+/// A cell color → RGB. `dim` applies the SGR 2 treatment: named colors route
+/// to their Dim* palette slots, direct/indexed colors scale by 0.66.
+pub fn resolve(color: Color, overrides: &Colors, dim: bool) -> Rgb {
+    match color {
+        Color::Spec(rgb) => {
+            if dim {
+                scale(rgb, 0.66)
+            } else {
+                rgb
+            }
+        }
+        Color::Named(name) => {
+            let name = if dim { name.to_dim() } else { name };
+            indexed(name as usize, overrides)
+        }
+        Color::Indexed(i) => {
+            let rgb = indexed(i as usize, overrides);
+            if dim {
+                scale(rgb, 0.66)
+            } else {
+                rgb
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn cube_and_gray_ramp() {
+        // 16 = cube (0,0,0); 231 = cube (255,255,255); 244 = mid gray.
+        assert_eq!(default_color(16), Rgb { r: 0, g: 0, b: 0 });
+        assert_eq!(default_color(231), Rgb { r: 255, g: 255, b: 255 });
+        assert_eq!(default_color(196), Rgb { r: 255, g: 0, b: 0 }); // cube (5,0,0)
+        assert_eq!(default_color(244), Rgb { r: 128, g: 128, b: 128 });
+    }
+
+    #[test]
+    fn named_specials() {
+        assert_eq!(default_color(NamedColor::Foreground as usize), FOREGROUND);
+        assert_eq!(default_color(NamedColor::Background as usize), BACKGROUND);
+        assert_eq!(
+            default_color(NamedColor::DimRed as usize),
+            scale(ANSI[1], 0.66)
+        );
+    }
+
+    #[test]
+    fn resolve_respects_overrides() {
+        let mut overrides = Colors::default();
+        let custom = Rgb { r: 1, g: 2, b: 3 };
+        overrides[NamedColor::Red as usize] = Some(custom);
+        assert_eq!(resolve(Color::Named(NamedColor::Red), &overrides, false), custom);
+        assert_eq!(
+            resolve(Color::Named(NamedColor::Green), &overrides, false),
+            ANSI[2]
+        );
+        assert_eq!(resolve(Color::Indexed(1), &overrides, false), custom);
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index dea319f..fa19738 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,19 +1,33 @@
 //! 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.
+//! VT emulation is `alacritty_terminal`'s `Term` fed by its `vte` parser; this
+//! crate owns the pty ([`pty`]), the palette mapping ([`colors`]), and the
+//! rendering onto cce-ui's display-list path: cell backgrounds and decorations
+//! as quads, foreground text batched into per-style runs, everything on the
+//! DE's standard window plate. Key input encodes to terminal bytes
+//! (APP_CURSOR-aware); the wheel scrolls the scrollback (or synthesizes
+//! arrows on the alternate screen); OSC titles flow to the xdg toplevel via
+//! the engine's `settings().title` poll.
+//!
+//! Not yet: selection/clipboard, mouse reporting, alt-as-ESC (cce-ui's
+//! `KeyEvent` carries no alt modifier), measured cell metrics (0.60 em / 1.2 em
+//! estimates — exact for Berkeley Mono in practice).
 
+mod colors;
 mod pty;
-mod term;
 
 use std::io::{Read, Write};
 
+use alacritty_terminal::event::{Event as TermEvent, EventListener, WindowSize};
+use alacritty_terminal::grid::{Dimensions, Scroll};
+use alacritty_terminal::term::cell::Flags;
+use alacritty_terminal::term::{Config as TermConfig, Term, TermMode};
+use alacritty_terminal::vte::ansi::{
+    Color as AnsiColor, CursorShape, NamedColor, Processor, Rgb,
+};
 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::scene::paint::{DisplayList, PaintCtx, TextAttrs};
 use cce_ui::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey};
 use wayland_client::QueueHandle;
 
@@ -22,27 +36,62 @@ const FONT_SIZE: f32 = 14.0;
 /// 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;
+const SCROLLBACK: usize = 5000;
+/// Wheel notches → scrollback lines.
+const SCROLL_LINES_PER_NOTCH: f32 = 3.0;
 
 #[derive(Clone)]
 enum Msg {
     Pty(Vec<u8>),
     PtyClosed,
+    Term(TermEvent),
+}
+
+/// The terminal's grid dimensions, for `Term::new`/`resize`.
+#[derive(Clone, Copy)]
+struct TermDims {
+    cols: u16,
+    rows: u16,
+}
+
+impl Dimensions for TermDims {
+    fn total_lines(&self) -> usize {
+        self.rows as usize
+    }
+    fn screen_lines(&self) -> usize {
+        self.rows as usize
+    }
+    fn columns(&self) -> usize {
+        self.cols as usize
+    }
+}
+
+/// Forwards `Term`'s synthesized events (pty write-backs, title changes …)
+/// into the engine's message channel; they are handled in `update`.
+struct EventProxy(calloop::channel::Sender<Msg>);
+
+impl EventListener for EventProxy {
+    fn send_event(&self, event: TermEvent) {
+        let _ = self.0.send(Msg::Term(event));
+    }
 }
 
 struct TerminalApp {
-    screen: term::Screen,
+    term: Term<EventProxy>,
+    parser: Processor,
     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,
+    /// OSC title; polled by the engine through `settings().title`.
+    title: Option<String>,
+    /// Fractional wheel-scroll remainder (trackpad pixel deltas).
+    scroll_accum: f32,
+    focused: bool,
 }
 
 impl TerminalApp {
@@ -55,12 +104,33 @@ impl TerminalApp {
     fn write_pty(&mut self, bytes: &[u8]) {
         let _ = self.writer.write_all(bytes);
     }
+
+    fn cell_rect(&self, row: usize, col: usize, width_cells: usize) -> Rect {
+        Rect {
+            x: self.pad + col as f32 * CELL_W,
+            y: self.pad + row as f32 * LINE_H,
+            width: width_cells as f32 * CELL_W,
+            height: LINE_H,
+        }
+    }
+}
+
+/// Terminal-space RGB → cce-ui quad color (the geometry pipeline is linear;
+/// terminal colors are sRGB).
+fn quad_color(rgb: Rgb, alpha: f32) -> [f32; 4] {
+    [
+        cce_ui::color::srgb_to_linear(rgb.r as f32 / 255.0),
+        cce_ui::color::srgb_to_linear(rgb.g as f32 / 255.0),
+        cce_ui::color::srgb_to_linear(rgb.b as f32 / 255.0),
+        alpha,
+    ]
 }
 
-/// 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>> {
+/// Terminal byte encoding for a key press; `None` = nothing to send. Honors
+/// DECCKM (application cursor keys). No alt-as-ESC yet: `KeyEvent` carries no
+/// alt modifier.
+fn encode_key(ev: &KeyEvent, mode: TermMode) -> Option<Vec<u8>> {
+    let app = mode.contains(TermMode::APP_CURSOR);
     match &ev.logical_key {
         Key::Named(k) => {
             let b: &[u8] = match k {
@@ -69,15 +139,52 @@ fn encode_key(ev: &KeyEvent) -> Option<Vec<u8>> {
                 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::ArrowUp => {
+                    if app {
+                        b"\x1bOA"
+                    } else {
+                        b"\x1b[A"
+                    }
+                }
+                NamedKey::ArrowDown => {
+                    if app {
+                        b"\x1bOB"
+                    } else {
+                        b"\x1b[B"
+                    }
+                }
+                NamedKey::ArrowRight => {
+                    if app {
+                        b"\x1bOC"
+                    } else {
+                        b"\x1b[C"
+                    }
+                }
+                NamedKey::ArrowLeft => {
+                    if app {
+                        b"\x1bOD"
+                    } else {
+                        b"\x1b[D"
+                    }
+                }
+                NamedKey::Home => {
+                    if app {
+                        b"\x1bOH"
+                    } else {
+                        b"\x1b[H"
+                    }
+                }
+                NamedKey::End => {
+                    if app {
+                        b"\x1bOF"
+                    } else {
+                        b"\x1b[F"
+                    }
+                }
                 NamedKey::PageUp => b"\x1b[5~",
                 NamedKey::PageDown => b"\x1b[6~",
                 NamedKey::Delete => b"\x1b[3~",
+                NamedKey::F5 => b"\x1b[15~",
                 _ => return None,
             };
             Some(b.to_vec())
@@ -87,6 +194,8 @@ fn encode_key(ev: &KeyEvent) -> Option<Vec<u8>> {
                 match s.chars().next()?.to_ascii_lowercase() {
                     c @ 'a'..='z' => Some(vec![c as u8 - b'a' + 1]),
                     '[' => Some(vec![0x1b]),
+                    '\\' => Some(vec![0x1c]),
+                    ']' => Some(vec![0x1d]),
                     _ => None,
                 }
             } else if let Some(t) = &ev.text {
@@ -98,6 +207,18 @@ fn encode_key(ev: &KeyEvent) -> Option<Vec<u8>> {
     }
 }
 
+/// A run of contiguous same-style cells on one row, batched into a single
+/// text prim (valid because the grid is monospace-cell-addressed).
+struct TextRun {
+    row: usize,
+    col: usize,
+    next_col: usize,
+    text: String,
+    fg: Rgb,
+    bold: bool,
+    italic: bool,
+}
+
 impl Application for TerminalApp {
     type Message = Msg;
 
@@ -109,16 +230,26 @@ impl Application for TerminalApp {
         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");
+        // `cce-terminal -e <cmd> [args…]` runs a command instead of $SHELL.
+        let args: Vec<String> = std::env::args().collect();
+        let command: Option<Vec<String>> = args
+            .iter()
+            .position(|a| a == "-e")
+            .map(|i| args[i + 1..].to_vec())
+            .filter(|c| !c.is_empty());
+
+        let pty = pty::spawn_shell(cols, rows, command.as_deref())
+            .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");
+        let pty_sender = sender.clone();
         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() {
+                        if pty_sender.send(Msg::Pty(buf[..n].to_vec())).is_err() {
                             return;
                         }
                     }
@@ -127,28 +258,34 @@ impl Application for TerminalApp {
                     Err(_) => break,
                 }
             }
-            let _ = sender.send(Msg::PtyClosed);
+            let _ = pty_sender.send(Msg::PtyClosed);
         });
 
+        let config = TermConfig { scrolling_history: SCROLLBACK, ..TermConfig::default() };
+        let term = Term::new(config, &TermDims { cols, rows }, EventProxy(sender));
+
         // 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(),
+            term,
+            parser: Processor::new(),
             pty,
             writer,
             font,
             pad,
             cols,
             rows,
-            scroll_offset: 0,
+            title: None,
+            scroll_accum: 0.0,
+            focused: true,
         }
     }
 
     fn settings(&self) -> WindowSettings {
         WindowSettings {
-            title: "cce-terminal".to_string(),
+            title: self.title.clone().unwrap_or_else(|| "cce-terminal".to_string()),
             app_id: "cce-terminal".to_string(),
             width: INIT_W,
             height: INIT_H,
@@ -160,14 +297,44 @@ impl Application for TerminalApp {
     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;
+                self.parser.advance(&mut self.term, &bytes);
                 *needs_rebuild = true;
             }
             Msg::PtyClosed => {
                 let _ = self.pty.child.wait();
                 *exit = true;
             }
+            Msg::Term(event) => match event {
+                TermEvent::PtyWrite(s) => self.write_pty(s.clone().as_bytes()),
+                TermEvent::Title(t) => {
+                    self.title = Some(t);
+                    *needs_rebuild = true;
+                }
+                TermEvent::ResetTitle => {
+                    self.title = None;
+                    *needs_rebuild = true;
+                }
+                TermEvent::ColorRequest(index, format) => {
+                    let rgb = self
+                        .term
+                        .colors()[index]
+                        .unwrap_or_else(|| colors::default_color(index));
+                    let response = format(rgb);
+                    self.write_pty(response.as_bytes());
+                }
+                TermEvent::TextAreaSizeRequest(format) => {
+                    let response = format(WindowSize {
+                        num_lines: self.rows,
+                        num_cols: self.cols,
+                        cell_width: CELL_W as u16,
+                        cell_height: LINE_H as u16,
+                    });
+                    self.write_pty(response.as_bytes());
+                }
+                // Selection/clipboard phase: ClipboardStore/ClipboardLoad.
+                // Bell/Wakeup/cursor-blink: nothing to do yet.
+                _ => {}
+            },
         }
     }
 
@@ -179,6 +346,7 @@ impl Application for TerminalApp {
             self.cols = cols;
             self.rows = rows;
             self.pty.resize(cols, rows);
+            self.term.resize(TermDims { cols, rows });
         }
     }
 
@@ -186,8 +354,7 @@ impl Application for TerminalApp {
         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).
+        // The window plate, per the DE convention.
         let mut plate = cce_ui::color::page_low_color();
         if plate[3] > 0.001 {
             plate[3] = cce_ui::color::active_backplate_opacity();
@@ -196,31 +363,199 @@ impl Application for TerminalApp {
         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() {
+        let rows = self.rows as usize;
+        let pad = self.pad;
+        let font = self.font.clone();
+        let bounds = [pad, 0.0, w - pad, h];
+
+        let content = self.term.renderable_content();
+        let display_offset = content.display_offset as i32;
+        let palette = content.colors;
+
+        // One ordered pass over the viewport cells, batching backgrounds and
+        // same-style text into runs. Emission order: bg quads → text →
+        // decorations → cursor.
+        let mut bg_runs: Vec<(usize, usize, usize, Rgb)> = Vec::new();
+        let mut text_runs: Vec<TextRun> = Vec::new();
+        // (row, col_start, col_end, color, is_strikeout)
+        let mut deco_runs: Vec<(usize, usize, usize, Rgb, bool)> = Vec::new();
+        let mut cur_bg: Option<(usize, usize, usize, Rgb)> = None;
+        let mut cur_text: Option<TextRun> = None;
+
+        for cell in content.display_iter {
+            let row_i = cell.point.line.0 + display_offset;
+            if row_i < 0 {
+                continue;
+            }
+            let row = row_i as usize;
+            if row >= rows {
+                break;
+            }
+            let col = cell.point.column.0;
+            let flags = cell.flags;
+            if flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) {
                 continue;
             }
-            let text: String = line.iter().take(cols as usize).collect();
-            pc.text_with(
-                text,
-                self.pad,
-                self.pad + i as f32 * LINE_H,
+            let width_cells = if flags.contains(Flags::WIDE_CHAR) { 2 } else { 1 };
+            let (fg_color, bg_color) = if flags.contains(Flags::INVERSE) {
+                (cell.bg, cell.fg)
+            } else {
+                (cell.fg, cell.bg)
+            };
+            let dim = flags.intersects(Flags::DIM);
+            let fg = colors::resolve(fg_color, palette, dim);
+
+            // Background run (skip the default background: the plate shows through).
+            let bg = (bg_color != AnsiColor::Named(NamedColor::Background))
+                .then(|| colors::resolve(bg_color, palette, false));
+            match (&mut cur_bg, bg) {
+                (Some((r, _s, e, rgb)), Some(new)) if *r == row && *e == col && *rgb == new => {
+                    *e = col + width_cells;
+                }
+                (run, bg) => {
+                    if let Some(done) = run.take() {
+                        bg_runs.push(done);
+                    }
+                    if let Some(new) = bg {
+                        *run = Some((row, col, col + width_cells, new));
+                    }
+                }
+            }
+
+            // Decoration runs (underline family collapses to underline).
+            let underline = flags.intersects(
+                Flags::UNDERLINE
+                    | Flags::DOUBLE_UNDERLINE
+                    | Flags::UNDERCURL
+                    | Flags::DOTTED_UNDERLINE
+                    | Flags::DASHED_UNDERLINE,
+            );
+            let strikeout = flags.contains(Flags::STRIKEOUT);
+            for (on, is_strike) in [(underline, false), (strikeout, true)] {
+                if !on {
+                    continue;
+                }
+                if let Some(last) = deco_runs.last_mut() {
+                    if last.0 == row && last.2 == col && last.4 == is_strike && last.3 == fg {
+                        last.2 = col + width_cells;
+                        continue;
+                    }
+                }
+                deco_runs.push((row, col, col + width_cells, fg, is_strike));
+            }
+
+            // Text run: spaces and hidden cells only break runs, never render.
+            let bold = flags.intersects(Flags::BOLD);
+            let italic = flags.intersects(Flags::ITALIC);
+            let renders = cell.c != ' ' && !flags.contains(Flags::HIDDEN);
+            match &mut cur_text {
+                Some(run)
+                    if renders
+                        && run.row == row
+                        && run.next_col == col
+                        && run.fg == fg
+                        && run.bold == bold
+                        && run.italic == italic
+                        && width_cells == 1 =>
+                {
+                    run.text.push(cell.c);
+                    run.next_col += 1;
+                }
+                run => {
+                    if let Some(done) = run.take() {
+                        text_runs.push(done);
+                    }
+                    if renders {
+                        *run = Some(TextRun {
+                            row,
+                            col,
+                            next_col: col + width_cells,
+                            text: cell.c.to_string(),
+                            fg,
+                            bold,
+                            italic,
+                        });
+                        // A wide char ends its run: the glyph's natural advance
+                        // (~2 cells) is not guaranteed to match, so don't let
+                        // drift accumulate into following cells.
+                        if width_cells == 2 {
+                            text_runs.push(run.take().unwrap());
+                        }
+                    }
+                }
+            }
+        }
+        if let Some(run) = cur_bg.take() {
+            bg_runs.push(run);
+        }
+        if let Some(run) = cur_text.take() {
+            text_runs.push(run);
+        }
+
+        let cursor = content.cursor;
+        let cursor_shape = cursor.shape;
+        let cursor_row = cursor.point.line.0 + display_offset;
+        let cursor_col = cursor.point.column.0;
+        let cursor_rgb = colors::indexed(NamedColor::Cursor as usize, palette);
+        let mode = content.mode;
+
+        for (row, start, end, rgb) in bg_runs {
+            pc.quad(self.cell_rect(row, start, end - start), quad_color(rgb, 1.0));
+        }
+        for run in text_runs {
+            pc.text_attrs(
+                run.text,
+                pad + run.col as f32 * CELL_W,
+                pad + run.row as f32 * LINE_H,
                 FONT_SIZE,
-                FG,
-                Some(self.font.clone()),
+                [run.fg.r, run.fg.g, run.fg.b],
+                Some(font.clone()),
                 Some(bounds),
+                TextAttrs { italic: run.italic, weight: run.bold.then_some(700) },
             );
         }
+        for (row, start, end, rgb, is_strike) in deco_runs {
+            let mut rect = self.cell_rect(row, start, end - start);
+            rect.y += if is_strike { LINE_H * 0.55 } else { LINE_H - 2.0 };
+            rect.height = 1.0;
+            pc.quad(rect, quad_color(rgb, 1.0));
+        }
 
-        // 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);
+        // Cursor last, over the glyphs. Only when visible in the viewport
+        // (scrolled history moves it off) and not hidden by DECTCEM.
+        if cursor_shape != CursorShape::Hidden
+            && mode.contains(TermMode::SHOW_CURSOR)
+            && (0..rows as i32).contains(&cursor_row)
+        {
+            let rect = self.cell_rect(cursor_row as usize, cursor_col, 1);
+            if !self.focused {
+                // Hollow outline while unfocused.
+                pc.border(
+                    rect,
+                    (0.0, 0.0, 0.0, 0.0),
+                    [0.0, 0.0, 0.0, 0.0],
+                    quad_color(cursor_rgb, 0.8),
+                    1.0,
+                );
+            } else {
+                match cursor_shape {
+                    CursorShape::Beam => {
+                        pc.quad(
+                            Rect { width: 2.0, ..rect },
+                            quad_color(cursor_rgb, 0.9),
+                        );
+                    }
+                    CursorShape::Underline => {
+                        pc.quad(
+                            Rect { y: rect.y + LINE_H - 2.0, height: 2.0, ..rect },
+                            quad_color(cursor_rgb, 0.9),
+                        );
+                    }
+                    // Block (and HollowBlock while focused): translucent
+                    // overlay so the glyph beneath stays readable.
+                    _ => pc.quad(rect, quad_color(cursor_rgb, 0.4)),
+                }
+            }
         }
 
         Some(pc.finish())
@@ -230,6 +565,13 @@ impl Application for TerminalApp {
         true
     }
 
+    fn handle_focus_change(&mut self, focused: bool, needs_rebuild: &mut bool) {
+        if self.focused != focused {
+            self.focused = focused;
+            *needs_rebuild = true;
+        }
+    }
+
     fn handle_pointer_move(&mut self, _pos: LogicalPosition, _needs_rebuild: &mut bool) {}
 
     fn handle_mouse_input(
@@ -248,11 +590,21 @@ impl Application for TerminalApp {
         _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;
+        self.scroll_accum += delta.notches_y() * SCROLL_LINES_PER_NOTCH;
+        let lines = self.scroll_accum as i32;
+        if lines == 0 {
+            return;
+        }
+        self.scroll_accum -= lines as f32;
+
+        let mode = *self.term.mode();
+        if mode.contains(TermMode::ALT_SCREEN) && mode.contains(TermMode::ALTERNATE_SCROLL) {
+            // Full-screen apps without mouse reporting get arrow keys.
+            let key: &[u8] = if lines > 0 { b"\x1b[A" } else { b"\x1b[B" };
+            let bytes = key.repeat(lines.unsigned_abs() as usize);
+            self.write_pty(&bytes);
+        } else {
+            self.term.scroll_display(Scroll::Delta(lines));
             *needs_rebuild = true;
         }
     }
@@ -261,10 +613,10 @@ impl Application for TerminalApp {
         if event.state != ElementState::Pressed {
             return None;
         }
-        if let Some(bytes) = encode_key(event) {
+        if let Some(bytes) = encode_key(event, *self.term.mode()) {
             self.write_pty(&bytes);
-            if self.scroll_offset != 0 {
-                self.scroll_offset = 0;
+            if self.term.grid().display_offset() != 0 {
+                self.term.scroll_display(Scroll::Bottom);
                 *needs_rebuild = true;
             }
         }
@@ -279,3 +631,67 @@ impl Application for TerminalApp {
 fn main() {
     cce_ui::engine::run::<TerminalApp>();
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use alacritty_terminal::event::VoidListener;
+    use alacritty_terminal::index::{Column, Line};
+
+    fn term_with(bytes: &[u8]) -> Term<VoidListener> {
+        let mut term = Term::new(
+            TermConfig::default(),
+            &TermDims { cols: 20, rows: 5 },
+            VoidListener,
+        );
+        let mut parser: Processor = Processor::new();
+        parser.advance(&mut term, bytes);
+        term
+    }
+
+    #[test]
+    fn sgr_colors_land_in_grid() {
+        let term = term_with(b"\x1b[31mred\x1b[0m ok");
+        let grid = term.grid();
+        assert_eq!(grid[Line(0)][Column(0)].c, 'r');
+        assert_eq!(grid[Line(0)][Column(0)].fg, AnsiColor::Named(NamedColor::Red));
+        assert_eq!(grid[Line(0)][Column(4)].c, 'o');
+        assert_eq!(grid[Line(0)][Column(4)].fg, AnsiColor::Named(NamedColor::Foreground));
+    }
+
+    #[test]
+    fn cursor_addressing_works() {
+        // CUP to row 3 col 5, write X — the stub couldn't do this.
+        let term = term_with(b"\x1b[3;5HX");
+        assert_eq!(term.grid()[Line(2)][Column(4)].c, 'X');
+        assert_eq!(term.grid().cursor.point.line, Line(2));
+    }
+
+    #[test]
+    fn app_cursor_mode_switches_arrow_encoding() {
+        let ev = KeyEvent {
+            state: ElementState::Pressed,
+            logical_key: Key::Named(NamedKey::ArrowUp),
+            text: None,
+            repeat: false,
+            ctrl: false,
+            shift: false,
+        };
+        assert_eq!(encode_key(&ev, TermMode::empty()).unwrap(), b"\x1b[A");
+        assert_eq!(encode_key(&ev, TermMode::APP_CURSOR).unwrap(), b"\x1bOA");
+    }
+
+    #[test]
+    fn ctrl_chars_encode() {
+        let ev = |c: &str, ctrl: bool| KeyEvent {
+            state: ElementState::Pressed,
+            logical_key: Key::Character(c.to_string()),
+            text: Some(c.to_string()),
+            repeat: false,
+            ctrl,
+            shift: false,
+        };
+        assert_eq!(encode_key(&ev("c", true), TermMode::empty()).unwrap(), vec![0x03]);
+        assert_eq!(encode_key(&ev("a", false), TermMode::empty()).unwrap(), b"a");
+    }
+}
diff --git a/src/pty.rs b/src/pty.rs
index f207a03..e318d0f 100644
--- a/src/pty.rs
+++ b/src/pty.rs
@@ -12,11 +12,11 @@ pub struct Pty {
     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> {
+/// Open a pty pair and spawn `command` (or `$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, command: Option<&[String]>) -> 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 };
@@ -30,12 +30,18 @@ pub fn spawn_shell(cols: u16, rows: u16) -> io::Result<Pty> {
     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")
+    let mut cmd = match command {
+        Some(argv) => {
+            let mut c = Command::new(&argv[0]);
+            c.args(&argv[1..]);
+            c
+        }
+        None => Command::new(std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())),
+    };
+    // The VT layer is alacritty_terminal, so alacritty's terminfo entry
+    // describes us accurately (verified present on the host).
+    cmd.env("TERM", "alacritty")
+        .env("COLORTERM", "truecolor")
         .stdin(Stdio::from(slave.try_clone()?))
         .stdout(Stdio::from(slave.try_clone()?))
         .stderr(Stdio::from(slave));
@@ -83,7 +89,7 @@ mod tests {
     /// 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 pty = spawn_shell(80, 24, None).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();
diff --git a/src/term.rs b/src/term.rs
deleted file mode 100644
index 14d1647..0000000
--- a/src/term.rs
+++ /dev/null
@@ -1,206 +0,0 @@
-//! 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"]);
-    }
-}