par-term 0.49.0

Cross-platform GPU-accelerated terminal emulator with inline graphics support (Sixel, iTerm2, Kitty)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
//! The gate over `AVAILABLE_ACTIONS`' advertised-chord column.
//!
//! # What is covered
//!
//! For every row of `par_term_settings_ui::input_tab::actions_table::AVAILABLE_ACTIONS`
//! that advertises a chord, [`advertised_chord_is_dispatched_by_its_own_action`]
//! resolves that chord against [`claims::claim_chain`] — the ordered list of
//! everything that can consume a key press before the terminal sees it — and
//! asserts the *first* source to claim it runs the action that advertises it.
//! That ordering clause is the point: it is what catches an advertised chord
//! that a higher-precedence layer silently eats.
//!
//! # What is not covered, and why
//!
//! - **Runtime-dynamic sources are excluded entirely**: the leader and the tmux
//!   prefix that aliases it, and the custom-action prefix. Any of them can shadow
//!   a chord at runtime and this gate will not notice; the default leader chord
//!   has its own gate (`leader::tests::no_advertised_chord_is_the_default_leader`).
//!   Profile shortcuts are not one of them: they are registry bindings to
//!   `open_profile:<id>` (UX MP3, B59).
//! - **Open overlays are excluded**: while a dialog, picker, or panel is open
//!   the overlay stack (UX.md OV2) consumes every key before `handle_key_event`
//!   runs. That is modal behaviour, not a chord claim, and it is gated by the
//!   routing tests in `app::overlay`.
//! - **No uniform layers remain** — the last one, per-profile hotkeys, became
//!   registry bindings (`paste_copy_is_the_only_hardcoded_source` gates it),
//!   and the macOS application menu lives in `menu/macos.rs`, outside this
//!   module.
//! - **The menu and the config defaults are derived, not mirrored** — they read
//!   `crate::menu::model::menu_model` and `Config::default().keybindings` live.
//! - **One platform per run.** `AVAILABLE_ACTIONS` and `Config::default()` are
//!   both `#[cfg]`-selected, so a run only ever sees the host's table. CI runs
//!   `cargo test --workspace` on Linux, macOS and Windows, so all three are
//!   covered across the matrix, never within one run.
//! - **The gate is one-directional.** It proves advertised → dispatched. The
//!   reverse (something dispatchable that the table advertises as having no
//!   default) is reported by [`claimed_actions_advertised_as_having_no_default`]
//!   against a frozen list, not gated.

use super::claims::{self, Chord, Platform};
use par_term_settings_ui::input_tab::actions_table::AVAILABLE_ACTIONS;

/// Advertised chords whose first claimer runs a different action.
///
/// Frozen deliberately: this gate exists to stop new ones. Each entry is
/// `action -> first claiming source / action it actually runs`. Empty on every
/// platform since UX.md I15 gave the File menu's Close item (Cmd+W /
/// Ctrl+Shift+W) the `close_pane` action and Close Tab its own chord; the
/// smart-close mismatch that used to sit here is gone.
const KNOWN_MISMATCHES: &[(&str, &str)] = &[];

/// Actions the table advertises as having no default, which some modeled source
/// nevertheless claims a chord for.
///
/// Reported, not gated: under-advertisement is a documentation gap, not a
/// dispatch defect, and correcting the table is a separate decision.
///
/// The menu-advertised chords became registry defaults (`defaults::menu_chords`,
/// UX K2), so every action the menus accelerate is advertised with its default.
/// Windows used to list `close_window (native_menu)`; since UX.md I15 no menu
/// item carries the smart close, so the set is empty on every platform.
const CLAIMED_BUT_ADVERTISED_AS_NONE: &[&str] = &[];

fn advertised() -> Vec<(&'static str, Chord)> {
    AVAILABLE_ACTIONS
        .iter()
        .filter_map(|(action, _, default)| default.map(|d| (*action, d)))
        .map(|(action, d)| {
            let chord = claims::parse_chord(d)
                .unwrap_or_else(|e| panic!("AVAILABLE_ACTIONS row {action:?}: {e}"));
            (action, chord)
        })
        .collect()
}

