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

src/main.rs (69.4K)

   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 tab ([`Tab`]) is one shell on one pty with its own `Term`,
  27 //! title, view offset and bell; the window shows the active one and the
  28 //! menu lists them as a radio group (the designer's dock-tab language) with
  29 //! New Tab / Close Tab. There is no tab bar: the title carries `[i/n]` while
  30 //! more than one is open. Rebindable chords (`cce-terminal` domain in
  31 //! input.kdl): `new_tab`, `close_tab`, `next_tab`, `prev_tab`.
  32 //!
  33 //! Config (KDL, live-reloaded on file change): a `terminal { … }` section in
  34 //! the shared `~/.config/cce/config.kdl` or the per-app
  35 //! `~/.config/cce/cce-terminal/config.kdl` (app file wins) with `font_size`,
  36 //! `scrollback`, and a `colors { … }` block naming `foreground`, `background`,
  37 //! `cursor`, `selection`, and the 16 ANSI slots (`black` … `bright_white`)
  38 //! as hex strings. The font family comes from the shared config's
  39 //! `fonts { terminal }` key, like the rest of the DE's font routing.
  40 //!
  41 //! Not yet: measured cell metrics (0.60 em / 1.2 em estimates — exact for
  42 //! Berkeley Mono in practice).
  43 
  44 mod clip;
  45 mod colors;
  46 mod plate_menu;
  47 mod pty;
  48 
  49 use std::io::{Read, Write};
  50 use std::path::{Path, PathBuf};
  51 use std::time::{Duration, Instant};
  52 
  53 use alacritty_terminal::event::{Event as TermEvent, EventListener, WindowSize};
  54 use alacritty_terminal::grid::{Dimensions, Scroll};
  55 use alacritty_terminal::index::{Column, Line, Point, Side};
  56 use alacritty_terminal::selection::{Selection, SelectionType};
  57 use alacritty_terminal::term::cell::Flags;
  58 use alacritty_terminal::term::{ClipboardType, Config as TermConfig, Term, TermMode};
  59 use alacritty_terminal::vte::ansi::{
  60     Color as AnsiColor, CursorShape, NamedColor, Processor, Rgb,
  61 };
  62 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
  63 use cce_ui::scene::layout::Rect;
  64 use cce_ui::scene::paint::{DisplayList, PaintCtx, TextAttrs};
  65 use cce_ui::widget::{Bounds, ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey, ScrollMotion};
  66 use wayland_client::QueueHandle;
  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 new(
 765         _qh: &QueueHandle<EngineState<Self>>,
 766         sender: calloop::channel::Sender<Self::Message>,
 767     ) -> Self {
 768         // The window-edge inset: the root plate's roll plus one padding.
 769         let pad = cce_ui::layout::root_plate_inset();
 770         // `.3` is the `fonts { terminal }` family (this was the 7-tuple's
 771         // fontconfig `terminal` alias before the DE's fonts moved into the
 772         // shared KDL config), falling back to Noto Sans Mono.
 773         let font = cce_ui::layout::read_preferred_fonts().3;
 774         let settings = Settings::load(&font, 0);
 775         let config_stamp = cce_ui::config::config_files_modified();
 776         let (cols, rows) = grid_dims(INIT_W as f32, INIT_H as f32, pad, &settings);
 777 
 778         // A command instead of $SHELL: `cce-terminal -e <cmd> [args…]`
 779         // (xterm-style), or bare trailing args (foot-style) — the launcher
 780         // hosts `Terminal=true` entries positionally (`term sh -c …`), so
 781         // both conventions must work.
 782         let argv: Vec<String> = std::env::args().skip(1).collect();
 783         let command: Option<Vec<String>> = match argv.first().map(String::as_str) {
 784             Some("-e") => Some(argv[1..].to_vec()).filter(|c| !c.is_empty()),
 785             Some(_) => Some(argv.clone()),
 786             None => None,
 787         };
 788 
 789         // The first tab runs the command (later tabs are plain shells) and
 790         // inherits the terminal's own directory.
 791         let first = Tab::spawn(TabId(0), cols, rows, &settings, &sender, command.as_deref(), None)
 792             .expect("cce-terminal: failed to spawn shell on pty");
 793 
 794         TerminalApp {
 795             sender,
 796             tabs: vec![first],
 797             active: 0,
 798             next_tab_id: 1,
 799             font,
 800             pad,
 801             cols,
 802             rows,
 803             scroll_accum: 0.0,
 804             focused: true,
 805             selecting: false,
 806             last_click: None,
 807             click_count: 0,
 808             settings,
 809             keys: Keys::load(),
 810             config_stamp,
 811             last_pointer: LogicalPosition::new(0.0, 0.0),
 812             autoscroll_accum: 0.0,
 813             win_w: INIT_W as f32,
 814             win_h: INIT_H as f32,
 815             shift_down: false,
 816             ctrl_down: false,
 817             alt_down: false,
 818             mouse_buttons: 0,
 819             zoom_steps: 0,
 820             plate_menu_actions: Vec::new(),
 821         }
 822     }
 823 
 824     fn settings(&self) -> WindowSettings {
 825         // The active tab's title; with several tabs and no tab bar, the
 826         // title is where "which one, of how many" shows.
 827         let mut title = self.tab().title.clone().unwrap_or_else(|| "cce-terminal".to_string());
 828         if self.tabs.len() > 1 {
 829             title = format!("{title} [{}/{}]", self.active + 1, self.tabs.len());
 830         }
 831         WindowSettings {
 832             title,
 833             app_id: "cce-terminal".to_string(),
 834             width: INIT_W,
 835             height: INIT_H,
 836             fullscreen: false,
 837             min_size: Some((240, 140)),
 838         }
 839     }
 840 
 841     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool) {
 842         // Every message names its tab; one from a tab already closed (its
 843         // reader thread outlives the kill by a moment) is simply dropped.
 844         // Background tabs keep parsing but never ask for a frame: nothing
 845         // of theirs is on screen except their menu label and the title.
 846         let (id, is_active) = match &msg {
 847             Msg::Pty(id, _) | Msg::PtyClosed(id) | Msg::Term(id, _) => {
 848                 let Some(i) = self.tab_index(*id) else { return };
 849                 (i, i == self.active)
 850             }
 851         };
 852         let window = WindowSize {
 853             num_lines: self.rows,
 854             num_cols: self.cols,
 855             cell_width: self.settings.cell_w as u16,
 856             cell_height: self.settings.line_h as u16,
 857         };
 858         let palette = self.settings.palette;
 859         let tab = &mut self.tabs[id];
 860         match msg {
 861             Msg::Pty(_, bytes) => {
 862                 tab.parser.advance(&mut tab.term, &bytes);
 863                 *needs_rebuild |= is_active;
 864             }
 865             Msg::PtyClosed(_) => {
 866                 let _ = tab.pty.child.wait();
 867                 if self.tabs.len() == 1 {
 868                     // The last shell exiting is the window closing — the
 869                     // tab stays in place so nothing observes an empty list.
 870                     *exit = true;
 871                     return;
 872                 }
 873                 self.tabs.remove(id);
 874                 if id < self.active {
 875                     self.active -= 1;
 876                 } else if self.active >= self.tabs.len() {
 877                     self.active = self.tabs.len() - 1;
 878                 }
 879                 if is_active {
 880                     self.selecting = false;
 881                     self.last_click = None;
 882                     self.click_count = 0;
 883                 }
 884                 *needs_rebuild = true;
 885             }
 886             Msg::Term(_, event) => match event {
 887                 TermEvent::PtyWrite(s) => {
 888                     let _ = tab.writer.write_all(s.as_bytes());
 889                 }
 890                 TermEvent::Title(t) => {
 891                     tab.title = Some(t);
 892                     *needs_rebuild |= is_active;
 893                 }
 894                 TermEvent::ResetTitle => {
 895                     tab.title = None;
 896                     *needs_rebuild |= is_active;
 897                 }
 898                 TermEvent::ColorRequest(index, format) => {
 899                     let rgb = tab.term.colors()[index]
 900                         .unwrap_or_else(|| colors::default_color(index, &palette));
 901                     let _ = tab.writer.write_all(format(rgb).as_bytes());
 902                 }
 903                 TermEvent::TextAreaSizeRequest(format) => {
 904                     let _ = tab.writer.write_all(format(window).as_bytes());
 905                 }
 906                 // OSC 52: programs storing to (or, if enabled in the term
 907                 // config, reading from) the system clipboards.
 908                 TermEvent::ClipboardStore(ty, text) => match ty {
 909                     ClipboardType::Clipboard => cce_ui::widget::clipboard::copy_to_clipboard(&text),
 910                     ClipboardType::Selection => clip::copy_primary(&text),
 911                 },
 912                 TermEvent::ClipboardLoad(ty, format) => {
 913                     let text = match ty {
 914                         ClipboardType::Clipboard => {
 915                             cce_ui::widget::clipboard::read_from_clipboard()
 916                         }
 917                         ClipboardType::Selection => clip::paste_primary(),
 918                     }
 919                     .unwrap_or_default();
 920                     let _ = tab.writer.write_all(format(&text).as_bytes());
 921                 }
 922                 TermEvent::Bell => {
 923                     tab.bell = 1.0;
 924                     *needs_rebuild |= is_active;
 925                 }
 926                 // Wakeup/cursor-blink: nothing to do.
 927                 _ => {}
 928             },
 929         }
 930     }
 931 
 932     /// Two things age in `tick` without redrawing anything the runner can
 933     /// see: a background tab's bell flash (only the active tab's decay asks
 934     /// for a frame) and selection autoscroll with the pointer held still in
 935     /// the edge padding (motion events stop at the edge). Both are real-time
 936     /// decays, and the runner's idle sleep clamps `dt` to one frame per
 937     /// wake — so ask for frame cadence while either is live, and nothing
 938     /// otherwise.
 939     fn idle_poll_interval(&self) -> Option<std::time::Duration> {
 940         let bell = self.tabs.iter().any(|t| t.bell > 0.0);
 941         (bell || self.autoscroll_overshoot() != 0.0)
 942             .then(|| std::time::Duration::from_millis(16))
 943     }
 944 
 945     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
 946         // Bell flash decay — every tab's, so a background tab's flash has
 947         // faded by the time it is shown rather than greeting the switch.
 948         for (i, tab) in self.tabs.iter_mut().enumerate() {
 949             if tab.bell > 0.0 {
 950                 tab.bell = (tab.bell - dt * 4.0).max(0.0);
 951                 *needs_rebuild |= i == self.active;
 952             }
 953         }
 954 
 955         // Wheel glide / trackpad coast through the scrollback: advance the
 956         // line-unit motion and step the Term to its rounded offset, asking
 957         // for frames while it is still moving.
 958         if self.tab().scroll_motion.is_animating() {
 959             self.sync_scroll_motion();
 960             if self.tab().scroll_motion.is_animating() {
 961                 let bounds = self.scrollback_bounds();
 962                 self.tab_mut().scroll_motion.tick(dt, Bounds::max(0.0), bounds);
 963                 if self.apply_scroll_motion() || self.tab().scroll_motion.is_animating() {
 964                     *needs_rebuild = true;
 965                 }
 966             }
 967         }
 968 
 969         // Selection autoscroll: while a drag holds the pointer in the top or
 970         // bottom edge padding, scroll at a rate scaling with the overshoot
 971         // (motion events stop at the edge, so this is time-driven).
 972         {
 973             let overshoot = self.autoscroll_overshoot();
 974             if overshoot != 0.0 {
 975                 let rate = 4.0 + overshoot.abs().min(24.0) * 2.0; // lines/sec
 976                 self.autoscroll_accum += dt * rate * overshoot.signum();
 977                 let lines = self.autoscroll_accum as i32;
 978                 if lines != 0 {
 979                     self.autoscroll_accum -= lines as f32;
 980                     self.tab_mut().term.scroll_display(Scroll::Delta(lines));
 981                     let (point, side) = self.grid_point(self.last_pointer);
 982                     if let Some(selection) = &mut self.tab_mut().term.selection {
 983                         selection.update(point, side);
 984                     }
 985                     *needs_rebuild = true;
 986                 }
 987             } else {
 988                 self.autoscroll_accum = 0.0;
 989             }
 990         }
 991 
 992         // Config edits apply live: poll the config files' mtime (the
 993         // toolkit's own getters stat them on every call anyway) and re-derive
 994         // settings, grid, and pty size on change. Scrollback capacity is the
 995         // exception — it's baked into the Term at startup.
 996         let stamp = cce_ui::config::config_files_modified();
 997         if stamp == self.config_stamp {
 998             return;
 999         }
