Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions default_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -263,9 +263,13 @@ Esc = { EnterMode = "Normal" }
[keys.normal."Ctrl-w"]
"a" = { PluginCommand = "AgentOpen" }
"h" = "MoveWindowLeft"
"H" = "MoveWindowToLeft"
"j" = "MoveWindowDown"
"J" = "MoveWindowToBottom"
"k" = "MoveWindowUp"
"K" = "MoveWindowToTop"
"l" = "MoveWindowRight"
"L" = "MoveWindowToRight"
"w" = "NextWindow"
"W" = "PreviousWindow"
"p" = "PreviousWindow"
Expand Down
4 changes: 3 additions & 1 deletion docs/GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,9 @@ The command palette includes descriptions, effective keymaps, and accepted
## Windows and buffers

- `Ctrl-w s` and `Ctrl-w v` split horizontally and vertically.
- `Ctrl-w h/j/k/l` move between windows.
- `Ctrl-w h/j/k/l` move focus between windows.
- `Ctrl-w H/J/K/L` move the current window to the left, bottom, top, or right
outer edge.
- `Ctrl-w w` selects the next window.
- `Ctrl-w c` closes a window.
- `Ctrl-w =`, `Ctrl-w _`, and `Ctrl-w o` balance, maximize, or keep only the
Expand Down
2 changes: 1 addition & 1 deletion docs/VIM_COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ the corresponding integration tests.
| Unicode graphemes | **supported** | Cursoring, replacement, selection, paste, undo, and marks are tested with multi-codepoint graphemes. Rust-regex offsets are converted to character coordinates before editing. |
| Empty buffers | **supported** | The synthetic editable line remains cursor-safe across insert, delete, render, and undo. |
| Final line / trailing newline | **supported** | Both forms render and edit without exposing a phantom gutter line. |
| Multi-window | **supported** | Active-buffer cursor, viewport, wrapping, gutter width, and focus-cycle state are window-aware. |
| Multi-window | **supported** | Active-buffer cursor, viewport, wrapping, gutter width, and focus-cycle state are window-aware; `Ctrl-w H/J/K/L` move the active window to the corresponding outer edge. |
| Multi-window Vim window command parity | **intentional difference** | Red supports its published `Ctrl-w` subset; arbitrary Vim layouts and every resizing command are not promised. |

