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