1000         self.config_stamp = stamp;
1001         let font = self.font.clone();
1002         let reloaded = Settings::load(&font, self.zoom_steps);
1003         if self.apply_settings(reloaded) {
1004             *needs_rebuild = true;
1005         }
1006     }
1007 
1008     fn handle_resize(&mut self, width: f32, height: f32, _scale: f64) {
1009         self.win_w = width;
1010         self.win_h = height;
1011         let (cols, rows) = self.grid_for(width, height);
1012         if (cols, rows) != (self.cols, self.rows) {
1013             self.resize_tabs(cols, rows);
1014         }
1015     }
1016 
1017     fn display_list(&mut self, size: LogicalSize, _scale: f64) -> Option<DisplayList> {
1018         let (w, h) = (size.width, size.height);
1019         let mut pc = PaintCtx::new();
1020 
1021         // The standard root plate (cce-ui `PlateSpec::window`).
1022         pc.root_plate(w, h);
1023         let frame = Rect { x: 0.0, y: 0.0, width: w, height: h };
1024 
1025         let rows = self.rows as usize;
1026         let pad = self.pad;
1027         let font = self.font.clone();
1028         let bounds = [pad, 0.0, w - pad, h];
1029 
1030         let tab = self.tab();
1031         let bell = tab.bell;
1032         let content = tab.term.renderable_content();
1033         let display_offset = content.display_offset as i32;
1034         let overrides = content.colors;
1035         let cfg_palette = self.settings.palette;
1036 
1037         // One ordered pass over the viewport cells, batching backgrounds and
1038         // same-style text into runs. Emission order: bg quads → text →
1039         // decorations → cursor.
1040         let mut bg_runs: Vec<(usize, usize, usize, Rgb)> = Vec::new();
1041         let mut text_runs: Vec<TextRun> = Vec::new();
1042         // (row, col_start, col_end, color, is_strikeout)
1043         let mut deco_runs: Vec<(usize, usize, usize, Rgb, bool)> = Vec::new();
1044         // (row, col_start, col_end) — selection highlight spans.
1045         let mut sel_runs: Vec<(usize, usize, usize)> = Vec::new();
1046         let mut cur_bg: Option<(usize, usize, usize, Rgb)> = None;
1047         let mut cur_text: Option<TextRun> = None;
1048 
1049         for cell in content.display_iter {
1050             let row_i = cell.point.line.0 + display_offset;
1051             if row_i < 0 {
1052                 continue;
1053             }
1054             let row = row_i as usize;
1055             if row >= rows {
1056                 break;
1057             }
1058             let col = cell.point.column.0;
1059             let flags = cell.flags;
1060             if flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) {
1061                 continue;
1062             }
1063             let width_cells = if flags.contains(Flags::WIDE_CHAR) { 2 } else { 1 };
1064             let (fg_color, bg_color) = if flags.contains(Flags::INVERSE) {
1065                 (cell.bg, cell.fg)
1066             } else {
1067                 (cell.fg, cell.bg)
1068             };
1069             let dim = flags.intersects(Flags::DIM);
1070             let fg = colors::resolve(fg_color, overrides, dim, &cfg_palette);
1071 
1072             // Selection highlight span.
1073             if content.selection.is_some_and(|sel| sel.contains(cell.point)) {
1074                 match sel_runs.last_mut() {
1075                     Some((r, _s, e)) if *r == row && *e == col => *e = col + width_cells,
1076                     _ => sel_runs.push((row, col, col + width_cells)),
1077                 }
1078             }
1079 
1080             // Background run (skip the default background: the plate shows through).
1081             let bg = (bg_color != AnsiColor::Named(NamedColor::Background))
1082                 .then(|| colors::resolve(bg_color, overrides, false, &cfg_palette));
1083             match (&mut cur_bg, bg) {
1084                 (Some((r, _s, e, rgb)), Some(new)) if *r == row && *e == col && *rgb == new => {
1085                     *e = col + width_cells;
1086                 }
1087                 (run, bg) => {
1088                     if let Some(done) = run.take() {
1089                         bg_runs.push(done);
1090                     }
1091                     if let Some(new) = bg {
1092                         *run = Some((row, col, col + width_cells, new));
1093                     }
1094                 }
1095             }
1096 
1097             // Decoration runs (underline family collapses to underline).
1098             let underline = flags.intersects(
1099                 Flags::UNDERLINE
1100                     | Flags::DOUBLE_UNDERLINE
1101                     | Flags::UNDERCURL
1102                     | Flags::DOTTED_UNDERLINE
1103                     | Flags::DASHED_UNDERLINE,
1104             );
1105             let strikeout = flags.contains(Flags::STRIKEOUT);
1106             for (on, is_strike) in [(underline, false), (strikeout, true)] {
1107                 if !on {
1108                     continue;
1109                 }
1110                 if let Some(last) = deco_runs.last_mut() {
1111                     if last.0 == row && last.2 == col && last.4 == is_strike && last.3 == fg {
1112                         last.2 = col + width_cells;
1113                         continue;
1114                     }
1115                 }
1116                 deco_runs.push((row, col, col + width_cells, fg, is_strike));
1117             }
1118 
1119             // Text run: spaces and hidden cells only break runs, never render.
1120             let bold = flags.intersects(Flags::BOLD);
1121             let italic = flags.intersects(Flags::ITALIC);
1122             let renders = cell.c != ' ' && !flags.contains(Flags::HIDDEN);
1123             match &mut cur_text {
1124                 Some(run)
1125                     if renders
1126                         && run.row == row
1127                         && run.next_col == col
1128                         && run.fg == fg
1129                         && run.bold == bold
1130                         && run.italic == italic
1131                         && width_cells == 1 =>
1132                 {
1133                     run.text.push(cell.c);
1134                     run.next_col += 1;
1135                 }
1136                 run => {
1137                     if let Some(done) = run.take() {
1138                         text_runs.push(done);
1139                     }
1140                     if renders {
1141                         *run = Some(TextRun {
1142                             row,
1143                             col,
1144                             next_col: col + width_cells,
1145                             text: cell.c.to_string(),
1146                             fg,
1147                             bold,
1148                             italic,
1149                         });
1150                         // A wide char ends its run: the glyph's natural advance
1151                         // (~2 cells) is not guaranteed to match, so don't let
1152                         // drift accumulate into following cells.
1153                         if width_cells == 2 {
1154                             text_runs.push(run.take().unwrap());
1155                         }
1156                     }
1157                 }
1158             }
1159         }
1160         if let Some(run) = cur_bg.take() {
1161             bg_runs.push(run);
1162         }
1163         if let Some(run) = cur_text.take() {
1164             text_runs.push(run);
1165         }
1166 
1167         let cursor = content.cursor;
1168         let cursor_shape = cursor.shape;
1169         let cursor_row = cursor.point.line.0 + display_offset;
1170         let cursor_col = cursor.point.column.0;
1171         let cursor_rgb = colors::indexed(NamedColor::Cursor as usize, overrides, &cfg_palette);
1172         let mode = content.mode;
1173 
1174         for (row, start, end, rgb) in bg_runs {
1175             pc.quad(self.cell_rect(row, start, end - start), quad_color(rgb, 1.0));
1176         }
1177         for (row, start, end) in sel_runs {
1178             pc.quad(
1179                 self.cell_rect(row, start, end - start),
1180                 quad_color(cfg_palette.selection, 0.28),
1181             );
1182         }
1183         for run in text_runs {
1184             pc.text_attrs(
1185                 run.text,
1186                 pad + run.col as f32 * self.settings.cell_w,
1187                 pad + run.row as f32 * self.settings.line_h,
1188                 self.settings.font_size,
1189                 [run.fg.r, run.fg.g, run.fg.b],
1190                 Some(font.clone()),
1191                 Some(bounds),
1192                 TextAttrs { italic: run.italic, weight: run.bold.then_some(700) },
1193             );
1194         }
1195         for (row, start, end, rgb, is_strike) in deco_runs {
1196             let mut rect = self.cell_rect(row, start, end - start);
1197             rect.y += if is_strike {
1198                 self.settings.line_h * 0.55
1199             } else {
1200                 self.settings.line_h - 2.0
1201             };
1202             rect.height = 1.0;
1203             pc.quad(rect, quad_color(rgb, 1.0));
1204         }
1205 
1206         // Cursor last, over the glyphs. Only when visible in the viewport
1207         // (scrolled history moves it off) and not hidden by DECTCEM.
1208         if cursor_shape != CursorShape::Hidden
1209             && mode.contains(TermMode::SHOW_CURSOR)
1210             && (0..rows as i32).contains(&cursor_row)
1211         {
1212             let rect = self.cell_rect(cursor_row as usize, cursor_col, 1);
1213             if !self.focused {
1214                 // Hollow outline while unfocused.
1215                 pc.border(
1216                     rect,
1217                     (0.0, 0.0, 0.0, 0.0),
1218                     [0.0, 0.0, 0.0, 0.0],
1219                     quad_color(cursor_rgb, 0.8),
1220                     1.0,
1221                 );
1222             } else {
1223                 match cursor_shape {
1224                     CursorShape::Beam => {
1225                         pc.quad(
1226                             Rect { width: 2.0, ..rect },
1227                             quad_color(cursor_rgb, 0.9),
1228                         );
1229                     }
1230                     CursorShape::Underline => {
1231                         pc.quad(
1232                             Rect { y: rect.y + self.settings.line_h - 2.0, height: 2.0, ..rect },
1233                             quad_color(cursor_rgb, 0.9),
1234                         );
1235                     }
1236                     // Block (and HollowBlock while focused): translucent
1237                     // overlay so the glyph beneath stays readable.
1238                     _ => pc.quad(rect, quad_color(cursor_rgb, 0.4)),
1239                 }
1240             }
1241         }
1242 
1243         // Visual bell: a brief foreground-tinted wash over the frame.
1244         if bell > 0.0 {
1245             pc.quad(frame, quad_color(cfg_palette.foreground, 0.12 * bell));
1246         }
1247 
1248         // The corner control over the grid, and its menu over everything.
1249         self.paint_plate_menu(&mut pc);
1250 
1251         Some(pc.finish())
1252     }
1253 
1254     fn display_list_text(&self) -> bool {
1255         true
1256     }
1257 
1258     fn handle_focus_change(&mut self, focused: bool, needs_rebuild: &mut bool) {
1259         if self.focused != focused {
1260             self.focused = focused;
1261             *needs_rebuild = true;
1262             // Modifier releases are lost while unfocused — start clean.
1263             self.shift_down = false;
1264             self.ctrl_down = false;
1265             self.alt_down = false;
1266             self.mouse_buttons = 0;
1267             if self.tab().term.mode().contains(TermMode::FOCUS_IN_OUT) {
1268                 self.write_pty(if focused { b"\x1b[I" } else { b"\x1b[O" });
1269             }
1270         }
1271     }
1272 
1273     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
1274         if std::env::var_os("CCE_TERM_DEBUG").is_some() {
1275             eprintln!("[input] move ({:.1},{:.1}) selecting={}", pos.x, pos.y, self.selecting);
1276         }
1277         // The corner control's hover emphasis is a repaint; the open menu
1278         // takes the pointer exclusively (its own row hover).
1279         let was_on_corner = self.plate_corner_hit(self.last_pointer.x as f32, self.last_pointer.y as f32);
1280         self.last_pointer = pos;
1281         if self.plate_menu_open() {
1282             if cce_ui::widget::context_menu::cursor_moved(pos.x as f32, pos.y as f32) {
1283                 *needs_rebuild = true;
1284             }
1285             return;
1286         }
1287         if self.plate_corner_hit(pos.x as f32, pos.y as f32) != was_on_corner {
1288             *needs_rebuild = true;
1289         }
1290         if self.mouse_reporting() && !self.selecting {
1291             let mode = *self.tab().term.mode();
1292             let motion_wanted = mode.contains(TermMode::MOUSE_MOTION)
1293                 || (mode.contains(TermMode::MOUSE_DRAG) && self.mouse_buttons != 0);
1294             if motion_wanted {
1295                 let cell = self.viewport_cell(pos);
1296                 if self.tab().last_mouse_cell != Some(cell) {
1297                     self.tab_mut().last_mouse_cell = Some(cell);
1298                     // Lowest held button, or 3 (no button) for plain motion.
1299                     let button = (0..3).find(|b| self.mouse_buttons & (1 << b) != 0).unwrap_or(3);
1300                     let code = 32 + button + self.report_mods();
1301                     self.send_mouse_report(code, true, cell.0, cell.1);
1302                 }
1303             }
1304             return;
1305         }
1306         if !self.selecting {
1307             return;
1308         }
1309         // Edge overshoot scrolls on tick's autoscroll clock, not per event.
1310         let (point, side) = self.grid_point(pos);
1311         if let Some(selection) = &mut self.tab_mut().term.selection {
1312             selection.update(point, side);
1313             *needs_rebuild = true;
1314         }
1315     }
1316 
1317     fn handle_mouse_input(
1318         &mut self,
1319         button: MouseButton,
1320         state: ElementState,
1321         pos: LogicalPosition,
1322         needs_rebuild: &mut bool,
1323     ) -> Option<Self::Message> {
1324         if std::env::var_os("CCE_TERM_DEBUG").is_some() {
1325             eprintln!(
1326                 "[input] {:?} {:?} ({:.1},{:.1}) sel={} mode={:?} shift={}",
1327                 button,
1328                 state,
1329                 pos.x,
1330                 pos.y,
1331                 self.tab().term.selection.is_some(),
1332                 self.tab().term.mode(),
1333                 self.shift_down
1334             );
1335         }
1336         let button_bit = match button {
1337             MouseButton::Left => Some(0u8),
1338             MouseButton::Middle => Some(1),
1339             MouseButton::Right => Some(2),
1340             _ => None,
1341         };
1342         if let Some(bit) = button_bit {
1343             match state {
1344                 ElementState::Pressed => self.mouse_buttons |= 1 << bit,
1345                 ElementState::Released => self.mouse_buttons &= !(1 << bit),
1346             }
1347         }
1348 
1349         // The corner menu, ahead of everything: an open menu owns every
1350         // button event, and a left press on the control opens it (and never
1351         // reaches the grid — not as a selection, not as a mouse report).
1352         let (px, py) = (pos.x as f32, pos.y as f32);
1353         if self.handle_plate_menu_input(button, state, px, py) {
1354             *needs_rebuild = true;
1355             return None;
1356         }
1357         if button == MouseButton::Left
1358             && state == ElementState::Pressed
1359             && self.plate_corner_hit(px, py)
1360         {
1361             self.open_plate_menu();
1362             *needs_rebuild = true;
1363             return None;
1364         }
1365 
1366         // Application-owned pointer: report and stop — no selection, no
1367         // middle-paste (Shift bypasses via mouse_reporting).
1368         if self.mouse_reporting() && !self.selecting {
1369             if let Some(code) = button_bit {
1370                 let (col, row) = self.viewport_cell(pos);
1371                 let code = code + self.report_mods();
1372                 self.send_mouse_report(code, state == ElementState::Pressed, col, row);
1373                 if state == ElementState::Pressed {
1374                     self.tab_mut().last_mouse_cell = Some((col, row));
1375                 }
1376             }
1377             return None;
1378         }
1379 
1380         match (button, state) {
1381             (MouseButton::Left, ElementState::Pressed) => {
1382                 let (point, side) = self.grid_point(pos);
1383                 let now = Instant::now();
1384                 let repeat = self
1385                     .last_click
1386                     .is_some_and(|(t, p)| now - t < MULTI_CLICK_WINDOW && p == point);
1387                 self.click_count = if repeat { self.click_count % 3 + 1 } else { 1 };
1388                 self.last_click = Some((now, point));
1389                 let ty = match self.click_count {
1390                     2 => SelectionType::Semantic,
1391                     3 => SelectionType::Lines,
1392                     _ => SelectionType::Simple,
1393                 };
1394                 let had_selection = self.tab().term.selection.is_some();
1395                 self.tab_mut().term.selection = Some(Selection::new(ty, point, side));
1396                 self.selecting = true;
1397                 // Semantic/Lines are non-empty immediately; a fresh Simple
1398                 // press only needs a repaint if it cleared an old highlight.
1399                 *needs_rebuild = had_selection || ty != SelectionType::Simple;
1400             }
1401             (MouseButton::Left, ElementState::Released) => {
1402                 self.selecting = false;
1403                 // Empty selections (a plain click) drop; real ones go to
1404                 // PRIMARY, per the select-then-middle-click convention.
1405                 match self.tab().term.selection_to_string() {
1406                     Some(text) if !text.is_empty() => clip::copy_primary(&text),
1407                     _ => {
1408                         if self.tab_mut().term.selection.take().is_some() {
1409                             *needs_rebuild = true;
1410                         }
1411                     }
1412                 }
1413             }
1414             (MouseButton::Middle, ElementState::Pressed) => {
1415                 if let Some(text) = clip::paste_primary() {
1416                     if !text.is_empty() {
1417                         self.paste(&text);
1418                     }
1419                 }
1420             }
1421             _ => {}
1422         }
1423         None
1424     }
1425 
1426     fn handle_mouse_wheel(
1427         &mut self,
1428         delta: &MouseScrollDelta,
1429         pos: LogicalPosition,
1430         needs_rebuild: &mut bool,
1431     ) {
1432         // Lines, signed like the display offset: up is positive.
1433         let lines = delta.notches_y() * SCROLL_LINES_PER_NOTCH;
1434 
1435         // Wheel reports take precedence over alternate-scroll arrows. Both
1436         // are whole-line protocols and stay quantized.
1437         if self.mouse_reporting() {
1438             let Some(lines) = self.take_whole_lines(lines) else { return };
1439             let (col, row) = self.viewport_cell(pos);
1440             let code = if lines > 0 { 64 } else { 65 } + self.report_mods();
1441             for _ in 0..lines.unsigned_abs() {
1442                 self.send_mouse_report(code, true, col, row);
1443             }
1444             return;
1445         }
1446 
1447         let mode = *self.tab().term.mode();
1448         if mode.contains(TermMode::ALT_SCREEN) && mode.contains(TermMode::ALTERNATE_SCROLL) {
1449             // Full-screen apps without mouse reporting get arrow keys.
1450             let Some(lines) = self.take_whole_lines(lines) else { return };
1451             let key: &[u8] = if lines > 0 { b"\x1b[A" } else { b"\x1b[B" };
1452             let bytes = key.repeat(lines.unsigned_abs() as usize);
1453             self.write_pty(&bytes);
1454             return;
1455         }
1456 
1457         // Scrollback: feed the line-unit motion. `apply_px` rather than
1458         // `apply` — that one's sign flip and 1:1 pixel path are for pixel
1459         // offsets, whereas here up grows the offset and the delta is already
1460         // in lines. A finger lift arrives as a zero delta and must reach the
1461         // motion (it is what starts the coast), so no early return on zero.
1462         self.sync_scroll_motion();
1463         let bounds = self.scrollback_bounds();
1464         let discrete = matches!(delta, MouseScrollDelta::LineDelta(..));
1465         let moved =
1466             self.tab_mut().scroll_motion.apply_px(0.0, lines, discrete, Bounds::max(0.0), bounds);
1467         if moved && self.apply_scroll_motion() {
1468             *needs_rebuild = true;
1469         }
1470         if self.tab().scroll_motion.is_animating() {
1471             // A notch only moved the target; tick glides the view there.
1472             *needs_rebuild = true;
1473         }
1474     }
1475 
1476     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
1477         // Modifier state for mouse reporting (both edges, ahead of the
1478         // pressed-only gate).
1479         match &event.logical_key {
1480             Key::Named(NamedKey::Shift) => self.shift_down = event.state == ElementState::Pressed,
1481             Key::Named(NamedKey::Control) => {
1482                 self.ctrl_down = event.state == ElementState::Pressed
1483             }
1484             Key::Named(NamedKey::Alt) => self.alt_down = event.state == ElementState::Pressed,
1485             _ => {}
1486         }
1487         if event.state != ElementState::Pressed {
1488             return None;
1489         }
1490 
1491         // Escape dismisses the corner menu instead of reaching the shell.
1492         if self.plate_menu_open() && matches!(event.logical_key, Key::Named(NamedKey::Escape)) {
1493             self.close_plate_menu();
1494             *needs_rebuild = true;
1495             return None;
1496         }
1497 
1498         // App shortcuts (input.kdl-rebindable), ahead of terminal encoding —
1499         // a bare Ctrl+C must still reach the shell as SIGINT.
1500         use cce_ui::widget::match_key_shortcut;
1501         if match_key_shortcut(event, &self.keys.copy) {
1502             if let Some(text) = self.tab().term.selection_to_string() {
1503                 if !text.is_empty() {
1504                     cce_ui::widget::clipboard::copy_to_clipboard(&text);
1505                 }
1506             }
1507             return None;
1508         }
1509         // Tabs: the same operations the corner menu offers, by chord.
1510         if match_key_shortcut(event, &self.keys.new_tab) {
1511             self.new_tab();
1512             *needs_rebuild = true;
1513             return None;
1514         }
1515         if match_key_shortcut(event, &self.keys.close_tab) {
1516             self.close_active_tab();
1517             *needs_rebuild = true;
1518             return None;
1519         }
1520         if match_key_shortcut(event, &self.keys.next_tab) {
1521             self.cycle_tab(1);
1522             *needs_rebuild = true;
1523             return None;
1524         }
1525         if match_key_shortcut(event, &self.keys.prev_tab) {
1526             self.cycle_tab(-1);
1527             *needs_rebuild = true;
1528             return None;
1529         }
1530         if match_key_shortcut(event, &self.keys.paste) {
1531             if let Some(text) = cce_ui::widget::clipboard::read_from_clipboard() {
1532                 if !text.is_empty() {
1533                     self.paste(&text);
1534                 }
1535             }
1536             return None;
1537         }
1538         if match_key_shortcut(event, &self.keys.scroll_up) {
1539             self.tab_mut().term.scroll_display(Scroll::PageUp);
1540             *needs_rebuild = true;
1541             return None;
1542         }
1543         if match_key_shortcut(event, &self.keys.scroll_down) {
1544             self.tab_mut().term.scroll_display(Scroll::PageDown);
1545             *needs_rebuild = true;
1546             return None;
1547         }
1548 
1549         if let Some(bytes) = encode_key(event, *self.tab().term.mode()) {
1550             self.write_pty(&bytes);
1551             let term = &mut self.tab_mut().term;
1552             if term.selection.take().is_some() {
1553                 *needs_rebuild = true;
1554             }
1555             if term.grid().display_offset() != 0 {
1556                 term.scroll_display(Scroll::Bottom);
1557                 *needs_rebuild = true;
1558             }
1559         }
1560         None
1561     }
1562 
1563     fn clear_color(&self) -> [f32; 4] {
1564         [0.0, 0.0, 0.0, 0.0]
1565     }
1566 }
1567 
1568 fn main() {
1569     // cce-ui logs its own fatal paths (Wayland dispatch / protocol errors that
1570     // end the event loop) through `log`, which is a no-op sink unless the app
1571     // installs a logger — without this, an app that dies with its compositor
1572     // connection leaves no explanation behind.
1573     env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
1574     log::info!("cce-terminal starting (pid {})", std::process::id());
1575     cce_ui::engine::run::<TerminalApp>();
1576     log::info!("cce-terminal event loop returned; exiting");
1577 }
1578 
1579 #[cfg(test)]
1580 mod tests {
1581     use super::*;
1582     use alacritty_terminal::event::VoidListener;
1583     use alacritty_terminal::index::{Column, Line};
1584 
1585     fn term_with(bytes: &[u8]) -> Term<VoidListener> {
1586         let mut term = Term::new(
1587             TermConfig::default(),
1588             &TermDims { cols: 20, rows: 5 },
1589             VoidListener,
1590         );
1591         let mut parser: Processor = Processor::new();
1592         parser.advance(&mut term, bytes);
1593         term
1594     }
1595 
1596     #[test]
1597     fn sgr_colors_land_in_grid() {
1598         let term = term_with(b"\x1b[31mred\x1b[0m ok");
1599         let grid = term.grid();
1600         assert_eq!(grid[Line(0)][Column(0)].c, 'r');
1601         assert_eq!(grid[Line(0)][Column(0)].fg, AnsiColor::Named(NamedColor::Red));
1602         assert_eq!(grid[Line(0)][Column(4)].c, 'o');
1603         assert_eq!(grid[Line(0)][Column(4)].fg, AnsiColor::Named(NamedColor::Foreground));
1604     }
1605 
1606     #[test]
1607     fn cursor_addressing_works() {
1608         // CUP to row 3 col 5, write X — the stub couldn't do this.
1609         let term = term_with(b"\x1b[3;5HX");
1610         assert_eq!(term.grid()[Line(2)][Column(4)].c, 'X');
1611         assert_eq!(term.grid().cursor.point.line, Line(2));
1612     }
1613 
1614     fn key(logical_key: Key, ctrl: bool, shift: bool, alt: bool) -> KeyEvent {
1615         let text = match &logical_key {
1616             Key::Character(s) if !ctrl && !alt => Some(s.clone()),
1617             _ => None,
1618         };
1619         KeyEvent { state: ElementState::Pressed, logical_key, text, repeat: false, ctrl, shift, alt }
1620     }
1621 
1622     #[test]
1623     fn app_cursor_mode_switches_arrow_encoding() {
1624         let ev = key(Key::Named(NamedKey::ArrowUp), false, false, false);
1625         assert_eq!(encode_key(&ev, TermMode::empty()).unwrap(), b"\x1b[A");
1626         assert_eq!(encode_key(&ev, TermMode::APP_CURSOR).unwrap(), b"\x1bOA");
1627     }
1628 
1629     #[test]
1630     fn modifier_parameters_on_csi_keys() {
1631         let up = |c, s, a| key(Key::Named(NamedKey::ArrowUp), c, s, a);
1632         // alt = +2, ctrl = +4, shift = +1 on the xterm modifier parameter.
1633         assert_eq!(encode_key(&up(false, false, true), TermMode::empty()).unwrap(), b"\x1b[1;3A");
1634         assert_eq!(encode_key(&up(true, false, false), TermMode::empty()).unwrap(), b"\x1b[1;5A");
1635         assert_eq!(encode_key(&up(true, true, true), TermMode::empty()).unwrap(), b"\x1b[1;8A");
1636         // Modified keys keep CSI form even in app-cursor mode.
1637         assert_eq!(
1638             encode_key(&up(true, false, false), TermMode::APP_CURSOR).unwrap(),
1639             b"\x1b[1;5A"
1640         );
1641         // Tilde keys carry the parameter after their number.
1642         let del = key(Key::Named(NamedKey::Delete), false, false, true);
1643         assert_eq!(encode_key(&del, TermMode::empty()).unwrap(), b"\x1b[3;3~");
1644     }
1645 
1646     #[test]
1647     fn alt_prefixes_esc() {
1648         let b = key(Key::Character("b".into()), false, false, true);
1649         assert_eq!(encode_key(&b, TermMode::empty()).unwrap(), b"\x1bb");
1650         // ctrl+alt compose: ESC then the ctrl byte.
1651         let w = key(Key::Character("w".into()), true, false, true);
1652         assert_eq!(encode_key(&w, TermMode::empty()).unwrap(), vec![0x1b, 0x17]);
1653         // alt+backspace: readline backward-kill-word.
1654         let bs = key(Key::Named(NamedKey::Backspace), false, false, true);
1655         assert_eq!(encode_key(&bs, TermMode::empty()).unwrap(), vec![0x1b, 0x7f]);
1656         // shift+tab is backtab regardless of other state.
1657         let tab = key(Key::Named(NamedKey::Tab), false, true, false);
1658         assert_eq!(encode_key(&tab, TermMode::empty()).unwrap(), b"\x1b[Z");
1659     }
1660 
1661     #[test]
1662     fn selection_extracts_text() {
1663         let mut term = term_with(b"hello world\r\nsecond line");
1664         // Word-select "world": semantic selection from a point inside it.
1665         term.selection = Some(Selection::new(
1666             SelectionType::Semantic,
1667             Point::new(Line(0), Column(8)),
1668             Side::Left,
1669         ));
1670         assert_eq!(term.selection_to_string().as_deref(), Some("world"));
1671         // Line-select the second row.
1672         term.selection = Some(Selection::new(
1673             SelectionType::Lines,
1674             Point::new(Line(1), Column(3)),
1675             Side::Left,
1676         ));
1677         // Line selections carry their trailing newline.
1678         assert_eq!(term.selection_to_string().as_deref(), Some("second line\n"));
1679         // Simple drag across the first word.
1680         let mut sel =
1681             Selection::new(SelectionType::Simple, Point::new(Line(0), Column(0)), Side::Left);
1682         sel.update(Point::new(Line(0), Column(4)), Side::Right);
1683         term.selection = Some(sel);
1684         assert_eq!(term.selection_to_string().as_deref(), Some("hello"));
1685     }
1686 
1687     #[test]
1688     fn mouse_report_encodings() {
1689         let sgr = TermMode::MOUSE_REPORT_CLICK | TermMode::SGR_MOUSE;
1690         // SGR: press 'M', release 'm', same button code, 1-based coords.
1691         assert_eq!(mouse_report_bytes(sgr, 0, true, 4, 2).unwrap(), b"\x1b[<0;5;3M");
1692         assert_eq!(mouse_report_bytes(sgr, 2, false, 0, 0).unwrap(), b"\x1b[<2;1;1m");
1693         // Legacy: +32 bytes, release collapses the button to 3.
1694         let legacy = TermMode::MOUSE_REPORT_CLICK;
1695         assert_eq!(
1696             mouse_report_bytes(legacy, 0, true, 4, 2).unwrap(),
1697             vec![0x1b, b'[', b'M', 32, 37, 35]
1698         );
1699         assert_eq!(
1700             mouse_report_bytes(legacy, 0, false, 4, 2).unwrap(),
1701             vec![0x1b, b'[', b'M', 35, 37, 35]
1702         );
1703         // Legacy coordinate saturation at byte 255.
1704         assert_eq!(mouse_report_bytes(legacy, 0, true, 300, 2).unwrap()[4], 255);
1705         // UTF-8 extended coords: col 200 → 233 → two-byte UTF-8.
1706         let utf8 = TermMode::MOUSE_REPORT_CLICK | TermMode::UTF8_MOUSE;
1707         let bytes = mouse_report_bytes(utf8, 0, true, 199, 0).unwrap();
1708         assert_eq!(&bytes[4..], "\u{e8}\u{21}".to_string().as_bytes());
1709     }
1710 
1711     #[test]
1712     fn mouse_modes_land_from_escapes() {
1713         let term = term_with(b"\x1b[?1002h\x1b[?1006h");
1714         assert!(term.mode().contains(TermMode::MOUSE_DRAG));
1715         assert!(term.mode().contains(TermMode::SGR_MOUSE));
1716         assert!(term.mode().intersects(TermMode::MOUSE_MODE));
1717     }
1718 
1719     #[test]
1720     fn paste_encoding() {
1721         assert_eq!(paste_bytes("a\nb\r\nc", false), b"a\rb\rc");
1722         assert_eq!(
1723             paste_bytes("hi\x1b[201~!", true),
1724             b"\x1b[200~hi!\x1b[201~"
1725         );
1726     }
1727 
1728     #[test]
1729     fn measured_advance_is_sane() {
1730         // Depends on the bundled fonts being present ($CCE_FONTS_DIR /
1731         // ~/Dropbox/Fonts); when they are, the measured advance must sit in
1732         // the mono band around the 0.60 em estimate.
1733         if let Some(adv) = measure_advance("Berkeley Mono", 14.0) {
1734             assert!((5.0..=14.0).contains(&adv), "advance {adv} out of band");
1735         }
1736     }
1737 
1738     #[test]
1739     fn shortcut_matching_honors_alt() {
1740         let ev = key(Key::Character("c".into()), true, true, false);
1741         assert!(cce_ui::widget::match_key_shortcut(&ev, "ctrl+shift+c"));
1742         assert!(!cce_ui::widget::match_key_shortcut(&ev, "ctrl+alt+c"));
1743         let alt_ev = key(Key::Character("c".into()), false, false, true);
1744         assert!(cce_ui::widget::match_key_shortcut(&alt_ev, "alt+c"));
1745         assert!(!cce_ui::widget::match_key_shortcut(&alt_ev, "c"));
1746     }
1747 
1748     #[test]
1749     fn ctrl_chars_encode() {
1750         let c = key(Key::Character("c".into()), true, false, false);
1751         assert_eq!(encode_key(&c, TermMode::empty()).unwrap(), vec![0x03]);
1752         let a = key(Key::Character("a".into()), false, false, false);
1753         assert_eq!(encode_key(&a, TermMode::empty()).unwrap(), b"a");
1754     }
1755 }