## Release gate
Expand Down
119 changes: 119 additions & 0 deletions src/command_palette.rs
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,42 @@ fn builtin_commands() -> Vec<BuiltinCommand> {
&[],
Action::MoveWindowRight,
),
builtin(
"window.move_to_left",
"Move window to left edge",
"Window",
"Move the current split to the full-height left edge",
None,
&[],
Action::MoveWindowToLeft,
),
builtin(
"window.move_to_bottom",
"Move window to bottom edge",
"Window",
"Move the current split to the full-width bottom edge",
None,
&[],
Action::MoveWindowToBottom,
),
builtin(
"window.move_to_top",
"Move window to top edge",
"Window",
"Move the current split to the full-width top edge",
None,
&[],
Action::MoveWindowToTop,
),
builtin(
"window.move_to_right",
"Move window to right edge",
"Window",
"Move the current split to the full-height right edge",
None,
&[],
Action::MoveWindowToRight,
),
builtin(
"window.balance",
"Balance windows",
Expand Down Expand Up @@ -945,6 +981,10 @@ fn action_label(action: &Action) -> String {
Action::MoveWindowDown => "Focus window below".to_string(),
Action::MoveWindowUp => "Focus window above".to_string(),
Action::MoveWindowRight => "Focus window right".to_string(),
Action::MoveWindowToLeft => "Move window to left edge".to_string(),
Action::MoveWindowToBottom => "Move window to bottom edge".to_string(),
Action::MoveWindowToTop => "Move window to top edge".to_string(),
Action::MoveWindowToRight => "Move window to right edge".to_string(),
Action::ViewLogs => "View logs".to_string(),
Action::ListPlugins => "List plugins".to_string(),
Action::DumpBuffer => "Dump buffer".to_string(),
Expand Down Expand Up @@ -1071,6 +1111,61 @@ mod tests {
assert!(save.aliases.iter().any(|alias| alias == ":write"));
}

#[test]
fn palette_distinguishes_directional_window_focus_from_edge_movement() {
let entries = entries(&default_keys(), &[]);

for (move_id, title, shortcut, action, focus_id, focus_shortcut) in [
(
"window.move_to_left",
"Move window to left edge",
"Ctrl-w H",
Action::MoveWindowToLeft,
"window.left",
"Ctrl-w h",
),
(
"window.move_to_bottom",
"Move window to bottom edge",
"Ctrl-w J",
Action::MoveWindowToBottom,
"window.down",
"Ctrl-w j",
),
(
"window.move_to_top",
"Move window to top edge",
"Ctrl-w K",
Action::MoveWindowToTop,
"window.up",
"Ctrl-w k",
),
(
"window.move_to_right",
"Move window to right edge",
"Ctrl-w L",
Action::MoveWindowToRight,
"window.right",
"Ctrl-w l",
),
] {
let movement = entries
.iter()
.find(|entry| entry.id == move_id)
.expect("window edge movement should appear in the command palette");
assert_eq!(movement.category, "Window");
assert_eq!(movement.title, title);
assert_eq!(movement.action, action);
assert!(movement.shortcuts.iter().any(|value| value == shortcut));

let focus = entries
.iter()
.find(|entry| entry.id == focus_id)
.expect("directional window focus should remain in the command palette");
assert!(focus.shortcuts.iter().any(|value| value == focus_shortcut));
}
}

#[test]
fn palette_lists_commenting_as_a_discoverable_edit_action() {
let entries = entries(&default_keys(), &[]);
Expand Down Expand Up @@ -1264,6 +1359,30 @@ mod tests {
.any(|hint| hint.key == "a" && hint.label == "Select all"));
}

#[test]
fn window_keymap_hints_distinguish_focus_from_edge_movement() {
let keys = default_keys();
let Some(KeyAction::Nested(window_keys)) = keys.normal.get("Ctrl-w") else {
panic!("expected the window management keymap");
};

let hints = keymap_hints(&["Ctrl-w".to_string()], window_keys);
for (key, label) in [
("h", "Focus window left"),
("H", "Move window to left edge"),
("j", "Focus window below"),
("J", "Move window to bottom edge"),
("k", "Focus window above"),
("K", "Move window to top edge"),
("l", "Focus window right"),
("L", "Move window to right edge"),
] {
assert!(hints
.iter()
.any(|hint| hint.key == key && hint.label == label && !hint.is_group));
}
}

#[test]
fn humanizes_camel_case_plugin_names() {
assert_eq!(humanize_identifier("ProjectSearch"), "Project search");
Expand Down
35 changes: 35 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1997,6 +1997,28 @@ mod test {
.all(|plugin| !loaded.config.plugins.contains_key(*plugin)));
}

#[test]
fn legacy_window_keymap_preserves_focus_and_inherits_edge_movement() {
let loaded =
Config::load_user_toml(LEGACY_CONFIG, Path::new("/tmp/config.toml"), &[]).unwrap();
let Some(KeyAction::Nested(ctrl_w)) = loaded.config.keys.normal.get("Ctrl-w") else {
panic!("legacy window bindings should remain a keymap prefix");
};

for (key, action) in [
("h", Action::MoveWindowLeft),
("j", Action::MoveWindowDown),
("k", Action::MoveWindowUp),
("l", Action::MoveWindowRight),
("H", Action::MoveWindowToLeft),
("J", Action::MoveWindowToBottom),
("K", Action::MoveWindowToTop),
("L", Action::MoveWindowToRight),
] {
assert_eq!(ctrl_w.get(key), Some(&KeyAction::Single(action)));
}
}

#[test]
fn independent_invalid_values_do_not_hide_valid_siblings() {
let loaded = Config::load_user_toml(
Expand Down Expand Up @@ -2721,6 +2743,19 @@ groups = [["\\bif\\b", "\\belse\\b", "\\bendif\\b"]]
panic!("default config should map Ctrl-w to window management actions");
};

for (key, action) in [
("h", Action::MoveWindowLeft),
("j", Action::MoveWindowDown),
("k", Action::MoveWindowUp),
("l", Action::MoveWindowRight),
("H", Action::MoveWindowToLeft),
("J", Action::MoveWindowToBottom),
("K", Action::MoveWindowToTop),
("L", Action::MoveWindowToRight),
] {
assert_eq!(ctrl_w.get(key), Some(&KeyAction::Single(action)));
}

assert_eq!(
ctrl_w.get("s"),
Some(&KeyAction::Single(Action::SplitHorizontal))
Expand Down
Loading
Loading