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 }