terminal emulator
git clone https://git.lucas.co/cce-terminal.git
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"]);
- }
-}