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

src/main.rs (70.6K)

   1 //! cce-terminal — terminal emulator for the cce desktop.
   2 //!
   3 //! VT emulation is `alacritty_terminal`'s `Term` fed by its `vte` parser; this
   4 //! crate owns the pty ([`pty`]), the palette mapping ([`colors`]), and the
   5 //! rendering onto cce-ui's display-list path: cell backgrounds and decorations
   6 //! as quads, foreground text batched into per-style runs, everything on the
   7 //! DE's standard window plate. Key input encodes to terminal bytes
   8 //! (APP_CURSOR-aware); the wheel scrolls the scrollback (or synthesizes
   9 //! arrows on the alternate screen); OSC titles flow to the xdg toplevel via
  10 //! the engine's `settings().title` poll.
  11 //!
  12 //! Selection follows the X/Wayland convention: click-drag (double = word,
  13 //! triple = line) highlights and copies to PRIMARY on release; middle-click
  14 //! pastes PRIMARY; Ctrl+Shift+C / Ctrl+Shift+V are the regular clipboard,
  15 //! with bracketed paste when the app enables it. OSC 52 stores are honored.
  16 //!
  17 //! Mouse reporting: TUIs that enable a mouse mode (1000/1002/1003, with SGR
  18 //! 1006 / UTF-8 1005 / legacy encodings) get button, drag-motion, and wheel
  19 //! reports at cell coordinates, plus focus in/out (1004); holding Shift
  20 //! bypasses reporting so selection stays reachable, per convention.
  21 //!
  22 //! The window plate carries the DE's corner control ([`plate_menu`]): the
  23 //! circular trigger on the top-right that cce-designer's panes wear, opening
  24 //! a menu of the actions a menubar-less terminal has nowhere else to put —
  25 //! copy/paste, text zoom, scrollback and terminal resets, a new window, and
  26 //! the tabs. A right-click on the grid opens the same menu at the pointer
  27 //! (Shift+right-click while a TUI has mouse reporting on). A tab ([`Tab`]) is one shell on one pty with its own `Term`,
  28 //! title, view offset and bell; the window shows the active one and the
  29 //! menu lists them as a radio group (the designer's dock-tab language) with
  30 //! New Tab / Close Tab. There is no tab bar: the title carries `[i/n]` while
  31 //! more than one is open. Rebindable chords (`cce-terminal` domain in
  32 //! input.kdl): `new_tab`, `close_tab`, `next_tab`, `prev_tab`.
  33 //!
  34 //! Config (KDL, live-reloaded on file change): a `terminal { … }` section in
  35 //! the shared `~/.config/cce/config.kdl` or the per-app
  36 //! `~/.config/cce/cce-terminal/config.kdl` (app file wins) with `font_size`,
  37 //! `scrollback`, and a `colors { … }` block naming `foreground`, `background`,
  38 //! `cursor`, `selection`, and the 16 ANSI slots (`black` … `bright_white`)
  39 //! as hex strings. The font family comes from the shared config's
  40 //! `fonts { terminal }` key, like the rest of the DE's font routing.
  41 //!
  42 //! Not yet: measured cell metrics (0.60 em / 1.2 em estimates — exact for
  43 //! Berkeley Mono in practice).
  44 
  45 mod clip;
  46 mod colors;
  47 mod plate_menu;
  48 mod pty;
  49 
  50 use std::io::{Read, Write};
  51 use std::path::{Path, PathBuf};
  52 use std::time::{Duration, Instant};
  53 
  54 use alacritty_terminal::event::{Event as TermEvent, EventListener, WindowSize};
  55 use alacritty_terminal::grid::{Dimensions, Scroll};
  56 use alacritty_terminal::index::{Column, Line, Point, Side};
  57 use alacritty_terminal::selection::{Selection, SelectionType};
  58 use alacritty_terminal::term::cell::Flags;
  59 use alacritty_terminal::term::{ClipboardType, Config as TermConfig, Term, TermMode};
  60 use alacritty_terminal::vte::ansi::{
  61     Color as AnsiColor, CursorShape, NamedColor, Processor, Rgb,
  62 };
  63 use cce_ui::engine::{Application, LogicalPosition, LogicalSize, WindowSettings};
  64 use cce_ui::scene::layout::Rect;
  65 use cce_ui::scene::paint::{DisplayList, PaintCtx, TextAttrs};
  66 use cce_ui::widget::{Bounds, ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey, ScrollMotion};
  67 
  68 const INIT_W: u32 = 840;
  69 const INIT_H: u32 = 520;
  70 /// Wheel notches → scrollback lines.
  71 const SCROLL_LINES_PER_NOTCH: f32 = 3.0;
  72 /// Presses in the same cell within this window escalate Simple → Semantic →
  73 /// Lines selection.
  74 const MULTI_CLICK_WINDOW: Duration = Duration::from_millis(400);
  75 
  76 /// The KDL-configurable knobs (`terminal { … }` in the shared config.kdl or
  77 /// the per-app `~/.config/cce/cce-terminal/config.kdl`, app file winning) and
  78 /// the cell metrics derived from them. Toolkit conventions for the metrics:
  79 /// ceil(font_size × 1.2) line height (`text_leaf_height`), 0.60 × font_size
  80 /// mono advance (`estimate_label_width_helper`).
  81 #[derive(Clone, Copy, PartialEq)]
  82 struct Settings {
  83     font_size: f32,
  84     line_h: f32,
  85     cell_w: f32,
  86     scrollback: usize,
  87     palette: colors::Palette,
  88 }
  89 
  90 impl Settings {
  91     /// `zoom_steps` is the corner menu's text zoom: one point per step over
  92     /// the configured size, session-only (a config edit keeps the zoom).
  93     fn load(font_family: &str, zoom_steps: i32) -> Self {
  94         let font_size = (cce_ui::config::get_f32("/terminal/font_size", 14.0) + zoom_steps as f32)
  95             .clamp(6.0, 72.0);
  96         let scrollback =
  97             cce_ui::config::get_i64("/terminal/scrollback", 5000).clamp(0, 200_000) as usize;
  98         // Measure the real glyph advance; the 0.60 em toolkit estimate is the
  99         // fallback (and the sanity band — a broken measurement won't wreck
 100         // the grid).
 101         let estimate = font_size * 0.60;
 102         let cell_w = measure_advance(font_family, font_size)
 103             .filter(|w| (0.5 * estimate..2.0 * estimate).contains(w))
 104             .unwrap_or(estimate);
 105         if std::env::var_os("CCE_TERM_DEBUG").is_some() {
 106             eprintln!("[metrics] family={font_family} size={font_size} cell_w={cell_w} (estimate {estimate})");
 107         }
 108         Settings {
 109             font_size,
 110             line_h: (font_size * 1.2).ceil(),
 111             cell_w,
 112             scrollback,
 113             palette: colors::Palette::from_config(),
 114         }
 115     }
 116 }
 117 
 118 /// Shape a long run of one ASCII glyph in the terminal font and divide out
 119 /// the per-cell advance. Uses an app-side bundled-fonts `FontSystem` (the
 120 /// documented pattern for measurement), cached across config reloads.
 121 fn measure_advance(font_family: &str, font_size: f32) -> Option<f32> {
 122     use std::sync::{Mutex, OnceLock};
 123     static FONT_SYSTEM: OnceLock<Mutex<cce_ui::cosmic_text::FontSystem>> = OnceLock::new();
 124     const RUN: usize = 64;
 125     let fs = FONT_SYSTEM.get_or_init(|| Mutex::new(cce_ui::create_font_system()));
 126     let mut fs = fs.lock().ok()?;
 127     let mut buffer = cce_ui::cosmic_text::Buffer::new(
 128         &mut fs,
 129         cce_ui::cosmic_text::Metrics::new(font_size, (font_size * 1.2).ceil()),
 130     );
 131     buffer.set_size(&mut fs, None, None);
 132     buffer.set_text(
 133         &mut fs,
 134         &"M".repeat(RUN),
 135         cce_ui::cosmic_text::Attrs::new().family(cce_ui::cosmic_text::Family::Name(font_family)),
 136         cce_ui::cosmic_text::Shaping::Advanced,
 137     );
 138     let advance = buffer.layout_runs().next()?.line_w / RUN as f32;
 139     (advance.is_finite() && advance > 0.0).then_some(advance)
 140 }
 141 
 142 /// The app's rebindable shortcuts, resolved once at startup from input.kdl
 143 /// (domain `cce-terminal`, falling back to `cce-ui` then these defaults).
 144 struct Keys {
 145     copy: String,
 146     paste: String,
 147     scroll_up: String,
 148     scroll_down: String,
 149     new_tab: String,
 150     close_tab: String,
 151     next_tab: String,
 152     prev_tab: String,
 153 }
 154 
 155 impl Keys {
 156     fn load() -> Self {
 157         let get = cce_ui::input::app_chord;
 158         Keys {
 159             copy: get("copy", "ctrl+shift+c"),
 160             paste: get("paste", "ctrl+shift+v"),
 161             scroll_up: get("scroll_up", "shift+pageup"),
 162             scroll_down: get("scroll_down", "shift+pagedown"),
 163             // The gnome-terminal chords; Ctrl+PageUp/Down are taken from the
 164             // shell (they would encode as CSI 5;5~ / 6;5~ otherwise).
 165             new_tab: get("new_tab", "ctrl+shift+t"),
 166             close_tab: get("close_tab", "ctrl+shift+w"),
 167             next_tab: get("next_tab", "ctrl+pagedown"),
 168             prev_tab: get("prev_tab", "ctrl+pageup"),
 169         }
 170     }
 171 }
 172 
 173 /// A tab's identity for its lifetime. Messages from the pty reader thread
 174 /// and the Term's event proxy are keyed on it rather than on a `Vec` index,
 175 /// which shifts when an earlier tab closes.
 176 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 177 struct TabId(u64);
 178 
 179 #[derive(Clone)]
 180 enum Msg {
 181     Pty(TabId, Vec<u8>),
 182     PtyClosed(TabId),
 183     Term(TabId, TermEvent),
 184 }
 185 
 186 /// The terminal's grid dimensions, for `Term::new`/`resize`.
 187 #[derive(Clone, Copy)]
 188 struct TermDims {
 189     cols: u16,
 190     rows: u16,
 191 }
 192 
 193 impl Dimensions for TermDims {
 194     fn total_lines(&self) -> usize {
 195         self.rows as usize
 196     }
 197     fn screen_lines(&self) -> usize {
 198         self.rows as usize
 199     }
 200     fn columns(&self) -> usize {
 201         self.cols as usize
 202     }
 203 }
 204 
 205 /// Forwards a tab's `Term` events (pty write-backs, title changes …) into
 206 /// the engine's message channel, tagged with the tab; handled in `update`.
 207 struct EventProxy {
 208     sender: calloop::channel::Sender<Msg>,
 209     id: TabId,
 210 }
 211 
 212 impl EventListener for EventProxy {
 213     fn send_event(&self, event: TermEvent) {
 214         let _ = self.sender.send(Msg::Term(self.id, event));
 215     }
 216 }
 217 
 218 /// One shell on one pty: the VT state and everything that is per-view
 219 /// rather than per-window. Gesture state (selection drag, multi-click,
 220 /// wheel remainders) stays on the app: it belongs to the pointer, and a
 221 /// tab switch simply drops it.
 222 struct Tab {
 223     id: TabId,
 224     term: Term<EventProxy>,
 225     parser: Processor,
 226     pty: pty::Pty,
 227     writer: std::fs::File,
 228     /// OSC title; the active tab's is polled by the engine through
 229     /// `settings().title`, every tab's names its menu row.
 230     title: Option<String>,
 231     /// Scrollback view motion in LINE units: `y.pos()` is the float display
 232     /// offset — a notch glides it, a trackpad tracks it 1:1 and its flick
 233     /// coasts (cce-ui's ScrollMotion) — and each frame the Term is stepped to
 234     /// its rounding. Drawing stays line-quantized; only the offset is smooth.
 235     scroll_motion: ScrollMotion,
 236     /// Bell flash intensity, 1.0 → 0 over ~a quarter second (decayed in tick).
 237     bell: f32,
 238     /// Cell of the last motion report — motion is per-cell, not per-pixel.
 239     last_mouse_cell: Option<(usize, usize)>,
 240 }
 241 
 242 impl Tab {
 243     /// Spawn a shell (or `command`) on a fresh pty sized to the grid, start
 244     /// its reader thread, and build the Term that will parse it.
 245     fn spawn(
 246         id: TabId,
 247         cols: u16,
 248         rows: u16,
 249         settings: &Settings,
 250         sender: &calloop::channel::Sender<Msg>,
 251         command: Option<&[String]>,
 252         cwd: Option<&Path>,
 253     ) -> std::io::Result<Tab> {
 254         let pty = pty::spawn_shell(cols, rows, command, cwd)?;
 255         let writer = pty.dup_handle()?;
 256         let mut reader = pty.dup_handle()?;
 257         let pty_sender = sender.clone();
 258         std::thread::spawn(move || {
 259             let mut buf = [0u8; 8192];
 260             loop {
 261                 match reader.read(&mut buf) {
 262                     Ok(0) => break,
 263                     Ok(n) => {
 264                         if pty_sender.send(Msg::Pty(id, buf[..n].to_vec())).is_err() {
 265                             return;
 266                         }
 267                     }
 268                     Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
 269                     // EIO when the last slave fd closes: the normal exit path.
 270                     Err(_) => break,
 271                 }
 272             }
 273             let _ = pty_sender.send(Msg::PtyClosed(id));
 274         });
 275 
 276         let config =
 277             TermConfig { scrolling_history: settings.scrollback, ..TermConfig::default() };
 278         let term = Term::new(
 279             config,
 280             &TermDims { cols, rows },
 281             EventProxy { sender: sender.clone(), id },
 282         );
 283         Ok(Tab {
 284             id,
 285             term,
 286             parser: Processor::new(),
 287             pty,
 288             writer,
 289             title: None,
 290             scroll_motion: ScrollMotion::new(),
 291             bell: 0.0,
 292             last_mouse_cell: None,
 293         })
 294     }
 295 
 296     /// The shell's current directory (via /proc), so a new tab can start
 297     /// where this one is. The shell itself, not its foreground job: that is
 298     /// where the next prompt will be.
 299     fn cwd(&self) -> Option<PathBuf> {
 300         std::fs::read_link(format!("/proc/{}/cwd", self.pty.child.id())).ok()
 301     }
 302 
 303     /// The tab's menu row text: its title, or "Tab n" before one arrives,
 304     /// clipped so a long OSC title does not stretch the menu.
 305     fn label(&self, n: usize) -> String {
 306         const MAX: usize = 32;
 307         let title = match &self.title {
 308             Some(t) if !t.trim().is_empty() => t.trim().to_string(),
 309             _ => return format!("Tab {}", n + 1),
 310         };
 311         if title.chars().count() <= MAX {
 312             title
 313         } else {
 314             let head: String = title.chars().take(MAX - 1).collect();
 315             format!("{head}…")
 316         }
 317     }
 318 
 319     /// Kill the shell and reap it. Closing a tab, not the shell exiting on
 320     /// its own — that arrives as `Msg::PtyClosed` from the reader thread.
 321     fn kill(&mut self) {
 322         let _ = self.pty.child.kill();
 323         let _ = self.pty.child.wait();
 324     }
 325 }
 326 
 327 struct TerminalApp {
 328     /// The engine's message channel, kept so new tabs can spawn onto it.
 329     sender: calloop::channel::Sender<Msg>,
 330     /// Never empty while the app runs: the last shell exiting exits the app.
 331     tabs: Vec<Tab>,
 332     active: usize,
 333     next_tab_id: u64,
 334     font: String,
 335     pad: f32,
 336     cols: u16,
 337     rows: u16,
 338     /// Fractional wheel remainder for the whole-line wheel protocols (mouse
 339     /// reports, alternate-scroll arrows), where a trackpad's pixel deltas
 340     /// have to add up to a line before anything is sent.
 341     scroll_accum: f32,
 342     focused: bool,
 343     /// Left button held: pointer moves extend the selection.
 344     selecting: bool,
 345     /// Multi-click escalation state: last press instant + cell.
 346     last_click: Option<(Instant, Point)>,
 347     click_count: u8,
 348     settings: Settings,
 349     keys: Keys,
 350     /// Config-file mtime at the last (re)load — tick polls it so palette and
 351     /// font-size edits apply live, per the DE's edit-the-file convention.
 352     config_stamp: Option<std::time::SystemTime>,
 353     /// Last pointer position (any state) — tick's autoscroll reads it while a
 354     /// selection drag holds the pointer in the frame's edge padding.
 355     last_pointer: LogicalPosition,
 356     autoscroll_accum: f32,
 357     /// Current window size (for grid re-derivation on config reload and
 358     /// selection edge-autoscroll bounds).
 359     win_w: f32,
 360     win_h: f32,
 361     /// Modifier state tracked from key events (mouse handlers receive no
 362     /// modifiers). Shift bypasses mouse reporting; ctrl/alt ride report codes.
 363     shift_down: bool,
 364     ctrl_down: bool,
 365     alt_down: bool,
 366     /// Held buttons for drag-motion reports: bit 0 left, 1 middle, 2 right.
 367     mouse_buttons: u8,
 368     /// Corner-menu text zoom, in points over the configured font size.
 369     zoom_steps: i32,
 370     /// Rows of the OPEN corner menu (empty = not open); see [`plate_menu`].
 371     plate_menu_actions: Vec<plate_menu::PlateMenuAction>,
 372 }
 373 
 374 impl TerminalApp {
 375     fn tab(&self) -> &Tab {
 376         &self.tabs[self.active]
 377     }
 378 
 379     fn tab_mut(&mut self) -> &mut Tab {
 380         &mut self.tabs[self.active]
 381     }
 382 
 383     fn tab_index(&self, id: TabId) -> Option<usize> {
 384         self.tabs.iter().position(|t| t.id == id)
 385     }
 386 
 387     /// Open a new tab after the active one, in the active shell's directory,
 388     /// and switch to it.
 389     fn new_tab(&mut self) {
 390         let id = TabId(self.next_tab_id);
 391         self.next_tab_id += 1;
 392         let cwd = self.tab().cwd();
 393         match Tab::spawn(id, self.cols, self.rows, &self.settings, &self.sender, None, cwd.as_deref()) {
 394             Ok(tab) => {
 395                 let at = self.active + 1;
 396                 self.tabs.insert(at, tab);
 397                 self.show_tab(at);
 398             }
 399             Err(e) => log::warn!("cce-terminal: failed to spawn a new tab: {e}"),
 400         }
 401     }
 402 
 403     /// Make tab `i` the visible one. Apps tracking focus (1004) in either
 404     /// tab see the switch as focus leaving one and reaching the other.
 405     fn show_tab(&mut self, i: usize) {
 406         if i >= self.tabs.len() || i == self.active {
 407             return;
 408         }
 409         if self.focused && self.tab().term.mode().contains(TermMode::FOCUS_IN_OUT) {
 410             self.write_pty(b"\x1b[O");
 411         }
 412         self.active = i;
 413         // Pointer gestures do not survive the switch: they were about the
 414         // old tab's cells.
 415         self.selecting = false;
 416         self.last_click = None;
 417         self.click_count = 0;
 418         self.scroll_accum = 0.0;
 419         self.autoscroll_accum = 0.0;
 420         if self.focused && self.tab().term.mode().contains(TermMode::FOCUS_IN_OUT) {
 421             self.write_pty(b"\x1b[I");
 422         }
 423     }
 424 
 425     fn show_tab_by_id(&mut self, id: TabId) {
 426         if let Some(i) = self.tab_index(id) {
 427             self.show_tab(i);
 428         }
 429     }
 430 
 431     /// Next/previous tab, wrapping.
 432     fn cycle_tab(&mut self, delta: i32) {
 433         let n = self.tabs.len() as i32;
 434         if n > 1 {
 435             self.show_tab(((self.active as i32 + delta).rem_euclid(n)) as usize);
 436         }
 437     }
 438 
 439     /// Close the active tab. The last tab is the window: killing its shell
 440     /// ends the pty, and the reader thread's `PtyClosed` exits the app the
 441     /// way a typed `exit` would, so there is never a tabless window.
 442     fn close_active_tab(&mut self) {
 443         if self.tabs.len() == 1 {
 444             self.tab_mut().kill();
 445             return;
 446         }
 447         let mut tab = self.tabs.remove(self.active);
 448         tab.kill();
 449         let i = self.active.min(self.tabs.len() - 1);
 450         // `show_tab` declines a same-index switch, so drop the gesture
 451         // state here — the tab under the pointer changed either way.
 452         self.active = i;
 453         self.selecting = false;
 454         self.last_click = None;
 455         self.click_count = 0;
 456     }
 457 
 458     fn grid_for(&self, w: f32, h: f32) -> (u16, u16) {
 459         grid_dims(w, h, self.pad, &self.settings)
 460     }
 461 
 462     fn write_pty(&mut self, bytes: &[u8]) {
 463         let _ = self.tab_mut().writer.write_all(bytes);
 464     }
 465 
 466     /// Resize every tab's pty and Term to the current grid.
 467     fn resize_tabs(&mut self, cols: u16, rows: u16) {
 468         self.cols = cols;
 469         self.rows = rows;
 470         for tab in &mut self.tabs {
 471             tab.pty.resize(cols, rows);
 472             tab.term.resize(TermDims { cols, rows });
 473         }
 474     }
 475 
 476     fn cell_rect(&self, row: usize, col: usize, width_cells: usize) -> Rect {
 477         Rect {
 478             x: self.pad + col as f32 * self.settings.cell_w,
 479             y: self.pad + row as f32 * self.settings.line_h,
 480             width: width_cells as f32 * self.settings.cell_w,
 481             height: self.settings.line_h,
 482         }
 483     }
 484 
 485     /// Pixel position → 0-based viewport cell (clamped).
 486     fn viewport_cell(&self, pos: LogicalPosition) -> (usize, usize) {
 487         let col = (((pos.x as f32 - self.pad) / self.settings.cell_w).max(0.0) as usize)
 488             .min(self.cols as usize - 1);
 489         let row = (((pos.y as f32 - self.pad) / self.settings.line_h).max(0.0) as usize)
 490             .min(self.rows as usize - 1);
 491         (col, row)
 492     }
 493 
 494     /// Pixel position → grid point (viewport-clamped; grid-space line, so
 495     /// scrolled history resolves to negative lines) plus which half of the
 496     /// cell was hit.
 497     fn grid_point(&self, pos: LogicalPosition) -> (Point, Side) {
 498         let (col, row) = self.viewport_cell(pos);
 499         let col_f = ((pos.x as f32 - self.pad) / self.settings.cell_w).max(0.0);
 500         let line = Line(row as i32 - self.tab().term.grid().display_offset() as i32);
 501         let side = if col_f.fract() > 0.5 { Side::Right } else { Side::Left };
 502         (Point::new(line, Column(col)), side)
 503     }
 504 
 505     /// Whether pointer events currently belong to the application rather than
 506     /// the selection machinery (Shift bypasses, per convention).
 507     fn mouse_reporting(&self) -> bool {
 508         self.tab().term.mode().intersects(TermMode::MOUSE_MODE) && !self.shift_down
 509     }
 510 
 511     /// Modifier bits added to every report's button code. Shift never
 512     /// arrives here (it bypasses reporting).
 513     fn report_mods(&self) -> u8 {
 514         (self.alt_down as u8) * 8 + (self.ctrl_down as u8) * 16
 515     }
 516 
 517     fn send_mouse_report(&mut self, code: u8, pressed: bool, col: usize, row: usize) {
 518         if let Some(bytes) = mouse_report_bytes(*self.tab().term.mode(), code, pressed, col, row) {
 519             self.write_pty(&bytes);
 520         }
 521     }
 522 
 523     /// Quantize wheel motion for the whole-line protocols, carrying the
 524     /// fractional remainder across events.
 525     fn take_whole_lines(&mut self, lines: f32) -> Option<i32> {
 526         self.scroll_accum += lines;
 527         let whole = self.scroll_accum as i32;
 528         if whole == 0 {
 529             return None;
 530         }
 531         self.scroll_accum -= whole as f32;
 532         Some(whole)
 533     }
 534 
 535     /// Adopt view moves made behind the motion's back — PageUp/PageDown, the
 536     /// snap to the bottom on a keypress or paste, new output pushing a held
 537     /// view up the history — so the motion resumes from where the view is.
 538     fn sync_scroll_motion(&mut self) {
 539         let tab = self.tab_mut();
 540         let offset = tab.term.grid().display_offset() as f32;
 541         if tab.scroll_motion.y.pos().round() != offset {
 542             tab.scroll_motion.y.jump_to(offset);
 543         }
 544     }
 545 
 546     /// How far a selection drag is holding the pointer past the top or
 547     /// bottom edge padding: 0 when it is inside, negative above, positive
 548     /// below. Drives the autoscroll rate in `tick`, and the frame cadence
 549     /// `idle_poll_interval` has to ask for while it is non-zero.
 550     fn autoscroll_overshoot(&self) -> f32 {
 551         if !self.selecting {
 552             return 0.0;
 553         }
 554         let y = self.last_pointer.y as f32;
 555         if y < self.pad {
 556             self.pad - y
 557         } else if y > self.win_h - self.pad {
 558             (self.win_h - self.pad) - y
 559         } else {
 560             0.0
 561         }
 562     }
 563 
 564     /// The scrollback offset's range: 0 (live bottom) ..= history length.
 565     fn scrollback_bounds(&self) -> Bounds {
 566         Bounds::max(self.tab().term.grid().history_size() as f32)
 567     }
 568 
 569     /// Step the Term to the motion's rounded offset; true if the view moved.
 570     fn apply_scroll_motion(&mut self) -> bool {
 571         let tab = self.tab_mut();
 572         let target = tab.scroll_motion.y.pos().round() as i32;
 573         let current = tab.term.grid().display_offset() as i32;
 574         if target == current {
 575             return false;
 576         }
 577         tab.term.scroll_display(Scroll::Delta(target - current));
 578         true
 579     }
 580 
 581     /// Swap in re-derived settings (a config edit, a zoom step) and reflow
 582     /// the grid and pty to match. `true` when anything changed.
 583     fn apply_settings(&mut self, reloaded: Settings) -> bool {
 584         let old = std::mem::replace(&mut self.settings, reloaded);
 585         if reloaded == old {
 586             return false;
 587         }
 588         let (cols, rows) = self.grid_for(self.win_w, self.win_h);
 589         if (cols, rows) != (self.cols, self.rows) {
 590             self.resize_tabs(cols, rows);
 591         }
 592         true
 593     }
 594 
 595     /// Send pasted text to the pty and snap the view to the bottom.
 596     fn paste(&mut self, text: &str) {
 597         let bracketed = self.tab().term.mode().contains(TermMode::BRACKETED_PASTE);
 598         let bytes = paste_bytes(text, bracketed);
 599         self.write_pty(&bytes);
 600         let term = &mut self.tab_mut().term;
 601         if term.grid().display_offset() != 0 {
 602             term.scroll_display(Scroll::Bottom);
 603         }
 604     }
 605 }
 606 
 607 /// Encode one mouse report. `code` is the xterm button code with modifier
 608 /// bits already applied (0/1/2 buttons, 64/65 wheel, +32 for motion);
 609 /// `col`/`row` are 0-based viewport cells. Picks the encoding the app
 610 /// negotiated: SGR (1006) > UTF-8 extended coords (1005) > legacy X10 bytes
 611 /// (coordinates saturate at their encodable maximum).
 612 fn mouse_report_bytes(
 613     mode: TermMode,
 614     code: u8,
 615     pressed: bool,
 616     col: usize,
 617     row: usize,
 618 ) -> Option<Vec<u8>> {
 619     if mode.contains(TermMode::SGR_MOUSE) {
 620         let suffix = if pressed { 'M' } else { 'm' };
 621         return Some(format!("\x1b[<{};{};{}{}", code, col + 1, row + 1, suffix).into_bytes());
 622     }
 623     // Non-SGR encodings can't distinguish which button released.
 624     let byte = 32 + if pressed { code } else { 3 | (code & !0b11) };
 625     let mut bytes = vec![0x1b, b'[', b'M', byte];
 626     if mode.contains(TermMode::UTF8_MOUSE) {
 627         for coord in [col, row] {
 628             let n = (coord + 1 + 32).min(2015) as u32;
 629             let mut buf = [0u8; 4];
 630             bytes.extend_from_slice(
 631                 char::from_u32(n).unwrap_or(' ').encode_utf8(&mut buf).as_bytes(),
 632             );
 633         }
 634     } else {
 635         bytes.push((col + 1 + 32).min(255) as u8);
 636         bytes.push((row + 1 + 32).min(255) as u8);
 637     }
 638     Some(bytes)
 639 }
 640 
 641 /// Paste encoding: bracketed when the app asked for it (end-marker
 642 /// occurrences stripped so a paste can't fake the terminator),
 643 /// newline-normalized to CR otherwise.
 644 fn paste_bytes(text: &str, bracketed: bool) -> Vec<u8> {
 645     if bracketed {
 646         let mut bytes = b"\x1b[200~".to_vec();
 647         bytes.extend_from_slice(text.replace("\x1b[201~", "").as_bytes());
 648         bytes.extend_from_slice(b"\x1b[201~");
 649         bytes
 650     } else {
 651         text.replace("\r\n", "\r").replace('\n', "\r").into_bytes()
 652     }
 653 }
 654 
 655 fn grid_dims(w: f32, h: f32, pad: f32, settings: &Settings) -> (u16, u16) {
 656     let cols = (((w - 2.0 * pad) / settings.cell_w).floor() as i64).clamp(2, u16::MAX as i64);
 657     let rows = (((h - 2.0 * pad) / settings.line_h).floor() as i64).clamp(1, u16::MAX as i64);
 658     (cols as u16, rows as u16)
 659 }
 660 
 661 /// Terminal-space RGB → cce-ui quad color (the geometry pipeline is linear;
 662 /// terminal colors are sRGB).
 663 fn quad_color(rgb: Rgb, alpha: f32) -> [f32; 4] {
 664     [
 665         cce_ui::color::srgb_to_linear(rgb.r as f32 / 255.0),
 666         cce_ui::color::srgb_to_linear(rgb.g as f32 / 255.0),
 667         cce_ui::color::srgb_to_linear(rgb.b as f32 / 255.0),
 668         alpha,
 669     ]
 670 }
 671 
 672 /// Terminal byte encoding for a key press; `None` = nothing to send. Honors
 673 /// DECCKM (application cursor keys) and the xterm modifier parameter
 674 /// (`1 + shift + 2·alt + 4·ctrl`) on CSI keys; alt prefixes ESC everywhere
 675 /// else (readline's alt-b / alt-backspace family).
 676 fn encode_key(ev: &KeyEvent, mode: TermMode) -> Option<Vec<u8>> {
 677     let app = mode.contains(TermMode::APP_CURSOR);
 678     let m = 1 + ev.shift as u8 + 2 * ev.alt as u8 + 4 * ev.ctrl as u8;
 679 
 680     enum Enc {
 681         /// `ESC[<final>` / `ESCO<final>` (app mode) / `ESC[1;<m><final>`.
 682         Csi(char),
 683         /// `ESC[<n>~` / `ESC[<n>;<m>~`.
 684         Tilde(u8),
 685         /// Raw bytes, ESC-prefixed when alt is held.
 686         Plain(&'static [u8]),
 687     }
 688 
 689     match &ev.logical_key {
 690         Key::Named(k) => {
 691             let enc = match k {
 692                 NamedKey::ArrowUp => Enc::Csi('A'),
 693                 NamedKey::ArrowDown => Enc::Csi('B'),
 694                 NamedKey::ArrowRight => Enc::Csi('C'),
 695                 NamedKey::ArrowLeft => Enc::Csi('D'),
 696                 NamedKey::Home => Enc::Csi('H'),
 697                 NamedKey::End => Enc::Csi('F'),
 698                 NamedKey::PageUp => Enc::Tilde(5),
 699                 NamedKey::PageDown => Enc::Tilde(6),
 700                 NamedKey::Delete => Enc::Tilde(3),
 701                 NamedKey::F5 => Enc::Tilde(15),
 702                 NamedKey::Enter => Enc::Plain(b"\r"),
 703                 NamedKey::Backspace => Enc::Plain(b"\x7f"),
 704                 NamedKey::Tab if ev.shift => return Some(b"\x1b[Z".to_vec()),
 705                 NamedKey::Tab => Enc::Plain(b"\t"),
 706                 NamedKey::Escape => Enc::Plain(b"\x1b"),
 707                 NamedKey::Space if ev.ctrl => Enc::Plain(b"\x00"),
 708                 NamedKey::Space => Enc::Plain(b" "),
 709                 _ => return None,
 710             };
 711             Some(match enc {
 712                 Enc::Csi(c) if m > 1 => format!("\x1b[1;{m}{c}").into_bytes(),
 713                 Enc::Csi(c) if app => format!("\x1bO{c}").into_bytes(),
 714                 Enc::Csi(c) => format!("\x1b[{c}").into_bytes(),
 715                 Enc::Tilde(n) if m > 1 => format!("\x1b[{n};{m}~").into_bytes(),
 716                 Enc::Tilde(n) => format!("\x1b[{n}~").into_bytes(),
 717                 Enc::Plain(b) => {
 718                     let mut bytes = Vec::with_capacity(b.len() + 1);
 719                     if ev.alt {
 720                         bytes.push(0x1b);
 721                     }
 722                     bytes.extend_from_slice(b);
 723                     bytes
 724                 }
 725             })
 726         }
 727         Key::Character(s) => {
 728             let mut bytes: Vec<u8> = if ev.ctrl {
 729                 match s.chars().next()?.to_ascii_lowercase() {
 730                     c @ 'a'..='z' => vec![c as u8 - b'a' + 1],
 731                     '[' => vec![0x1b],
 732                     '\\' => vec![0x1c],
 733                     ']' => vec![0x1d],
 734                     _ => return None,
 735                 }
 736             } else if let Some(t) = &ev.text {
 737                 t.as_bytes().to_vec()
 738             } else {
 739                 s.as_bytes().to_vec()
 740             };
 741             if ev.alt {
 742                 bytes.insert(0, 0x1b);
 743             }
 744             Some(bytes)
 745         }
 746     }
 747 }
 748 
 749 /// A run of contiguous same-style cells on one row, batched into a single
 750 /// text prim (valid because the grid is monospace-cell-addressed).
 751 struct TextRun {
 752     row: usize,
 753     col: usize,
 754     next_col: usize,
 755     text: String,
 756     fg: Rgb,
 757     bold: bool,
 758     italic: bool,
 759 }
 760 
 761 impl Application for TerminalApp {
 762     type Message = Msg;
 763 
 764     fn create(sender: cce_ui::engine::AppSender<Self::Message>) -> Self {
 765         // The app keeps calloop's sender; `AppSender` converts into it.
 766         let sender: calloop::channel::Sender<Self::Message> = sender.into();
 767         // The window-edge inset: the root plate's roll plus one padding.
 768         let pad = cce_ui::layout::root_plate_inset();
 769         // `.3` is the `fonts { terminal }` family (this was the 7-tuple's
 770         // fontconfig `terminal` alias before the DE's fonts moved into the
 771         // shared KDL config), falling back to Noto Sans Mono.
 772         let font = cce_ui::layout::read_preferred_fonts().3;
 773         let settings = Settings::load(&font, 0);
 774         let config_stamp = cce_ui::config::config_files_modified();
 775         let (cols, rows) = grid_dims(INIT_W as f32, INIT_H as f32, pad, &settings);
 776 
 777         // A command instead of $SHELL: `cce-terminal -e <cmd> [args…]`
 778         // (xterm-style), or bare trailing args (foot-style) — the launcher
 779         // hosts `Terminal=true` entries positionally (`term sh -c …`), so
 780         // both conventions must work.
 781         let argv: Vec<String> = std::env::args().skip(1).collect();
 782         let command: Option<Vec<String>> = match argv.first().map(String::as_str) {
 783             Some("-e") => Some(argv[1..].to_vec()).filter(|c| !c.is_empty()),
 784             Some(_) => Some(argv.clone()),
 785             None => None,
 786         };
 787 
 788         // The first tab runs the command (later tabs are plain shells) and
 789         // inherits the terminal's own directory.
 790         let first = Tab::spawn(TabId(0), cols, rows, &settings, &sender, command.as_deref(), None)
 791             .expect("cce-terminal: failed to spawn shell on pty");
 792 
 793         TerminalApp {
 794             sender,
 795             tabs: vec![first],
 796             active: 0,
 797             next_tab_id: 1,
 798             font,
 799             pad,
 800             cols,
 801             rows,
 802             scroll_accum: 0.0,
 803             focused: true,
 804             selecting: false,
 805             last_click: None,
 806             click_count: 0,
 807             settings,
 808             keys: Keys::load(),
 809             config_stamp,
 810             last_pointer: LogicalPosition::new(0.0, 0.0),
 811             autoscroll_accum: 0.0,
 812             win_w: INIT_W as f32,
 813             win_h: INIT_H as f32,
 814             shift_down: false,
 815             ctrl_down: false,
 816             alt_down: false,
 817             mouse_buttons: 0,
 818             zoom_steps: 0,
 819             plate_menu_actions: Vec::new(),
 820         }
 821     }
 822 
 823     fn settings(&self) -> WindowSettings {
 824         // The active tab's title; with several tabs and no tab bar, the
 825         // title is where "which one, of how many" shows.
 826         let mut title = self.tab().title.clone().unwrap_or_else(|| "cce-terminal".to_string());
 827         if self.tabs.len() > 1 {
 828             title = format!("{title} [{}/{}]", self.active + 1, self.tabs.len());
 829         }
 830         WindowSettings {
 831             title,
 832             app_id: "cce-terminal".to_string(),
 833             width: INIT_W,
 834             height: INIT_H,
 835             fullscreen: false,
 836             min_size: Some((240, 140)),
 837         }
 838     }
 839 
 840     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool) {
 841         // Every message names its tab; one from a tab already closed (its
 842         // reader thread outlives the kill by a moment) is simply dropped.
 843         // Background tabs keep parsing but never ask for a frame: nothing
 844         // of theirs is on screen except their menu label and the title.
 845         let (id, is_active) = match &msg {
 846             Msg::Pty(id, _) | Msg::PtyClosed(id) | Msg::Term(id, _) => {
 847                 let Some(i) = self.tab_index(*id) else { return };
 848                 (i, i == self.active)
 849             }
 850         };
 851         let window = WindowSize {
 852             num_lines: self.rows,
 853             num_cols: self.cols,
 854             cell_width: self.settings.cell_w as u16,
 855             cell_height: self.settings.line_h as u16,
 856         };
 857         let palette = self.settings.palette;
 858         let tab = &mut self.tabs[id];
 859         match msg {
 860             Msg::Pty(_, bytes) => {
 861                 tab.parser.advance(&mut tab.term, &bytes);
 862                 *needs_rebuild |= is_active;
 863             }
 864             Msg::PtyClosed(_) => {
 865                 let _ = tab.pty.child.wait();
 866                 if self.tabs.len() == 1 {
 867                     // The last shell exiting is the window closing — the
 868                     // tab stays in place so nothing observes an empty list.
 869                     *exit = true;
 870                     return;
 871                 }
 872                 self.tabs.remove(id);
 873                 if id < self.active {
 874                     self.active -= 1;
 875                 } else if self.active >= self.tabs.len() {
 876                     self.active = self.tabs.len() - 1;
 877                 }
 878                 if is_active {
 879                     self.selecting = false;
 880                     self.last_click = None;
 881                     self.click_count = 0;
 882                 }
 883                 *needs_rebuild = true;
 884             }
 885             Msg::Term(_, event) => match event {
 886                 TermEvent::PtyWrite(s) => {
 887                     let _ = tab.writer.write_all(s.as_bytes());
 888                 }
 889                 TermEvent::Title(t) => {
 890                     tab.title = Some(t);
 891                     *needs_rebuild |= is_active;
 892                 }
 893                 TermEvent::ResetTitle => {
 894                     tab.title = None;
 895                     *needs_rebuild |= is_active;
 896                 }
 897                 TermEvent::ColorRequest(index, format) => {
 898                     let rgb = tab.term.colors()[index]
 899                         .unwrap_or_else(|| colors::default_color(index, &palette));
 900                     let _ = tab.writer.write_all(format(rgb).as_bytes());
 901                 }
 902                 TermEvent::TextAreaSizeRequest(format) => {
 903                     let _ = tab.writer.write_all(format(window).as_bytes());
 904                 }
 905                 // OSC 52: programs storing to (or, if enabled in the term
 906                 // config, reading from) the system clipboards.
 907                 TermEvent::ClipboardStore(ty, text) => match ty {
 908                     ClipboardType::Clipboard => cce_ui::widget::clipboard::copy_to_clipboard(&text),
 909                     ClipboardType::Selection => clip::copy_primary(&text),
 910                 },
 911                 TermEvent::ClipboardLoad(ty, format) => {
 912                     let text = match ty {
 913                         ClipboardType::Clipboard => {
 914                             cce_ui::widget::clipboard::read_from_clipboard()
 915                         }
 916                         ClipboardType::Selection => clip::paste_primary(),
 917                     }
 918                     .unwrap_or_default();
 919                     let _ = tab.writer.write_all(format(&text).as_bytes());
 920                 }
 921                 TermEvent::Bell => {
 922                     tab.bell = 1.0;
 923                     *needs_rebuild |= is_active;
 924                 }
 925                 // Wakeup/cursor-blink: nothing to do.
 926                 _ => {}
 927             },
 928         }
 929     }
 930 
 931     /// Two things age in `tick` without redrawing anything the runner can
 932     /// see: a background tab's bell flash (only the active tab's decay asks
 933     /// for a frame) and selection autoscroll with the pointer held still in
 934     /// the edge padding (motion events stop at the edge). Both are real-time
 935     /// decays, and the runner's idle sleep clamps `dt` to one frame per
 936     /// wake — so ask for frame cadence while either is live, and nothing
 937     /// otherwise.
 938     fn idle_poll_interval(&self) -> Option<std::time::Duration> {
 939         let bell = self.tabs.iter().any(|t| t.bell > 0.0);
 940         (bell || self.autoscroll_overshoot() != 0.0)
 941             .then(|| std::time::Duration::from_millis(16))
 942     }
 943 
 944     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
 945         // Bell flash decay — every tab's, so a background tab's flash has
 946         // faded by the time it is shown rather than greeting the switch.
 947         for (i, tab) in self.tabs.iter_mut().enumerate() {
 948             if tab.bell > 0.0 {
 949                 tab.bell = (tab.bell - dt * 4.0).max(0.0);
 950                 *needs_rebuild |= i == self.active;
 951             }
 952         }
 953 
 954         // Wheel glide / trackpad coast through the scrollback: advance the
 955         // line-unit motion and step the Term to its rounded offset, asking
 956         // for frames while it is still moving.
 957         if self.tab().scroll_motion.is_animating() {
 958             self.sync_scroll_motion();
 959             if self.tab().scroll_motion.is_animating() {
 960                 let bounds = self.scrollback_bounds();
 961                 self.tab_mut().scroll_motion.tick(dt, Bounds::max(0.0), bounds);
 962                 if self.apply_scroll_motion() || self.tab().scroll_motion.is_animating() {
 963                     *needs_rebuild = true;
 964                 }
 965             }
 966         }
 967 
 968         // Selection autoscroll: while a drag holds the pointer in the top or
 969         // bottom edge padding, scroll at a rate scaling with the overshoot
 970         // (motion events stop at the edge, so this is time-driven).
 971         {
 972             let overshoot = self.autoscroll_overshoot();
 973             if overshoot != 0.0 {
 974                 let rate = 4.0 + overshoot.abs().min(24.0) * 2.0; // lines/sec
 975                 self.autoscroll_accum += dt * rate * overshoot.signum();
 976                 let lines = self.autoscroll_accum as i32;
 977                 if lines != 0 {
 978                     self.autoscroll_accum -= lines as f32;
 979                     self.tab_mut().term.scroll_display(Scroll::Delta(lines));
 980                     let (point, side) = self.grid_point(self.last_pointer);
 981                     if let Some(selection) = &mut self.tab_mut().term.selection {
 982                         selection.update(point, side);
 983                     }
 984                     *needs_rebuild = true;
 985                 }
 986             } else {
 987                 self.autoscroll_accum = 0.0;
 988             }
 989         }
 990 
 991         // Config edits apply live: poll the config files' mtime (the
 992         // toolkit's own getters stat them on every call anyway) and re-derive
 993         // settings, grid, and pty size on change. Scrollback capacity is the
 994         // exception — it's baked into the Term at startup.
 995         let stamp = cce_ui::config::config_files_modified();
 996         if stamp == self.config_stamp {
 997             return;
 998         }
 999         self.config_stamp = stamp;
1000         let font = self.font.clone();
1001         let reloaded = Settings::load(&font, self.zoom_steps);
1002         if self.apply_settings(reloaded) {
1003             *needs_rebuild = true;
1004         }
1005     }
1006 
1007     fn handle_resize(&mut self, width: f32, height: f32, _scale: f64) {
1008         self.win_w = width;
1009         self.win_h = height;
1010         let (cols, rows) = self.grid_for(width, height);
1011         if (cols, rows) != (self.cols, self.rows) {
1012             self.resize_tabs(cols, rows);
1013         }
1014     }
1015 
1016     fn display_list(&mut self, size: LogicalSize, _scale: f64) -> Option<DisplayList> {
1017         let (w, h) = (size.width, size.height);
1018         let mut pc = PaintCtx::new();
1019 
1020         // The standard root plate (cce-ui `PlateSpec::window`).
1021         pc.root_plate(w, h);
1022         let frame = Rect { x: 0.0, y: 0.0, width: w, height: h };
1023 
1024         let rows = self.rows as usize;
1025         let pad = self.pad;
1026         let font = self.font.clone();
1027         let bounds = [pad, 0.0, w - pad, h];
1028 
1029         let tab = self.tab();
1030         let bell = tab.bell;
1031         let content = tab.term.renderable_content();
1032         let display_offset = content.display_offset as i32;
1033         let overrides = content.colors;
1034         let cfg_palette = self.settings.palette;
1035 
1036         // One ordered pass over the viewport cells, batching backgrounds and
1037         // same-style text into runs. Emission order: bg quads → text →
1038         // decorations → cursor.
1039         let mut bg_runs: Vec<(usize, usize, usize, Rgb)> = Vec::new();
1040         let mut text_runs: Vec<TextRun> = Vec::new();
1041         // (row, col_start, col_end, color, is_strikeout)
1042         let mut deco_runs: Vec<(usize, usize, usize, Rgb, bool)> = Vec::new();
1043         // (row, col_start, col_end) — selection highlight spans.
1044         let mut sel_runs: Vec<(usize, usize, usize)> = Vec::new();
1045         let mut cur_bg: Option<(usize, usize, usize, Rgb)> = None;
1046         let mut cur_text: Option<TextRun> = None;
1047 
1048         for cell in content.display_iter {
1049             let row_i = cell.point.line.0 + display_offset;
1050             if row_i < 0 {
1051                 continue;
1052             }
1053             let row = row_i as usize;
1054             if row >= rows {
1055                 break;
1056             }
1057             let col = cell.point.column.0;
1058             let flags = cell.flags;
1059             if flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) {
1060                 continue;
1061             }
1062             let width_cells = if flags.contains(Flags::WIDE_CHAR) { 2 } else { 1 };
1063             let (fg_color, bg_color) = if flags.contains(Flags::INVERSE) {
1064                 (cell.bg, cell.fg)
1065             } else {
1066                 (cell.fg, cell.bg)
1067             };
1068             let dim = flags.intersects(Flags::DIM);
1069             let fg = colors::resolve(fg_color, overrides, dim, &cfg_palette);
1070 
1071             // Selection highlight span.
1072             if content.selection.is_some_and(|sel| sel.contains(cell.point)) {
1073                 match sel_runs.last_mut() {
1074                     Some((r, _s, e)) if *r == row && *e == col => *e = col + width_cells,
1075                     _ => sel_runs.push((row, col, col + width_cells)),
1076                 }
1077             }
1078 
1079             // Background run (skip the default background: the plate shows through).
1080             let bg = (bg_color != AnsiColor::Named(NamedColor::Background))
1081                 .then(|| colors::resolve(bg_color, overrides, false, &cfg_palette));
1082             match (&mut cur_bg, bg) {
1083                 (Some((r, _s, e, rgb)), Some(new)) if *r == row && *e == col && *rgb == new => {
1084                     *e = col + width_cells;
1085                 }
1086                 (run, bg) => {
1087                     if let Some(done) = run.take() {
1088                         bg_runs.push(done);
1089                     }
1090                     if let Some(new) = bg {
1091                         *run = Some((row, col, col + width_cells, new));
1092                     }
1093                 }
1094             }
1095 
1096             // Decoration runs (underline family collapses to underline).
1097             let underline = flags.intersects(
1098                 Flags::UNDERLINE
1099                     | Flags::DOUBLE_UNDERLINE
1100                     | Flags::UNDERCURL
1101                     | Flags::DOTTED_UNDERLINE
1102                     | Flags::DASHED_UNDERLINE,
1103             );
1104             let strikeout = flags.contains(Flags::STRIKEOUT);
1105             for (on, is_strike) in [(underline, false), (strikeout, true)] {
1106                 if !on {
1107                     continue;
1108                 }
1109                 if let Some(last) = deco_runs.last_mut() {
1110                     if last.0 == row && last.2 == col && last.4 == is_strike && last.3 == fg {
1111                         last.2 = col + width_cells;
1112                         continue;
1113                     }
1114                 }
1115                 deco_runs.push((row, col, col + width_cells, fg, is_strike));
1116             }
1117 
1118             // Text run: spaces and hidden cells only break runs, never render.
1119             let bold = flags.intersects(Flags::BOLD);
1120             let italic = flags.intersects(Flags::ITALIC);
1121             let renders = cell.c != ' ' && !flags.contains(Flags::HIDDEN);
1122             match &mut cur_text {
1123                 Some(run)
1124                     if renders
1125                         && run.row == row
1126                         && run.next_col == col
1127                         && run.fg == fg
1128                         && run.bold == bold
1129                         && run.italic == italic
1130                         && width_cells == 1 =>
1131                 {
1132                     run.text.push(cell.c);
1133                     run.next_col += 1;
1134                 }
1135                 run => {
1136                     if let Some(done) = run.take() {
1137                         text_runs.push(done);
1138                     }
1139                     if renders {
1140                         *run = Some(TextRun {
1141                             row,
1142                             col,
1143                             next_col: col + width_cells,
1144                             text: cell.c.to_string(),
1145                             fg,
1146                             bold,
1147                             italic,
1148                         });
1149                         // A wide char ends its run: the glyph's natural advance
1150                         // (~2 cells) is not guaranteed to match, so don't let
1151                         // drift accumulate into following cells.
1152                         if width_cells == 2 {
1153                             text_runs.push(run.take().unwrap());
1154                         }
1155                     }
1156                 }
1157             }
1158         }
1159         if let Some(run) = cur_bg.take() {
1160             bg_runs.push(run);
1161         }
1162         if let Some(run) = cur_text.take() {
1163             text_runs.push(run);
1164         }
1165 
1166         let cursor = content.cursor;
1167         let cursor_shape = cursor.shape;
1168         let cursor_row = cursor.point.line.0 + display_offset;
1169         let cursor_col = cursor.point.column.0;
1170         let cursor_rgb = colors::indexed(NamedColor::Cursor as usize, overrides, &cfg_palette);
1171         let mode = content.mode;
1172 
1173         for (row, start, end, rgb) in bg_runs {
1174             pc.quad(self.cell_rect(row, start, end - start), quad_color(rgb, 1.0));
1175         }
1176         for (row, start, end) in sel_runs {
1177             pc.quad(
1178                 self.cell_rect(row, start, end - start),
1179                 quad_color(cfg_palette.selection, 0.28),
1180             );
1181         }
1182         for run in text_runs {
1183             pc.text_attrs(
1184                 run.text,
1185                 pad + run.col as f32 * self.settings.cell_w,
1186                 pad + run.row as f32 * self.settings.line_h,
1187                 self.settings.font_size,
1188                 [run.fg.r, run.fg.g, run.fg.b],
1189                 Some(font.clone()),
1190                 Some(bounds),
1191                 TextAttrs { italic: run.italic, weight: run.bold.then_some(700), ..Default::default() },
1192             );
1193         }
1194         for (row, start, end, rgb, is_strike) in deco_runs {
1195             let mut rect = self.cell_rect(row, start, end - start);
1196             rect.y += if is_strike {
1197                 self.settings.line_h * 0.55
1198             } else {
1199                 self.settings.line_h - 2.0
1200             };
1201             rect.height = 1.0;
1202             pc.quad(rect, quad_color(rgb, 1.0));
1203         }
1204 
1205         // Cursor last, over the glyphs. Only when visible in the viewport
1206         // (scrolled history moves it off) and not hidden by DECTCEM.
1207         let cursor_shown = cursor_shape != CursorShape::Hidden
1208             && mode.contains(TermMode::SHOW_CURSOR)
1209             && (0..rows as i32).contains(&cursor_row);
1210         // A focused terminal always takes typing: say so, so a touch raises
1211         // the on-screen keyboard. At the cursor cell when it is on screen,
1212         // else the whole grid (a TUI hiding it, or scrolled history).
1213         if self.focused {
1214             let at = if cursor_shown { self.cell_rect(cursor_row as usize, cursor_col, 1) } else { frame };
1215             cce_ui::text_input::claim(at.x, at.y, at.width, at.height);
1216         }
1217         if cursor_shown {
1218             let rect = self.cell_rect(cursor_row as usize, cursor_col, 1);
1219             if !self.focused {
1220                 // Hollow outline while unfocused.
1221                 pc.border(
1222                     rect,
1223                     (0.0, 0.0, 0.0, 0.0),
1224                     [0.0, 0.0, 0.0, 0.0],
1225                     quad_color(cursor_rgb, 0.8),
1226                     1.0,
1227                 );
1228             } else {
1229                 match cursor_shape {
1230                     CursorShape::Beam => {
1231                         pc.quad(
1232                             Rect { width: 2.0, ..rect },
1233                             quad_color(cursor_rgb, 0.9),
1234                         );
1235                     }
1236                     CursorShape::Underline => {
1237                         pc.quad(
1238                             Rect { y: rect.y + self.settings.line_h - 2.0, height: 2.0, ..rect },
1239                             quad_color(cursor_rgb, 0.9),
1240                         );
1241                     }
1242                     // Block (and HollowBlock while focused): translucent
1243                     // overlay so the glyph beneath stays readable.
1244                     _ => pc.quad(rect, quad_color(cursor_rgb, 0.4)),
1245                 }
1246             }
1247         }
1248 
1249         // Visual bell: a brief foreground-tinted wash over the frame.
1250         if bell > 0.0 {
1251             pc.quad(frame, quad_color(cfg_palette.foreground, 0.12 * bell));
1252         }
1253 
1254         // The corner control over the grid, and its menu over everything.
1255         self.paint_plate_menu(&mut pc);
1256 
1257         Some(pc.finish())
1258     }
1259 
1260     fn display_list_text(&self) -> bool {
1261         true
1262     }
1263 
1264     fn handle_focus_change(&mut self, focused: bool, needs_rebuild: &mut bool) {
1265         if self.focused != focused {
1266             self.focused = focused;
1267             *needs_rebuild = true;
1268             // Modifier releases are lost while unfocused — start clean.
1269             self.shift_down = false;
1270             self.ctrl_down = false;
1271             self.alt_down = false;
1272             self.mouse_buttons = 0;
1273             if self.tab().term.mode().contains(TermMode::FOCUS_IN_OUT) {
1274                 self.write_pty(if focused { b"\x1b[I" } else { b"\x1b[O" });
1275             }
1276         }
1277     }
1278 
1279     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
1280         if std::env::var_os("CCE_TERM_DEBUG").is_some() {
1281             eprintln!("[input] move ({:.1},{:.1}) selecting={}", pos.x, pos.y, self.selecting);
1282         }
1283         // The corner control's hover emphasis is a repaint; the open menu
1284         // takes the pointer exclusively (its own row hover).
1285         let was_on_corner = self.plate_corner_hit(self.last_pointer.x as f32, self.last_pointer.y as f32);
1286         self.last_pointer = pos;
1287         if self.plate_menu_open() {
1288             if cce_ui::widget::context_menu::cursor_moved(pos.x as f32, pos.y as f32) {
1289                 *needs_rebuild = true;
1290             }
1291             return;
1292         }
1293         if self.plate_corner_hit(pos.x as f32, pos.y as f32) != was_on_corner {
1294             *needs_rebuild = true;
1295         }
1296         if self.mouse_reporting() && !self.selecting {
1297             let mode = *self.tab().term.mode();
1298             let motion_wanted = mode.contains(TermMode::MOUSE_MOTION)
1299                 || (mode.contains(TermMode::MOUSE_DRAG) && self.mouse_buttons != 0);
1300             if motion_wanted {
1301                 let cell = self.viewport_cell(pos);
1302                 if self.tab().last_mouse_cell != Some(cell) {
1303                     self.tab_mut().last_mouse_cell = Some(cell);
1304                     // Lowest held button, or 3 (no button) for plain motion.
1305                     let button = (0..3).find(|b| self.mouse_buttons & (1 << b) != 0).unwrap_or(3);
1306                     let code = 32 + button + self.report_mods();
1307                     self.send_mouse_report(code, true, cell.0, cell.1);
1308                 }
1309             }
1310             return;
1311         }
1312         if !self.selecting {
1313             return;
1314         }
1315         // Edge overshoot scrolls on tick's autoscroll clock, not per event.
1316         let (point, side) = self.grid_point(pos);
1317         if let Some(selection) = &mut self.tab_mut().term.selection {
1318             selection.update(point, side);
1319             *needs_rebuild = true;
1320         }
1321     }
1322 
1323     fn handle_mouse_input(
1324         &mut self,
1325         button: MouseButton,
1326         state: ElementState,
1327         pos: LogicalPosition,
1328         needs_rebuild: &mut bool,
1329     ) -> Option<Self::Message> {
1330         if std::env::var_os("CCE_TERM_DEBUG").is_some() {
1331             eprintln!(
1332                 "[input] {:?} {:?} ({:.1},{:.1}) sel={} mode={:?} shift={}",
1333                 button,
1334                 state,
1335                 pos.x,
1336                 pos.y,
1337                 self.tab().term.selection.is_some(),
1338                 self.tab().term.mode(),
1339                 self.shift_down
1340             );
1341         }
1342         let button_bit = match button {
1343             MouseButton::Left => Some(0u8),
1344             MouseButton::Middle => Some(1),
1345             MouseButton::Right => Some(2),
1346             _ => None,
1347         };
1348         if let Some(bit) = button_bit {
1349             match state {
1350                 ElementState::Pressed => self.mouse_buttons |= 1 << bit,
1351                 ElementState::Released => self.mouse_buttons &= !(1 << bit),
1352             }
1353         }
1354 
1355         // The corner menu, ahead of everything: an open menu owns every
1356         // button event, and a left press on the control opens it (and never
1357         // reaches the grid — not as a selection, not as a mouse report).
1358         let (px, py) = (pos.x as f32, pos.y as f32);
1359         if self.handle_plate_menu_input(button, state, px, py) {
1360             *needs_rebuild = true;
1361             return None;
1362         }
1363         if button == MouseButton::Left
1364             && state == ElementState::Pressed
1365             && self.plate_corner_hit(px, py)
1366         {
1367             self.open_plate_menu();
1368             *needs_rebuild = true;
1369             return None;
1370         }
1371         // A right press opens it as a context menu at the pointer — unless
1372         // the app owns the pointer, where the press is reported instead
1373         // (Shift bypasses reporting, so the menu stays reachable there too).
1374         // The selection is left alone, so Copy is offered for it.
1375         if button == MouseButton::Right
1376             && state == ElementState::Pressed
1377             && !(self.mouse_reporting() && !self.selecting)
1378         {
1379             self.open_context_menu(px, py);
1380             *needs_rebuild = true;
1381             return None;
1382         }
1383 
1384         // Application-owned pointer: report and stop — no selection, no
1385         // middle-paste (Shift bypasses via mouse_reporting).
1386         if self.mouse_reporting() && !self.selecting {
1387             if let Some(code) = button_bit {
1388                 let (col, row) = self.viewport_cell(pos);
1389                 let code = code + self.report_mods();
1390                 self.send_mouse_report(code, state == ElementState::Pressed, col, row);
1391                 if state == ElementState::Pressed {
1392                     self.tab_mut().last_mouse_cell = Some((col, row));
1393                 }
1394             }
1395             return None;
1396         }
1397 
1398         match (button, state) {
1399             (MouseButton::Left, ElementState::Pressed) => {
1400                 let (point, side) = self.grid_point(pos);
1401                 let now = Instant::now();
1402                 let repeat = self
1403                     .last_click
1404                     .is_some_and(|(t, p)| now - t < MULTI_CLICK_WINDOW && p == point);
1405                 self.click_count = if repeat { self.click_count % 3 + 1 } else { 1 };
1406                 self.last_click = Some((now, point));
1407                 let ty = match self.click_count {
1408                     2 => SelectionType::Semantic,
1409                     3 => SelectionType::Lines,
1410                     _ => SelectionType::Simple,
1411                 };
1412                 let had_selection = self.tab().term.selection.is_some();
1413                 self.tab_mut().term.selection = Some(Selection::new(ty, point, side));
1414                 self.selecting = true;
1415                 // Semantic/Lines are non-empty immediately; a fresh Simple
1416                 // press only needs a repaint if it cleared an old highlight.
1417                 *needs_rebuild = had_selection || ty != SelectionType::Simple;
1418             }
1419             (MouseButton::Left, ElementState::Released) => {
1420                 self.selecting = false;
1421                 // Empty selections (a plain click) drop; real ones go to
1422                 // PRIMARY, per the select-then-middle-click convention.
1423                 match self.tab().term.selection_to_string() {
1424                     Some(text) if !text.is_empty() => clip::copy_primary(&text),
1425                     _ => {
1426                         if self.tab_mut().term.selection.take().is_some() {
1427                             *needs_rebuild = true;
1428                         }
1429                     }
1430                 }
1431             }
1432             (MouseButton::Middle, ElementState::Pressed) => {
1433                 if let Some(text) = clip::paste_primary() {
1434                     if !text.is_empty() {
1435                         self.paste(&text);
1436                     }
1437                 }
1438             }
1439             _ => {}
1440         }
1441         None
1442     }
1443 
1444     fn handle_mouse_wheel(
1445         &mut self,
1446         delta: &MouseScrollDelta,
1447         pos: LogicalPosition,
1448         needs_rebuild: &mut bool,
1449     ) {
1450         // Lines, signed like the display offset: up is positive.
1451         let lines = delta.notches_y() * SCROLL_LINES_PER_NOTCH;
1452 
1453         // Wheel reports take precedence over alternate-scroll arrows. Both
1454         // are whole-line protocols and stay quantized.
1455         if self.mouse_reporting() {
1456             let Some(lines) = self.take_whole_lines(lines) else { return };
1457             let (col, row) = self.viewport_cell(pos);
1458             let code = if lines > 0 { 64 } else { 65 } + self.report_mods();
1459             for _ in 0..lines.unsigned_abs() {
1460                 self.send_mouse_report(code, true, col, row);
1461             }
1462             return;
1463         }
1464 
1465         let mode = *self.tab().term.mode();
1466         if mode.contains(TermMode::ALT_SCREEN) && mode.contains(TermMode::ALTERNATE_SCROLL) {
1467             // Full-screen apps without mouse reporting get arrow keys.
1468             let Some(lines) = self.take_whole_lines(lines) else { return };
1469             let key: &[u8] = if lines > 0 { b"\x1b[A" } else { b"\x1b[B" };
1470             let bytes = key.repeat(lines.unsigned_abs() as usize);
1471             self.write_pty(&bytes);
1472             return;
1473         }
1474 
1475         // Scrollback: feed the line-unit motion. `apply_px` rather than
1476         // `apply` — that one's sign flip and 1:1 pixel path are for pixel
1477         // offsets, whereas here up grows the offset and the delta is already
1478         // in lines. A finger lift arrives as a zero delta and must reach the
1479         // motion (it is what starts the coast), so no early return on zero.
1480         self.sync_scroll_motion();
1481         let bounds = self.scrollback_bounds();
1482         let discrete = matches!(delta, MouseScrollDelta::LineDelta(..));
1483         let moved =
1484             self.tab_mut().scroll_motion.apply_px(0.0, lines, discrete, Bounds::max(0.0), bounds);
1485         if moved && self.apply_scroll_motion() {
1486             *needs_rebuild = true;
1487         }
1488         if self.tab().scroll_motion.is_animating() {
1489             // A notch only moved the target; tick glides the view there.
1490             *needs_rebuild = true;
1491         }
1492     }
1493 
1494     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
1495         // Modifier state for mouse reporting (both edges, ahead of the
1496         // pressed-only gate).
1497         match &event.logical_key {
1498             Key::Named(NamedKey::Shift) => self.shift_down = event.state == ElementState::Pressed,
1499             Key::Named(NamedKey::Control) => {
1500                 self.ctrl_down = event.state == ElementState::Pressed
1501             }
1502             Key::Named(NamedKey::Alt) => self.alt_down = event.state == ElementState::Pressed,
1503             _ => {}
1504         }
1505         if event.state != ElementState::Pressed {
1506             return None;
1507         }
1508 
1509         // Escape dismisses the corner menu instead of reaching the shell.
1510         if self.plate_menu_open() && matches!(event.logical_key, Key::Named(NamedKey::Escape)) {
1511             self.close_plate_menu();
1512             *needs_rebuild = true;
1513             return None;
1514         }
1515 
1516         // App shortcuts (input.kdl-rebindable), ahead of terminal encoding —
1517         // a bare Ctrl+C must still reach the shell as SIGINT.
1518         use cce_ui::widget::match_key_shortcut;
1519         if match_key_shortcut(event, &self.keys.copy) {
1520             if let Some(text) = self.tab().term.selection_to_string() {
1521                 if !text.is_empty() {
1522                     cce_ui::widget::clipboard::copy_to_clipboard(&text);
1523                 }
1524             }
1525             return None;
1526         }
1527         // Tabs: the same operations the corner menu offers, by chord.
1528         if match_key_shortcut(event, &self.keys.new_tab) {
1529             self.new_tab();
1530             *needs_rebuild = true;
1531             return None;
1532         }
1533         if match_key_shortcut(event, &self.keys.close_tab) {
1534             self.close_active_tab();
1535             *needs_rebuild = true;
1536             return None;
1537         }
1538         if match_key_shortcut(event, &self.keys.next_tab) {
1539             self.cycle_tab(1);
1540             *needs_rebuild = true;
1541             return None;
1542         }
1543         if match_key_shortcut(event, &self.keys.prev_tab) {
1544             self.cycle_tab(-1);
1545             *needs_rebuild = true;
1546             return None;
1547         }
1548         if match_key_shortcut(event, &self.keys.paste) {
1549             if let Some(text) = cce_ui::widget::clipboard::read_from_clipboard() {
1550                 if !text.is_empty() {
1551                     self.paste(&text);
1552                 }
1553             }
1554             return None;
1555         }
1556         if match_key_shortcut(event, &self.keys.scroll_up) {
1557             self.tab_mut().term.scroll_display(Scroll::PageUp);
1558             *needs_rebuild = true;
1559             return None;
1560         }
1561         if match_key_shortcut(event, &self.keys.scroll_down) {
1562             self.tab_mut().term.scroll_display(Scroll::PageDown);
1563             *needs_rebuild = true;
1564             return None;
1565         }
1566 
1567         if let Some(bytes) = encode_key(event, *self.tab().term.mode()) {
1568             self.write_pty(&bytes);
1569             let term = &mut self.tab_mut().term;
1570             if term.selection.take().is_some() {
1571                 *needs_rebuild = true;
1572             }
1573             if term.grid().display_offset() != 0 {
1574                 term.scroll_display(Scroll::Bottom);
1575                 *needs_rebuild = true;
1576             }
1577         }
1578         None
1579     }
1580 
1581     fn clear_color(&self) -> [f32; 4] {
1582         [0.0, 0.0, 0.0, 0.0]
1583     }
1584 }
1585 
1586 fn main() {
1587     // cce-ui logs its own fatal paths (Wayland dispatch / protocol errors that
1588     // end the event loop) through `log`, which is a no-op sink unless the app
1589     // installs a logger — without this, an app that dies with its compositor
1590     // connection leaves no explanation behind.
1591     env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
1592     log::info!("cce-terminal starting (pid {})", std::process::id());
1593     cce_ui::engine::run::<TerminalApp>();
1594     log::info!("cce-terminal event loop returned; exiting");
1595 }
1596 
1597 #[cfg(test)]
1598 mod tests {
1599     use super::*;
1600     use alacritty_terminal::event::VoidListener;
1601     use alacritty_terminal::index::{Column, Line};
1602 
1603     fn term_with(bytes: &[u8]) -> Term<VoidListener> {
1604         let mut term = Term::new(
1605             TermConfig::default(),
1606             &TermDims { cols: 20, rows: 5 },
1607             VoidListener,
1608         );
1609         let mut parser: Processor = Processor::new();
1610         parser.advance(&mut term, bytes);
1611         term
1612     }
1613 
1614     #[test]
1615     fn sgr_colors_land_in_grid() {
1616         let term = term_with(b"\x1b[31mred\x1b[0m ok");
1617         let grid = term.grid();
1618         assert_eq!(grid[Line(0)][Column(0)].c, 'r');
1619         assert_eq!(grid[Line(0)][Column(0)].fg, AnsiColor::Named(NamedColor::Red));
1620         assert_eq!(grid[Line(0)][Column(4)].c, 'o');
1621         assert_eq!(grid[Line(0)][Column(4)].fg, AnsiColor::Named(NamedColor::Foreground));
1622     }
1623 
1624     #[test]
1625     fn cursor_addressing_works() {
1626         // CUP to row 3 col 5, write X — the stub couldn't do this.
1627         let term = term_with(b"\x1b[3;5HX");
1628         assert_eq!(term.grid()[Line(2)][Column(4)].c, 'X');
1629         assert_eq!(term.grid().cursor.point.line, Line(2));
1630     }
1631 
1632     fn key(logical_key: Key, ctrl: bool, shift: bool, alt: bool) -> KeyEvent {
1633         let text = match &logical_key {
1634             Key::Character(s) if !ctrl && !alt => Some(s.clone()),
1635             _ => None,
1636         };
1637         KeyEvent { state: ElementState::Pressed, logical_key, text, repeat: false, ctrl, shift, alt }
1638     }
1639 
1640     #[test]
1641     fn app_cursor_mode_switches_arrow_encoding() {
1642         let ev = key(Key::Named(NamedKey::ArrowUp), false, false, false);
1643         assert_eq!(encode_key(&ev, TermMode::empty()).unwrap(), b"\x1b[A");
1644         assert_eq!(encode_key(&ev, TermMode::APP_CURSOR).unwrap(), b"\x1bOA");
1645     }
1646 
1647     #[test]
1648     fn modifier_parameters_on_csi_keys() {
1649         let up = |c, s, a| key(Key::Named(NamedKey::ArrowUp), c, s, a);
1650         // alt = +2, ctrl = +4, shift = +1 on the xterm modifier parameter.
1651         assert_eq!(encode_key(&up(false, false, true), TermMode::empty()).unwrap(), b"\x1b[1;3A");
1652         assert_eq!(encode_key(&up(true, false, false), TermMode::empty()).unwrap(), b"\x1b[1;5A");
1653         assert_eq!(encode_key(&up(true, true, true), TermMode::empty()).unwrap(), b"\x1b[1;8A");
1654         // Modified keys keep CSI form even in app-cursor mode.
1655         assert_eq!(
1656             encode_key(&up(true, false, false), TermMode::APP_CURSOR).unwrap(),
1657             b"\x1b[1;5A"
1658         );
1659         // Tilde keys carry the parameter after their number.
1660         let del = key(Key::Named(NamedKey::Delete), false, false, true);
1661         assert_eq!(encode_key(&del, TermMode::empty()).unwrap(), b"\x1b[3;3~");
1662     }
1663 
1664     #[test]
1665     fn alt_prefixes_esc() {
1666         let b = key(Key::Character("b".into()), false, false, true);
1667         assert_eq!(encode_key(&b, TermMode::empty()).unwrap(), b"\x1bb");
1668         // ctrl+alt compose: ESC then the ctrl byte.
1669         let w = key(Key::Character("w".into()), true, false, true);
1670         assert_eq!(encode_key(&w, TermMode::empty()).unwrap(), vec![0x1b, 0x17]);
1671         // alt+backspace: readline backward-kill-word.
1672         let bs = key(Key::Named(NamedKey::Backspace), false, false, true);
1673         assert_eq!(encode_key(&bs, TermMode::empty()).unwrap(), vec![0x1b, 0x7f]);
1674         // shift+tab is backtab regardless of other state.
1675         let tab = key(Key::Named(NamedKey::Tab), false, true, false);
1676         assert_eq!(encode_key(&tab, TermMode::empty()).unwrap(), b"\x1b[Z");
1677     }
1678 
1679     #[test]
1680     fn selection_extracts_text() {
1681         let mut term = term_with(b"hello world\r\nsecond line");
1682         // Word-select "world": semantic selection from a point inside it.
1683         term.selection = Some(Selection::new(
1684             SelectionType::Semantic,
1685             Point::new(Line(0), Column(8)),
1686             Side::Left,
1687         ));
1688         assert_eq!(term.selection_to_string().as_deref(), Some("world"));
1689         // Line-select the second row.
1690         term.selection = Some(Selection::new(
1691             SelectionType::Lines,
1692             Point::new(Line(1), Column(3)),
1693             Side::Left,
1694         ));
1695         // Line selections carry their trailing newline.
1696         assert_eq!(term.selection_to_string().as_deref(), Some("second line\n"));
1697         // Simple drag across the first word.
1698         let mut sel =
1699             Selection::new(SelectionType::Simple, Point::new(Line(0), Column(0)), Side::Left);
1700         sel.update(Point::new(Line(0), Column(4)), Side::Right);
1701         term.selection = Some(sel);
1702         assert_eq!(term.selection_to_string().as_deref(), Some("hello"));
1703     }
1704 
1705     #[test]
1706     fn mouse_report_encodings() {
1707         let sgr = TermMode::MOUSE_REPORT_CLICK | TermMode::SGR_MOUSE;
1708         // SGR: press 'M', release 'm', same button code, 1-based coords.
1709         assert_eq!(mouse_report_bytes(sgr, 0, true, 4, 2).unwrap(), b"\x1b[<0;5;3M");
1710         assert_eq!(mouse_report_bytes(sgr, 2, false, 0, 0).unwrap(), b"\x1b[<2;1;1m");
1711         // Legacy: +32 bytes, release collapses the button to 3.
1712         let legacy = TermMode::MOUSE_REPORT_CLICK;
1713         assert_eq!(
1714             mouse_report_bytes(legacy, 0, true, 4, 2).unwrap(),
1715             vec![0x1b, b'[', b'M', 32, 37, 35]
1716         );
1717         assert_eq!(
1718             mouse_report_bytes(legacy, 0, false, 4, 2).unwrap(),
1719             vec![0x1b, b'[', b'M', 35, 37, 35]
1720         );
1721         // Legacy coordinate saturation at byte 255.
1722         assert_eq!(mouse_report_bytes(legacy, 0, true, 300, 2).unwrap()[4], 255);
1723         // UTF-8 extended coords: col 200 → 233 → two-byte UTF-8.
1724         let utf8 = TermMode::MOUSE_REPORT_CLICK | TermMode::UTF8_MOUSE;
1725         let bytes = mouse_report_bytes(utf8, 0, true, 199, 0).unwrap();
1726         assert_eq!(&bytes[4..], "\u{e8}\u{21}".to_string().as_bytes());
1727     }
1728 
1729     #[test]
1730     fn mouse_modes_land_from_escapes() {
1731         let term = term_with(b"\x1b[?1002h\x1b[?1006h");
1732         assert!(term.mode().contains(TermMode::MOUSE_DRAG));
1733         assert!(term.mode().contains(TermMode::SGR_MOUSE));
1734         assert!(term.mode().intersects(TermMode::MOUSE_MODE));
1735     }
1736 
1737     #[test]
1738     fn paste_encoding() {
1739         assert_eq!(paste_bytes("a\nb\r\nc", false), b"a\rb\rc");
1740         assert_eq!(
1741             paste_bytes("hi\x1b[201~!", true),
1742             b"\x1b[200~hi!\x1b[201~"
1743         );
1744     }
1745 
1746     #[test]
1747     fn measured_advance_is_sane() {
1748         // Depends on the bundled fonts being present ($CCE_FONTS_DIR /
1749         // ~/Dropbox/Fonts); when they are, the measured advance must sit in
1750         // the mono band around the 0.60 em estimate.
1751         if let Some(adv) = measure_advance("Berkeley Mono", 14.0) {
1752             assert!((5.0..=14.0).contains(&adv), "advance {adv} out of band");
1753         }
1754     }
1755 
1756     #[test]
1757     fn shortcut_matching_honors_alt() {
1758         let ev = key(Key::Character("c".into()), true, true, false);
1759         assert!(cce_ui::widget::match_key_shortcut(&ev, "ctrl+shift+c"));
1760         assert!(!cce_ui::widget::match_key_shortcut(&ev, "ctrl+alt+c"));
1761         let alt_ev = key(Key::Character("c".into()), false, false, true);
1762         assert!(cce_ui::widget::match_key_shortcut(&alt_ev, "alt+c"));
1763         assert!(!cce_ui::widget::match_key_shortcut(&alt_ev, "c"));
1764     }
1765 
1766     #[test]
1767     fn ctrl_chars_encode() {
1768         let c = key(Key::Character("c".into()), true, false, false);
1769         assert_eq!(encode_key(&c, TermMode::empty()).unwrap(), vec![0x03]);
1770         let a = key(Key::Character("a".into()), false, false, false);
1771         assert_eq!(encode_key(&a, TermMode::empty()).unwrap(), b"a");
1772     }
1773 }