rmux-core 0.10.0

Core session, pane, layout, format, hook, and buffer model for the RMUX terminal multiplexer.
Documentation
use super::{
    key_code_lookup_bits, key_code_to_bytes, key_string_lookup_key, key_string_lookup_string,
    parse_binding_command_tokens, KeyBindingSortOrder, KeyBindingStore, KEYC_ANY, KEYC_CTRL,
    KEYC_META, KEYC_SHIFT,
};

#[test]
fn key_lookup_round_trips_named_keys_and_modifiers() {
    let key = key_string_lookup_string("C-M-Left").expect("key parses");
    assert_eq!(key_string_lookup_key(key, false), "C-M-Left");
    assert_eq!(
        key_string_lookup_key(key_code_lookup_bits(key), false),
        "C-M-Left"
    );
}

#[test]
fn key_lookup_accepts_hex_and_user_keys() {
    assert_eq!(key_string_lookup_string("0x41"), Some(b'A' as u64));
    assert_eq!(key_string_lookup_string("Any"), Some(KEYC_ANY));
    assert_eq!(
        key_string_lookup_string("user42"),
        key_string_lookup_string("User42")
    );
    assert_eq!(
        key_string_lookup_key(key_string_lookup_string("User42").expect("user key"), false),
        "User42"
    );
}

#[test]
fn key_lookup_accepts_mouse_keys() {
    let key = key_string_lookup_string("MouseDown1Pane").expect("mouse key parses");
    assert_eq!(key_string_lookup_key(key, false), "MouseDown1Pane");
}

#[test]
fn key_lookup_accepts_short_ctrl_notation() {
    let key = key_string_lookup_string("^B").expect("short ctrl parses");
    assert_eq!(key, (b'b' as u64) | KEYC_CTRL);
}

#[test]
fn key_lookup_canonicalizes_ctrl_bracket_as_escape() {
    for input in ["C-[", "^["] {
        let key = key_string_lookup_string(input).expect("ctrl bracket parses");
        assert_eq!(key_string_lookup_key(key, false), "Escape");
    }
}

#[test]
fn single_string_binding_rejects_unknown_percent_directives() {
    let error = parse_binding_command_tokens(&["display-message -p %foo".to_owned()])
        .expect_err("unknown percent directive should fail like tmux");

    assert_eq!(error.to_string(), "syntax error");
}

#[test]
fn key_code_to_bytes_encodes_ascii_control_and_utf8() {
    assert_eq!(
        key_code_to_bytes(key_string_lookup_string("Enter").unwrap()),
        Some(vec![13])
    );
    assert_eq!(
        key_code_to_bytes(key_string_lookup_string("S-Enter").unwrap()),
        Some(vec![10])
    );
    assert_eq!(
        key_code_to_bytes(key_string_lookup_string("C-c").unwrap()),
        Some(vec![3])
    );
    let utf8 = key_code_to_bytes(key_string_lookup_string("é").unwrap()).expect("utf8 bytes");
    assert_eq!(String::from_utf8(utf8).unwrap(), "é");
}

#[test]
fn default_store_loads_prefix_root_and_copy_tables() {
    let store = KeyBindingStore::default();
    assert!(store.table("prefix").is_some());
    assert!(store.table("root").is_some());
    assert!(store.table("copy-mode").is_some());
    assert!(store.table("copy-mode-vi").is_some());
}

#[test]
fn default_store_keeps_prefix_meta_layout_bindings() {
    let store = KeyBindingStore::default();
    for (key, expected) in [
        ("M-1", "select-layout even-horizontal"),
        ("M-2", "select-layout even-vertical"),
        ("M-3", "select-layout main-horizontal"),
        ("M-4", "select-layout main-vertical"),
        ("M-5", "select-layout tiled"),
        ("M-6", "select-layout main-horizontal-mirrored"),
        ("M-7", "select-layout main-vertical-mirrored"),
    ] {
        let binding = store
            .get_binding("prefix", key_string_lookup_string(key).expect("key parses"))
            .unwrap_or_else(|| panic!("missing default prefix binding for {key}"));
        assert_eq!(binding.commands().to_tmux_string(), expected);
    }
}

#[test]
fn default_store_does_not_bind_unimplemented_refresh_client_panning() {
    let store = KeyBindingStore::default();
    for key in ["DC", "S-Up", "S-Down", "S-Left", "S-Right"] {
        assert!(
            store
                .get_binding("prefix", key_string_lookup_string(key).expect("key parses"))
                .is_none(),
            "default prefix table advertised unsupported refresh-client panning on {key}"
        );
    }
}