/// A row whose chord cannot be parsed must fail, never be skipped — a
/// permissive parser would launder exactly the strings this gate exists to
/// catch.
#[test]
fn every_advertised_chord_parses() {
    let bad: Vec<String> = AVAILABLE_ACTIONS
        .iter()
        .filter_map(|(action, _, default)| {
            let d = (*default)?;
            claims::parse_chord(d)
                .err()
                .map(|e| format!("{action}: {e}"))
        })
        .collect();
    assert!(
        bad.is_empty(),
        "advertised default chords that do not parse: {bad:#?}"
    );
}

/// The invariant: an advertised chord reaches the action that advertises it,
/// and no higher-precedence source claims it first.
#[test]
fn advertised_chord_is_dispatched_by_its_own_action() {
    // Both failure modes go into one list so neither can mask the other: a row
    // nothing claims and a row the wrong layer claims are the same defect from
    // the user's side — the settings window names a chord that does not do what
    // it says.
    let mut violations: Vec<(String, String)> = Vec::new();

    for (action, chord) in advertised() {
        match claims::first_claimer(chord) {
            None => violations.push((action.to_string(), NOTHING_CLAIMS_IT.to_string())),
            Some((source, claimed)) if claimed != action => {
                violations.push((action.to_string(), format!("{source}/{claimed}")));
            }
            Some(_) => {}
        }
    }
    violations.sort();

    let mut expected: Vec<(String, String)> = KNOWN_MISMATCHES
        .iter()
        .map(|(a, s)| (a.to_string(), s.to_string()))
        .collect();
    expected.sort();

    assert_eq!(
        violations, expected,
        "advertised chords whose first claimer is not the advertising action \
         changed. {NOTHING_CLAIMS_IT:?} means the shortcut does nothing at all; \
         a `source/action` value means a higher-precedence layer eats the chord \
         first. A new entry is a defect; a disappeared entry means a known one \
         was fixed and KNOWN_MISMATCHES should shrink."
    );
}

/// Marker used in place of `source/action` when no source claims the chord.
const NOTHING_CLAIMS_IT: &str = "<nothing>";

/// Complementary and cheap: where an action *does* ship default keybindings,
/// the advertised string must name one of them. Compared as parsed chords,
/// so `CmdOrCtrl` expansion, modifier ordering, and the word/literal
/// punctuation spellings (`Ctrl+Comma` vs `Ctrl+,`) cannot cause a false
/// failure. An action may ship more than one chord (next_tab: the bracket
/// pair and Ctrl+Tab; the font keys: the base and shifted spellings); the
/// advertised column names the primary one, which is also the chord the menu
/// displays (`registry_accel` is first-wins).
#[test]
fn advertised_chord_agrees_with_the_shipped_default() {
    let defaults = par_term_config::Config::default().keybindings;
    assert!(
        !defaults.is_empty(),
        "Config::default() shipped no keybindings — this test would be vacuous"
    );

    let mut checked = 0usize;
    let mut wrong: Vec<String> = Vec::new();

    for (action, default) in AVAILABLE_ACTIONS
        .iter()
        .filter_map(|(action, _, default)| default.map(|default| (*action, default)))
    {
        let shipped: Vec<claims::Chord> = defaults
            .iter()
            .filter(|kb| kb.action == action)
            .map(|kb| {
                claims::parse_chord(&kb.key)
                    .unwrap_or_else(|e| panic!("shipped default {:?}: {e}", kb.key))
            })
            .collect();
        if shipped.is_empty() {
            continue;
        }
        let shown = claims::parse_chord(default)
            .unwrap_or_else(|e| panic!("AVAILABLE_ACTIONS row {action:?}: {e}"));
        checked += 1;
        if !shipped.contains(&shown) {
            wrong.push(format!(
                "{action}: table says {default:?}, shipped defaults are {:?}",
                defaults
                    .iter()
                    .filter(|kb| kb.action == action)
                    .map(|kb| kb.key.as_str())
                    .collect::<Vec<_>>()
            ));
        }
    }

    assert!(checked > 0, "no advertised action has a shipped default");
    assert!(
        wrong.is_empty(),
        "advertised chords that disagree with Config::default().keybindings: {wrong:#?}"
    );
}