#[test]
fn default_store_matches_tmux37_lot6_root_and_copy_bindings() {
    let store = KeyBindingStore::default();
    for (table, key, expected) in [
        ("root", "MouseDown1Status", "switch-client -t="),
        (
            "root",
            "WheelUpPane",
            r##"if-shell -F "#{||:#{alternate_on},#{pane_in_mode},#{mouse_any_flag}}" { send-keys -M } { copy-mode -e }"##,
        ),
        ("root", "C-MouseDown1Pane", "swap-pane -s @"),
        ("root", "C-MouseDown1Status", "swap-window -t @"),
        (
            "root",
            "MouseDown1ScrollbarUp",
            r##"if-shell -F -t= "#{pane_in_mode}" { send-keys -X page-up } { copy-mode -u }"##,
        ),
        (
            "root",
            "MouseDown1ScrollbarDown",
            r##"if-shell -F -t= "#{pane_in_mode}" { send-keys -X page-down } { copy-mode -d }"##,
        ),
        (
            "root",
            "MouseDrag1ScrollbarSlider",
            r##"if-shell -F -t= "#{pane_in_mode}" { send-keys -X scroll-to-mouse } { copy-mode -S }"##,
        ),
        ("root", "MouseDown1Control8", "resize-pane -Z"),
        ("root", "MouseDown1Border", "select-pane -M"),
        (
            "root",
            "MouseDown1Control9",
            r#"display-menu -O -T "Kill pane #{pane_index}?" -t= -xM -yM Yes y { kill-pane -t= } No n {  }"#,
        ),
        ("copy-mode", "C-l", "send-keys -X recentre-top-bottom"),
        ("copy-mode", "M-l", "send-keys -X cursor-centre-horizontal"),
        ("copy-mode", "C-[", "send-keys -X cancel"),
        ("copy-mode-vi", "C-[", "send-keys -X clear-selection"),
    ] {
        let binding = store
            .list_bindings(Some(table), KeyBindingSortOrder::Key, false)
            .into_iter()
            .find(|binding| binding.key_string() == key)
            .unwrap_or_else(|| panic!("missing default {table} binding for {key}"));
        assert_eq!(
            binding.binding().commands().to_tmux_string(),
            expected,
            "{table} {key}"
        );
    }
}