/// Tripwire: the only hardcoded source left after the registry is the inline
/// paste/copy branch. The utility and tab chord layers dissolved into
/// registry defaults (UX K2) and the per-profile hotkey layer into
/// `open_profile:<id>` bindings (UX MP3, B59); a new entry here means a
/// hardcoded chord layer came back.
#[test]
fn paste_copy_is_the_only_hardcoded_source() {
    let sources: Vec<&str> = claims::LAYER_CLAIMS.iter().map(|(name, _)| *name).collect();
    assert_eq!(
        sources,
        ["paste_copy"],
        "the only hardcoded chord source is the inline paste/copy branch, the \
         one deliberate exemption from the registry-only rule"
    );
}

/// B59: the per-profile hotkey layer is gone, source and all — its
/// `key.leak()` leaked a `String` on every unhandled key press reaching it,
/// and its whole-string matcher could not fire most of the chords users
/// wrote. Profile shortcuts are `open_profile:<id>` registry bindings now.
#[test]
fn the_profile_shortcut_layer_is_gone() {
    let handler = include_str!("mod.rs");
    for gone in [
        "handle_profile_shortcuts",
        "KEY_LAYERS:",
        "build_shortcut_string",
    ] {
        assert!(
            !handler.contains(gone),
            "{gone} came back in key_handler/mod.rs"
        );
    }
    assert!(
        !std::path::Path::new(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/src/app/input_events/key_handler/profiles.rs"
        ))
        .exists(),
        "key_handler/profiles.rs (the B59 layer, with its key.leak()) came back"
    );
}

/// Every claim must name either a real advertised action or an explicitly
/// internal one, so a typo cannot make a claim quietly unmatchable.
#[test]
fn claim_action_names_are_advertised_or_explicitly_internal() {
    let unknown: Vec<&str> = claims::LAYER_CLAIMS
        .iter()
        .flat_map(|(_, cs)| cs.iter())
        .map(|c| c.action)
        .filter(|a| {
            !a.starts_with("internal:") && !AVAILABLE_ACTIONS.iter().any(|(name, _, _)| name == a)
        })
        .collect();
    assert!(
        unknown.is_empty(),
        "layer claims naming an action that is neither advertised nor prefixed \
         `internal:`: {unknown:?}"
    );
}

/// Report-only: the reverse direction the gate does not prove.
#[test]
fn claimed_actions_advertised_as_having_no_default() {
    let unadvertised: Vec<&str> = AVAILABLE_ACTIONS
        .iter()
        .filter(|(_, _, default)| default.is_none())
        .map(|(action, _, _)| *action)
        .collect();

    let mut found: Vec<String> = claims::claim_chain()
        .iter()
        .filter(|rule| unadvertised.contains(&rule.action.as_str()))
        .map(|rule| format!("{} ({})", rule.action, rule.source))
        .collect();
    found.sort();
    found.dedup();

    let mut expected: Vec<String> = CLAIMED_BUT_ADVERTISED_AS_NONE
        .iter()
        .map(|s| (*s).to_string())
        .collect();
    expected.sort();

    assert_eq!(
        found, expected,
        "the set of actions that are dispatchable but advertised as having no \
         default changed. This is under-advertisement, not a dispatch defect — \
         update the list deliberately."
    );
}

#[test]
fn chord_parser_rejects_what_it_does_not_understand() {
    assert!(claims::parse_chord("Cmd+Nonsense").is_err());
    assert!(claims::parse_chord("Cmd+").is_err());
    assert!(claims::parse_chord("Cmd+A+B").is_err());
    assert!(claims::parse_chord("Shift").is_err());

    let plus = claims::parse_chord("Cmd+Plus").expect("Plus is part of the vocabulary");
    let equal = claims::parse_chord("Cmd+Equal").expect("Equal is part of the vocabulary");
    assert_ne!(
        plus, equal,
        "`Plus` and `Equal` are different logical keys and must not collapse"
    );
}