#[test]
fn default_store_matches_tmux37_status_and_pane_menu_rows() {
    let store = KeyBindingStore::default();
    for (table, key, expected) in [
        (
            "root",
            "MouseDown3StatusLeft",
            r##"display-menu -t= -xM -yW -T "#[align=centre]#{session_name}" Next n { switch-client -n } Previous p { switch-client -p } '' Renumber N { move-window -r } Rename r { command-prompt -I "#S" { rename-session -- "%%" } } Detach d { detach-client } '' "New Session" s { new-session } "New Window" w { new-window }"##,
        ),
        (
            "root",
            "M-MouseDown3StatusLeft",
            r##"display-menu -t= -xM -yW -T "#[align=centre]#{session_name}" Next n { switch-client -n } Previous p { switch-client -p } '' Renumber N { move-window -r } Rename r { command-prompt -I "#S" { rename-session -- "%%" } } Detach d { detach-client } '' "New Session" s { new-session } "New Window" w { new-window }"##,
        ),
        (
            "prefix",
            ">",
            r##"display-menu -xP -yP -T "#[align=centre]#{pane_index} (#{pane_id})" "#{?#{m/r:(copy|view)-mode,#{pane_mode}},Go To Top,}" < { send-keys -X history-top } "#{?#{m/r:(copy|view)-mode,#{pane_mode}},Go To Bottom,}" > { send-keys -X history-bottom } '' "#{?#{&&:#{buffer_size},#{!:#{pane_in_mode}}},Paste #[underscore]#{=/9/...:buffer_sample},}" p { paste-buffer } '' "#{?mouse_word,Search For #[underscore]#{=/9/...:mouse_word},}" C-r { if-shell -F "#{?#{m/r:(copy|view)-mode,#{pane_mode}},0,1}" "copy-mode -t=" ; send-keys -Xt= search-backward -- "#{q:mouse_word}" } "#{?mouse_word,Type #[underscore]#{=/9/...:mouse_word},}" C-y { copy-mode -q ; send-keys -l -- "#{q:mouse_word}" } "#{?mouse_word,Copy #[underscore]#{=/9/...:mouse_word},}" c { copy-mode -q ; set-buffer -- "#{q:mouse_word}" } "#{?mouse_line,Copy Line,}" l { copy-mode -q ; set-buffer -- "#{q:mouse_line}" } '' "#{?mouse_hyperlink,Type #[underscore]#{=/9/...:mouse_hyperlink},}" C-h { copy-mode -q ; send-keys -l -- "#{q:mouse_hyperlink}" } "#{?mouse_hyperlink,Copy #[underscore]#{=/9/...:mouse_hyperlink},}" h { copy-mode -q ; set-buffer -- "#{q:mouse_hyperlink}" } '' "Horizontal Split" h { split-window -h } "Vertical Split" v { split-window -v } '' "#{?#{>:#{window_panes},1},,-}Swap Up" u { swap-pane -U } "#{?#{>:#{window_panes},1},,-}Swap Down" d { swap-pane -D } "#{?pane_marked_set,,-}Swap Marked" s { swap-pane } '' Kill X { kill-pane } Respawn R { respawn-pane -k } "#{?pane_marked,Unmark,Mark}" m { select-pane -m } "#{?#{>:#{window_panes},1},,-}#{?window_zoomed_flag,Unzoom,Zoom}" z { resize-pane -Z }"##,
        ),
        (
            "root",
            "MouseDown3Pane",
            r##"if-shell -Ft= "#{||:#{mouse_any_flag},#{&&:#{pane_in_mode},#{?#{m/r:(copy|view)-mode,#{pane_mode}},0,1}}}" { select-pane -t= ; send-keys -M } { display-menu -t= -xM -yM -T "#[align=centre]#{pane_index} (#{pane_id})" "#{?#{m/r:(copy|view)-mode,#{pane_mode}},Go To Top,}" < { send-keys -X history-top } "#{?#{m/r:(copy|view)-mode,#{pane_mode}},Go To Bottom,}" > { send-keys -X history-bottom } '' "#{?#{&&:#{buffer_size},#{!:#{pane_in_mode}}},Paste #[underscore]#{=/9/...:buffer_sample},}" p { paste-buffer } '' "#{?mouse_word,Search For #[underscore]#{=/9/...:mouse_word},}" C-r { if-shell -F "#{?#{m/r:(copy|view)-mode,#{pane_mode}},0,1}" "copy-mode -t=" ; send-keys -Xt= search-backward -- "#{q:mouse_word}" } "#{?mouse_word,Type #[underscore]#{=/9/...:mouse_word},}" C-y { copy-mode -q ; send-keys -l -- "#{q:mouse_word}" } "#{?mouse_word,Copy #[underscore]#{=/9/...:mouse_word},}" c { copy-mode -q ; set-buffer -- "#{q:mouse_word}" } "#{?mouse_line,Copy Line,}" l { copy-mode -q ; set-buffer -- "#{q:mouse_line}" } '' "#{?mouse_hyperlink,Type #[underscore]#{=/9/...:mouse_hyperlink},}" C-h { copy-mode -q ; send-keys -l -- "#{q:mouse_hyperlink}" } "#{?mouse_hyperlink,Copy #[underscore]#{=/9/...:mouse_hyperlink},}" h { copy-mode -q ; set-buffer -- "#{q:mouse_hyperlink}" } '' "Horizontal Split" h { split-window -h } "Vertical Split" v { split-window -v } '' "#{?#{>:#{window_panes},1},,-}Swap Up" u { swap-pane -U } "#{?#{>:#{window_panes},1},,-}Swap Down" d { swap-pane -D } "#{?pane_marked_set,,-}Swap Marked" s { swap-pane } '' Kill X { kill-pane } Respawn R { respawn-pane -k } "#{?pane_marked,Unmark,Mark}" m { select-pane -m } "#{?#{>:#{window_panes},1},,-}#{?window_zoomed_flag,Unzoom,Zoom}" z { resize-pane -Z } }"##,
        ),
    ] {
        let binding = store
            .list_bindings(Some(table), KeyBindingSortOrder::Key, false)
            .into_iter()
            .find(|binding| binding.key_string() == key)
            .unwrap_or_else(|| panic!("missing default {table} binding for {key}"));
        assert_eq!(
            binding.binding().commands().to_tmux_string(),
            expected,
            "{table} {key}"
        );
    }
}

#[test]
fn default_key_string_format_exposes_unescaped_key_names() {
    let store = KeyBindingStore::default();
    let prefix_bindings = store.list_bindings(Some("prefix"), KeyBindingSortOrder::Key, false);

    for key in ["\"", "#", "$", "'", ";"] {
        assert!(
            prefix_bindings
                .iter()
                .any(|binding| binding.key_string() == key),
            "missing unescaped prefix key string {key:?}"
        );
    }
    assert!(
        !prefix_bindings
            .iter()
            .any(|binding| matches!(binding.key_string(), "\\\"" | "\\#" | "\\$" | "\\'" | "\\;")),
        "default key_string values must not expose command-line escaping"
    );
}

#[test]
fn reset_restores_defaults_from_snapshot() {
    let mut store = KeyBindingStore::default();
    let original = store
        .get_binding("prefix", key_string_lookup_string("C-b").unwrap())
        .expect("default binding")
        .commands()
        .to_tmux_string();
    let new_commands =
        parse_binding_command_tokens(&["display-message changed".to_owned()]).unwrap();
    store.add_binding(
        "prefix",
        key_string_lookup_string("C-b").unwrap(),
        None,
        false,
        Some(new_commands),
    );
    assert_ne!(
        store
            .get_binding("prefix", key_string_lookup_string("C-b").unwrap())
            .unwrap()
            .commands()
            .to_tmux_string(),
        original
    );
    store.reset_binding("prefix", key_string_lookup_string("C-b").unwrap());
    assert_eq!(
        store
            .get_binding("prefix", key_string_lookup_string("C-b").unwrap())
            .unwrap()
            .commands()
            .to_tmux_string(),
        original
    );
}

#[test]
fn reset_restores_removed_defaults_from_snapshot() {
    let mut store = KeyBindingStore::default();
    let key = key_string_lookup_string("C-b").unwrap();
    assert!(store.remove_binding("prefix", key));
    assert!(store.get_binding("prefix", key).is_none());

    store.reset_binding("prefix", key);

    assert!(store.get_binding("prefix", key).is_some());
}

#[test]
fn listed_bindings_escape_command_separators_for_resourcing() {
    let mut store = KeyBindingStore::default();
    let key = key_string_lookup_string("Y").expect("key parses");
    let commands =
        parse_binding_command_tokens(
            &[r#"run-shell "echo one" ; run-shell "echo two""#.to_owned()],
        )
        .expect("binding command parses");

    store.add_binding("prefix", key, None, false, Some(commands));
    let bindings = store.list_bindings(Some("prefix"), KeyBindingSortOrder::default(), false);
    let binding = bindings
        .iter()
        .find(|binding| binding.key_string() == "Y")
        .expect("binding is listed");

    assert_eq!(
        binding.command_string(),
        r#"run-shell "echo one" \; run-shell "echo two""#
    );
}

#[test]
fn remove_table_clears_active_bindings_but_preserves_default_snapshot() {
    let mut store = KeyBindingStore::default();
    assert!(store.remove_table("prefix"));

    let table = store.table("prefix").expect("default table should persist");
    assert!(table.active().is_empty());
    assert!(!table.defaults().is_empty());
}

#[test]
fn binding_updates_do_not_leak_table_references() {
    let mut store = KeyBindingStore::new();
    assert!(store.add_binding(
        "scratch",
        key_string_lookup_string("C-a").unwrap(),
        None,
        false,
        Some(parse_binding_command_tokens(&["display-message test".to_owned()]).unwrap()),
    ));
    let table = store.table("scratch").expect("table created");
    assert_eq!(table.references(), 0);
}

#[test]
fn list_bindings_sorts_and_widths() {
    let store = KeyBindingStore::default();
    let mut bindings = store.list_bindings(Some("prefix"), KeyBindingSortOrder::Key, false);
    assert!(!bindings.is_empty());
    let first = bindings.remove(0);
    assert!(!first.key_string().is_empty());
    assert!(!first.command_string().is_empty());
    assert!(
        KeyBindingStore::key_string_width(&store.list_bindings(
            Some("prefix"),
            KeyBindingSortOrder::Key,
            false
        )) > 0
    );
}

#[test]
fn list_bindings_key_sort_handles_custom_modified_keys() {
    let mut store = KeyBindingStore::default();
    let command = parse_binding_command_tokens(&["display-message custom".to_owned()]).unwrap();
    for key in ["C-/", "M-a"] {
        let parsed = key_string_lookup_string(key).expect("key parses");
        assert!(store.add_binding("prefix", parsed, None, false, Some(command.clone())));
    }

    let bindings = store.list_bindings(None, KeyBindingSortOrder::Key, false);
    assert!(bindings.iter().any(|binding| binding.key_string() == "C-/"));
    assert!(bindings.iter().any(|binding| binding.key_string() == "M-a"));
}

#[test]
fn modifiers_are_case_insensitive() {
    let key = key_string_lookup_string("c-m-s-a").expect("modifiers parse");
    assert_eq!(key_string_lookup_key(key, false), format!("C-M-S-{}", 'a'));
    assert_eq!(
        key & (KEYC_CTRL | KEYC_META | KEYC_SHIFT),
        KEYC_CTRL | KEYC_META | KEYC_SHIFT
    );
}