/// `CmdOrCtrl` must resolve the way the keybinding matcher resolves it.
#[test]
fn cmd_or_ctrl_expands_per_platform() {
    let c = claims::parse_chord("CmdOrCtrl+Shift+B").expect("parses");
    if Platform::HOST.is_mac() {
        assert!(c.mods.sup && !c.mods.ctrl, "CmdOrCtrl is Cmd on macOS");
    } else {
        assert!(c.mods.ctrl && !c.mods.sup, "CmdOrCtrl is Ctrl elsewhere");
    }
    assert!(c.mods.shift);
}

/// B63: a stray key in a tab whose panes all exited closes that tab (or the
/// window when it is the last tab) — it must never quit the app outright.
#[test]
fn exited_tab_keypress_closes_the_tab_not_the_app() {
    use super::{ExitedTabKeypress, exited_tab_keypress_action};

    assert_eq!(
        exited_tab_keypress_action(false, 3),
        ExitedTabKeypress::CloseTab,
        "other tabs stay open — the dead tab alone closes"
    );
    assert_eq!(
        exited_tab_keypress_action(false, 1),
        ExitedTabKeypress::CloseWindow,
        "last tab closes the window, the shell_exit Close path's own rule"
    );
    assert_eq!(
        exited_tab_keypress_action(true, 3),
        ExitedTabKeypress::Keep,
        "a pending restart prompt owns the key — Enter must reach its handler"
    );
    assert_eq!(
        exited_tab_keypress_action(true, 1),
        ExitedTabKeypress::Keep,
        "restart pending outranks even the last-tab window close"
    );
}

/// B63 regression pin: the all-exited branch must route through
/// `exited_tab_keypress_action`, and the one `event_loop.exit()` in
/// handle_key_event may exist only inside the last-tab window-close arm.
/// The old code exited the loop unconditionally on any key — quitting the
/// whole app (all windows) on a stray keypress.
#[test]
fn stray_keypress_cannot_exit_the_event_loop_outside_the_last_tab_arm() {
    // CRLF checkouts (Windows) shift every byte position below by one per
    // line; normalize once so the math matches the LF join it assumes.
    let source = include_str!("mod.rs").replace("\r\n", "\n");

    // Code lines only — this test's own comments name the call.
    let exit_lines: Vec<usize> = source
        .lines()
        .enumerate()
        .filter(|(_, line)| {
            let trimmed = line.trim_start();
            trimmed.contains("event_loop.exit()") && !trimmed.starts_with("//")
        })
        .map(|(i, _)| i)
        .collect();
    assert_eq!(
        exit_lines.len(),
        1,
        "handle_key_event must have exactly one event_loop.exit(), and only \
         in the ExitedTabKeypress::CloseWindow arm"
    );
    let exit_pos = source
        .lines()
        .take(exit_lines[0] + 1)
        .map(|l| l.len() + 1)
        .sum::<usize>()
        .saturating_sub(1);
    let window_arm = source.find("ExitedTabKeypress::CloseWindow").expect(
        "the exited-tab branch must dispatch through ExitedTabKeypress, not \
         call the event loop directly",
    );
    assert!(
        window_arm < exit_pos,
        "the exit call must live inside the CloseWindow arm"
    );
    assert!(
        source[..exit_pos].contains("is_shutting_down = true"),
        "window close must go through the is_shutting_down path \
         handle_shell_exit uses, not a bare loop exit"
    );
}

#[test]
fn unconsumed_keys_never_reach_the_pty_tail_while_a_modal_is_open() {
    // B61 central consumption: overlay-stack routing stops every key a
    // dialog does not use before terminal dispatch (the live proof is
    // overlay::routing_live_tests). handle_key_event keeps a backstop
    // check ahead of the PTY encoding tail, or an Escape a layer passed
    // along would write `1b` to the shell (UX.md RT22: observed leaking
    // from quit, tmux picker, remote install, SSH connect).
    let source = include_str!("mod.rs");
    let tail_guard_pos = source
        .rfind("if self.any_modal_ui_visible()")
        .expect("the modal-guard tail consumption is missing from handle_key_event");
    let encode_pos = source
        .rfind("handle_key_event_with_mode(")
        .expect("handle_key_event must keep its PTY encoding call");
    assert!(
        tail_guard_pos < encode_pos,
        "the tail consumption must precede the PTY encoding call"
    );
}