diff --git a/default_config.toml b/default_config.toml index de162904..6560d460 100644 --- a/default_config.toml +++ b/default_config.toml @@ -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" diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index e4238a9f..91d36278 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -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 diff --git a/docs/VIM_COMPATIBILITY.md b/docs/VIM_COMPATIBILITY.md index 3b8daa2c..6d8fa2fe 100644 --- a/docs/VIM_COMPATIBILITY.md +++ b/docs/VIM_COMPATIBILITY.md @@ -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 diff --git a/src/command_palette.rs b/src/command_palette.rs index 307c6a2d..3f19f449 100644 --- a/src/command_palette.rs +++ b/src/command_palette.rs @@ -650,6 +650,42 @@ fn builtin_commands() -> Vec { &[], 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", @@ -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(), @@ -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(), &[]); @@ -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"); diff --git a/src/config.rs b/src/config.rs index 3f6d987f..813c0761 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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( @@ -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)) diff --git a/src/editor.rs b/src/editor.rs index 6c820ccb..e6950cb1 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1455,6 +1455,10 @@ pub enum Action { MoveWindowDown, MoveWindowLeft, MoveWindowRight, + MoveWindowToLeft, + MoveWindowToBottom, + MoveWindowToTop, + MoveWindowToRight, ResizeWindowUp(usize), ResizeWindowDown(usize), ResizeWindowLeft(usize), @@ -2422,6 +2426,13 @@ struct SearchMatchCache { matches: Arc<[SearchMatch]>, } +#[derive(Debug, Clone, PartialEq)] +struct WindowLayoutEventSnapshot { + id: WindowId, + position: Point, + size: (usize, usize), +} + #[derive(Debug, Clone)] struct EditorEventSnapshot { mode: Mode, @@ -2435,7 +2446,7 @@ struct EditorEventSnapshot { height: usize, buffer_index: usize, window_id: Option, - window_ids: Vec, + windows: Vec, } #[derive(Debug, Clone)] @@ -4642,11 +4653,15 @@ impl Editor { height, buffer_index: self.buffer_manager.active_index(), window_id: self.window_manager.active_stable_window_id(), - window_ids: self + windows: self .window_manager .windows() .into_iter() - .map(|window| window.id) + .map(|window| WindowLayoutEventSnapshot { + id: window.id, + position: window.position, + size: window.size, + }) .collect(), } } @@ -4674,7 +4689,7 @@ impl Editor { cause: &str, ) -> anyhow::Result<()> { let after = self.event_snapshot(); - perf::gauge_max("plugin_window_count", after.window_ids.len() as u64); + perf::gauge_max("plugin_window_count", after.windows.len() as u64); let cursor_changed = before.cx != after.cx || before.y != after.y || before.vtop != after.vtop @@ -4686,11 +4701,11 @@ impl Editor { || before.width != after.width || before.height != after.height || before.buffer_index != after.buffer_index; - let windows_changed = before.window_ids != after.window_ids + let layout_changed = before.windows != after.windows || before.window_id != after.window_id - || before.buffer_index != after.buffer_index || before.width != after.width || before.height != after.height; + let windows_changed = layout_changed || before.buffer_index != after.buffer_index; self.refresh_plugin_snapshots( runtime, cursor_changed || viewport_changed, @@ -4698,11 +4713,15 @@ impl Editor { false, )?; - let current_window_ids = after.window_ids.iter().copied().collect::>(); + let current_window_ids = after + .windows + .iter() + .map(|window| window.id) + .collect::>(); for window_id in before - .window_ids + .windows .iter() - .copied() + .map(|window| window.id) .filter(|window_id| !current_window_ids.contains(window_id)) { self.window_bar_manager.close_window(window_id); @@ -4732,11 +4751,7 @@ impl Editor { .await?; } - if before.window_ids != after.window_ids - || before.window_id != after.window_id - || before.width != after.width - || before.height != after.height - { + if layout_changed { let mut payload = self.plugin_windows_payload(); if let Some(object) = payload.as_object_mut() { object.insert("cause".to_string(), json!(cause)); @@ -15199,6 +15214,34 @@ impl Editor { self.move_window_in_direction(crate::window::Direction::Right, buffer) .await?; } + Action::MoveWindowToLeft => { + if self.update_window_layout(|windows| { + windows.move_window_to_edge(crate::window::Direction::Left) + }) { + self.render(buffer)?; + } + } + Action::MoveWindowToBottom => { + if self.update_window_layout(|windows| { + windows.move_window_to_edge(crate::window::Direction::Down) + }) { + self.render(buffer)?; + } + } + Action::MoveWindowToTop => { + if self.update_window_layout(|windows| { + windows.move_window_to_edge(crate::window::Direction::Up) + }) { + self.render(buffer)?; + } + } + Action::MoveWindowToRight => { + if self.update_window_layout(|windows| { + windows.move_window_to_edge(crate::window::Direction::Right) + }) { + self.render(buffer)?; + } + } Action::ResizeWindowUp(amount) => { if self.update_window_layout(|windows| { windows.resize_window(crate::window::Direction::Up, *amount) @@ -22617,6 +22660,44 @@ mod test { drain_plugin_requests(); } + async fn install_window_event_recorder(editor: &mut Editor, runtime: &mut Runtime) { + drain_plugin_requests(); + let plugin_path = std::env::temp_dir().join(format!( + "red-window-event-recorder-{}.hk", + uuid::Uuid::new_v4() + )); + std::fs::write( + &plugin_path, + r#" + pub fn activate() { + red::on("window:layout_changed", layout_changed); + red::on("window:closed", window_closed); + red::on("window:focused", window_focused); + } + + fn layout_changed(event: Json) { + red::execute("Print", "window:layout_changed"); + } + + fn window_closed(event: Json) { + red::execute("Print", "window:closed"); + } + + fn window_focused(event: Json) { + red::execute("Print", "window:focused"); + } + "#, + ) + .unwrap(); + + editor.plugin_registry.add( + "window_event_recorder", + plugin_path.to_string_lossy().as_ref(), + ); + editor.plugin_registry.initialize(runtime).await.unwrap(); + drain_plugin_requests(); + } + async fn install_theme_probe(editor: &mut Editor, runtime: &mut Runtime) { drain_plugin_requests(); let plugin_path = @@ -27492,6 +27573,68 @@ while True: assert_eq!(editor.render_cursor_position().map(|(_, y)| y), Some(1)); } + #[tokio::test] + async fn moving_window_to_edge_refreshes_layout_without_closing_or_refocusing_it() { + let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; + let mut editor = test_editor(80, 24); + editor.window_manager.split_vertical(0).unwrap(); + editor.window_manager.set_active(0); + editor.window_manager.split_vertical(0).unwrap(); + editor.window_manager.set_active(0); + + let mut layout = editor.window_manager.snapshot(); + let crate::window::SplitSnapshot::Vertical { left, ratio, .. } = &mut layout.root else { + panic!("expected a vertical outer split"); + }; + *ratio = 0.75; + let crate::window::SplitSnapshot::Vertical { ratio, .. } = left.as_mut() else { + panic!("expected a nested vertical split"); + }; + *ratio = 0.675; + editor.window_manager = + WindowManager::from_snapshot(&layout, (80, 24), &HashMap::from([(0, 0)])).unwrap(); + editor.sync_with_window(); + install_test_window_bar(&mut editor); + + let mut runtime = Runtime::new(); + install_window_event_recorder(&mut editor, &mut runtime).await; + let before = editor.event_snapshot(); + let before_ids = before + .windows + .iter() + .map(|window| window.id) + .collect::>(); + let mut render_buffer = RenderBuffer::new(80, 24, &Style::default()); + + editor + .execute(&Action::MoveWindowToLeft, &mut render_buffer, &mut runtime) + .await + .unwrap(); + + let after = editor.event_snapshot(); + let after_ids = after + .windows + .iter() + .map(|window| window.id) + .collect::>(); + + assert_eq!(after.window_id, before.window_id); + assert_eq!(after.width, before.width); + assert_eq!(after.height, before.height); + assert_eq!(after_ids, before_ids); + assert_ne!(after.windows, before.windows); + assert_eq!( + collect_print_requests(), + vec!["window:layout_changed".to_string()] + ); + assert_eq!( + editor + .window_bar_manager + .reserved_top_height(after.window_id.unwrap()), + 1 + ); + } + #[tokio::test] async fn line_end_delta_render_does_not_paint_the_cursor_on_window_bar() { let mut editor = test_editor(20, 5); diff --git a/src/window.rs b/src/window.rs index 2c0e2345..768153fe 100644 --- a/src/window.rs +++ b/src/window.rs @@ -30,7 +30,7 @@ impl WindowId { } } -/// Spatial direction used for window navigation and split resizing. +/// Spatial direction used for window navigation, movement, and split resizing. #[derive(Debug, Clone, Copy)] pub enum Direction { /// Toward smaller terminal rows. @@ -317,6 +317,194 @@ mod tests { } assert!(manager.window_at_index(windows.len()).is_none()); } + + fn nested_window_manager() -> WindowManager { + let mut manager = WindowManager::new(0, (80, 26)); + manager.split_vertical(1).unwrap(); + manager.split_horizontal(2).unwrap(); + manager.set_active(0); + manager.split_horizontal(3).unwrap(); + + let Split::Vertical { right, .. } = &mut manager.root else { + panic!("expected a vertical outer split"); + }; + let Split::Horizontal { ratio, .. } = right.as_mut() else { + panic!("expected a horizontal right-hand split"); + }; + *ratio = 0.3; + manager.resize((80, 26)); + manager + } + + fn contains_split_ratio(split: &Split, expected: f32) -> bool { + match split { + Split::Window(_) => false, + Split::Horizontal { top, bottom, ratio } => { + (*ratio - expected).abs() < f32::EPSILON + || contains_split_ratio(top, expected) + || contains_split_ratio(bottom, expected) + } + Split::Vertical { left, right, ratio } => { + (*ratio - expected).abs() < f32::EPSILON + || contains_split_ratio(left, expected) + || contains_split_ratio(right, expected) + } + } + } + + #[test] + fn move_window_to_each_edge_preserves_identity_state_and_unaffected_ratios() { + for direction in [ + Direction::Left, + Direction::Right, + Direction::Up, + Direction::Down, + ] { + let mut manager = nested_window_manager(); + let original_ids = manager + .windows() + .into_iter() + .map(|window| window.id) + .collect::>(); + let window = manager.active_window_mut().unwrap(); + window.vtop = 7; + window.vleft = 4; + window.skipcol = 3; + window.wrap = false; + window.cx = 9; + window.cy = 2; + window.cursor_goal = CursorGoal::DisplayCol(11); + window.vx = 5; + let original_id = window.id; + + assert!(manager.move_window_to_edge(direction).is_some()); + + let moved = manager.active_window().unwrap(); + assert_eq!(moved.id, original_id); + assert_eq!(moved.buffer_index, 3); + assert_eq!(moved.vtop, 7); + assert_eq!(moved.vleft, 4); + assert_eq!(moved.skipcol, 3); + assert!(!moved.wrap); + assert_eq!(moved.cx, 9); + assert_eq!(moved.cy, 2); + assert_eq!(moved.cursor_goal, CursorGoal::DisplayCol(11)); + assert_eq!(moved.vx, 5); + assert!(moved.active); + + match direction { + Direction::Left => { + assert_eq!(moved.position, Point::new(0, 0)); + assert_eq!(moved.size, (39, 24)); + } + Direction::Right => { + assert_eq!(moved.position, Point::new(40, 0)); + assert_eq!(moved.size, (40, 24)); + } + Direction::Up => { + assert_eq!(moved.position, Point::new(0, 0)); + assert_eq!(moved.size, (80, 11)); + } + Direction::Down => { + assert_eq!(moved.position, Point::new(0, 12)); + assert_eq!(moved.size, (80, 12)); + } + } + + let mut remaining_ids = manager + .windows() + .into_iter() + .map(|window| window.id) + .collect::>(); + let mut expected_ids = original_ids; + remaining_ids.sort_unstable(); + expected_ids.sort_unstable(); + assert_eq!(remaining_ids, expected_ids); + assert_eq!(manager.window_count(), 4); + assert!(contains_split_ratio(&manager.root, 0.3)); + assert_eq!( + manager.window_index(original_id), + Some(manager.active_window_id()) + ); + } + } + + #[test] + fn moving_a_single_window_to_any_edge_is_a_no_op() { + for direction in [ + Direction::Left, + Direction::Right, + Direction::Up, + Direction::Down, + ] { + let mut manager = WindowManager::new(0, (80, 26)); + let before = manager.snapshot(); + + assert!(manager.move_window_to_edge(direction).is_none()); + assert_eq!(manager.snapshot(), before); + } + } + + #[test] + fn moving_a_window_already_at_the_full_edge_is_a_no_op() { + for direction in [ + Direction::Left, + Direction::Right, + Direction::Up, + Direction::Down, + ] { + let mut manager = nested_window_manager(); + assert!(manager.move_window_to_edge(direction).is_some()); + let before = manager.snapshot(); + + assert!(manager.move_window_to_edge(direction).is_none()); + assert_eq!(manager.snapshot(), before); + } + } + + #[test] + fn move_window_to_edge_preserves_nonzero_layout_origin() { + let mut manager = nested_window_manager(); + manager.resize_with_origin(Point::new(20, 2), (60, 28)); + + assert!(manager.move_window_to_edge(Direction::Left).is_some()); + + let moved = manager.active_window().unwrap(); + assert_eq!(moved.position, Point::new(20, 2)); + assert_eq!(moved.size, (29, 26)); + assert!(manager.windows().into_iter().all(|window| { + window.position.x >= 20 + && window.position.x + window.size.0 <= 80 + && window.position.y >= 2 + && window.position.y + window.size.1 <= 28 + })); + } + + #[test] + fn moved_window_layout_round_trips_through_snapshot() { + let mut manager = nested_window_manager(); + manager.active_window_mut().unwrap().vtop = 12; + manager.move_window_to_edge(Direction::Right).unwrap(); + let snapshot = manager.snapshot(); + let buffer_map = HashMap::from([(0, 0), (1, 1), (2, 2), (3, 3)]); + + let restored = WindowManager::from_snapshot(&snapshot, (80, 26), &buffer_map).unwrap(); + + assert_eq!(restored.snapshot(), snapshot); + assert_eq!(restored.active_window().unwrap().buffer_index, 3); + assert_eq!(restored.active_window().unwrap().vtop, 12); + assert!(contains_split_ratio(&restored.root, 0.3)); + } + + #[test] + fn moving_windows_in_a_tiny_layout_does_not_panic() { + let mut manager = WindowManager::new(0, (2, 3)); + manager.split_vertical(1).unwrap(); + + assert!(manager.move_window_to_edge(Direction::Up).is_some()); + assert_eq!(manager.window_count(), 2); + assert!(manager.active_window().unwrap().active); + } } /// Represents a split in the window layout @@ -483,6 +671,73 @@ impl Split { } } + /// Removes a leaf without recreating windows or changing surviving split ratios. + fn detach_window(self, target_id: WindowId) -> Result<(Option, Window), Self> { + match self { + Self::Window(window) => { + if window.id == target_id { + Ok((None, window)) + } else { + Err(Self::Window(window)) + } + } + Self::Horizontal { top, bottom, ratio } => match (*top).detach_window(target_id) { + Ok((Some(top), window)) => Ok(( + Some(Self::Horizontal { + top: Box::new(top), + bottom, + ratio, + }), + window, + )), + Ok((None, window)) => Ok((Some(*bottom), window)), + Err(top) => match (*bottom).detach_window(target_id) { + Ok((Some(bottom), window)) => Ok(( + Some(Self::Horizontal { + top: Box::new(top), + bottom: Box::new(bottom), + ratio, + }), + window, + )), + Ok((None, window)) => Ok((Some(top), window)), + Err(bottom) => Err(Self::Horizontal { + top: Box::new(top), + bottom: Box::new(bottom), + ratio, + }), + }, + }, + Self::Vertical { left, right, ratio } => match (*left).detach_window(target_id) { + Ok((Some(left), window)) => Ok(( + Some(Self::Vertical { + left: Box::new(left), + right, + ratio, + }), + window, + )), + Ok((None, window)) => Ok((Some(*right), window)), + Err(left) => match (*right).detach_window(target_id) { + Ok((Some(right), window)) => Ok(( + Some(Self::Vertical { + left: Box::new(left), + right: Box::new(right), + ratio, + }), + window, + )), + Ok((None, window)) => Ok((Some(left), window)), + Err(right) => Err(Self::Vertical { + left: Box::new(left), + right: Box::new(right), + ratio, + }), + }, + }, + } + } + fn snapshot(&self) -> SplitSnapshot { match self { Split::Window(window) => SplitSnapshot::Window { @@ -845,6 +1100,92 @@ impl WindowManager { Some(()) } + /// Moves the active window to the requested full-height or full-width outer edge. + /// + /// Returns `None` when there is only one window or the active window already + /// occupies the requested edge. + pub fn move_window_to_edge(&mut self, direction: Direction) -> Option<()> { + let active_window = self.active_window()?; + let active_id = active_window.id; + + let already_at_edge = match (&self.root, direction) { + (Split::Window(_), _) => true, + (Split::Vertical { left, .. }, Direction::Left) => { + matches!(left.as_ref(), Split::Window(window) if window.id == active_id) + } + (Split::Vertical { right, .. }, Direction::Right) => { + matches!(right.as_ref(), Split::Window(window) if window.id == active_id) + } + (Split::Horizontal { top, .. }, Direction::Up) => { + matches!(top.as_ref(), Split::Window(window) if window.id == active_id) + } + (Split::Horizontal { bottom, .. }, Direction::Down) => { + matches!(bottom.as_ref(), Split::Window(window) if window.id == active_id) + } + _ => false, + }; + if already_at_edge { + return None; + } + + let windows = self.root.windows(); + let origin_x = windows.iter().map(|window| window.position.x).min()?; + let origin_y = windows.iter().map(|window| window.position.y).min()?; + let max_x = windows + .iter() + .map(|window| window.position.x.saturating_add(window.size.0)) + .max()?; + let max_y = windows + .iter() + .map(|window| window.position.y.saturating_add(window.size.1)) + .max()?; + let origin = Point::new(origin_x, origin_y); + let size = ( + max_x.saturating_sub(origin_x), + max_y.saturating_sub(origin_y), + ); + + let placeholder = Split::Window(active_window.clone()); + let root = std::mem::replace(&mut self.root, placeholder); + let (remaining, window) = match root.detach_window(active_id) { + Ok((Some(remaining), window)) => (remaining, window), + Ok((None, window)) => { + self.root = Split::Window(window); + return None; + } + Err(root) => { + self.root = root; + return None; + } + }; + + self.root = match direction { + Direction::Left => Split::Vertical { + left: Box::new(Split::Window(window)), + right: Box::new(remaining), + ratio: 0.5, + }, + Direction::Right => Split::Vertical { + left: Box::new(remaining), + right: Box::new(Split::Window(window)), + ratio: 0.5, + }, + Direction::Up => Split::Horizontal { + top: Box::new(Split::Window(window)), + bottom: Box::new(remaining), + ratio: 0.5, + }, + Direction::Down => Split::Horizontal { + top: Box::new(remaining), + bottom: Box::new(Split::Window(window)), + ratio: 0.5, + }, + }; + self.root.layout(origin, size); + self.set_active(self.window_index(active_id)?); + Some(()) + } + /// Closes the active window pub fn close_window(&mut self) -> Option<()> { use crate::log; diff --git a/tests/editing.rs b/tests/editing.rs index 5cc46277..62d66cfc 100644 --- a/tests/editing.rs +++ b/tests/editing.rs @@ -22,6 +22,7 @@ use red::{ preferences::PreferencesStore, theme::{Style, Theme}, undo::EditOrigin, + window::SplitSnapshot, }; use std::{ env, fs, @@ -5231,6 +5232,183 @@ async fn next_and_previous_window_cycle_through_focused_panels() { assert_eq!(harness.editor.test_focused_panel_id(), Some("tree")); } +#[tokio::test] +async fn shifted_window_chords_move_nested_splits_to_each_outer_edge() { + for (key, expected_position, expected_size) in [ + ('H', (0, 0), (39, 22)), + ('J', (0, 11), (80, 11)), + ('K', (0, 0), (80, 10)), + ('L', (40, 0), (40, 22)), + ] { + let contents = (0..40) + .map(|line| format!("line {line:02}\n")) + .collect::(); + let buffer = Buffer::new(None, contents); + let mut harness = EditorHarness::with_config(buffer, default_key_config()); + harness.execute_action(Action::SplitVertical).await.unwrap(); + harness + .execute_action(Action::SplitHorizontal) + .await + .unwrap(); + harness.set_viewport_cursor(2, 3, 3); + let cursor = harness.cursor_position(); + + harness + .execute_event(Event::Key(KeyEvent::new( + KeyCode::Char('w'), + KeyModifiers::CONTROL, + ))) + .await + .unwrap(); + assert!(harness.is_waiting_for_key_sequence()); + harness + .execute_event(Event::Key(KeyEvent::new( + KeyCode::Char(key), + KeyModifiers::SHIFT, + ))) + .await + .unwrap(); + + let (position, size) = harness.editor.test_active_window_bounds().unwrap(); + assert_eq!((position.x, position.y), expected_position); + assert_eq!(size, expected_size); + assert_eq!(harness.window_count(), 3); + assert_eq!(harness.cursor_position(), cursor); + assert_eq!(harness.viewport_top(), 2); + assert!(!harness.is_waiting_for_key_sequence()); + + let snapshot = harness.editor.test_session_snapshot(); + match (key, snapshot.window_layout.root) { + ('H', SplitSnapshot::Vertical { left, .. }) => { + assert!(matches!(left.as_ref(), SplitSnapshot::Window { .. })); + } + ('L', SplitSnapshot::Vertical { right, .. }) => { + assert!(matches!(right.as_ref(), SplitSnapshot::Window { .. })); + } + ('K', SplitSnapshot::Horizontal { top, .. }) => { + assert!(matches!(top.as_ref(), SplitSnapshot::Window { .. })); + } + ('J', SplitSnapshot::Horizontal { bottom, .. }) => { + assert!(matches!(bottom.as_ref(), SplitSnapshot::Window { .. })); + } + _ => panic!("shifted window chord did not create the expected outer split"), + } + } +} + +#[tokio::test] +async fn lowercase_window_chords_preserve_split_topology_and_move_focus() { + for (key, preparation) in [ + ('h', Some(Action::MoveWindowRight)), + ('j', Some(Action::MoveWindowUp)), + ('k', None), + ('l', None), + ] { + let buffer = Buffer::new(None, "first\nsecond\nthird\n".to_string()); + let mut harness = EditorHarness::with_config(buffer, default_key_config()); + harness.execute_action(Action::SplitVertical).await.unwrap(); + harness + .execute_action(Action::SplitHorizontal) + .await + .unwrap(); + harness + .execute_action(Action::MoveWindowLeft) + .await + .unwrap(); + harness + .execute_action(Action::SplitHorizontal) + .await + .unwrap(); + if let Some(action) = preparation { + harness.execute_action(action).await.unwrap(); + } + let before = harness.editor.test_session_snapshot().window_layout; + + harness + .execute_event(Event::Key(KeyEvent::new( + KeyCode::Char('w'), + KeyModifiers::CONTROL, + ))) + .await + .unwrap(); + harness + .execute_event(Event::Key(KeyEvent::new( + KeyCode::Char(key), + KeyModifiers::NONE, + ))) + .await + .unwrap(); + + let after = harness.editor.test_session_snapshot().window_layout; + assert_eq!(after.root, before.root); + assert_ne!(after.active_window_id, before.active_window_id); + assert_eq!(harness.window_count(), 4); + } +} + +#[tokio::test] +async fn moving_a_window_to_an_edge_preserves_both_side_panels() { + let buffer = Buffer::new(None, "first\nsecond\nthird\n".to_string()); + let mut harness = EditorHarness::with_config(buffer, default_key_config()); + harness.execute_action(Action::SplitVertical).await.unwrap(); + harness + .execute_action(Action::SplitHorizontal) + .await + .unwrap(); + add_tree_panel(&mut harness); + harness.editor.test_create_panel( + "right", + PanelConfig { + side: PanelSide::Right, + width: 20, + title: None, + composer: None, + surface: None, + border: None, + header_actions: Vec::new(), + }, + ); + + harness + .execute_action(Action::MoveWindowToLeft) + .await + .unwrap(); + let (left_position, left_size) = harness.editor.test_active_window_bounds().unwrap(); + assert_eq!(left_position.x, 21); + assert_eq!(left_position.y, 0); + assert_eq!(left_size.1, 22); + assert!(left_position.x + left_size.0 <= 59); + + harness + .execute_action(Action::MoveWindowToRight) + .await + .unwrap(); + let (right_position, right_size) = harness.editor.test_active_window_bounds().unwrap(); + assert_eq!(right_position.y, 0); + assert_eq!(right_size.1, 22); + assert_eq!(right_position.x + right_size.0, 59); + assert_eq!(harness.window_count(), 3); +} + +#[tokio::test] +async fn moving_a_single_window_to_an_edge_is_silent() { + for action in [ + Action::MoveWindowToLeft, + Action::MoveWindowToBottom, + Action::MoveWindowToTop, + Action::MoveWindowToRight, + ] { + let mut harness = EditorHarness::with_content("abcdef"); + let before = harness.editor.test_session_snapshot().window_layout; + + harness.execute_action(action).await.unwrap(); + + assert_eq!(harness.editor.test_session_snapshot().window_layout, before); + assert_eq!(harness.last_error(), None); + assert_eq!(harness.window_count(), 1); + } +} + #[tokio::test] async fn directional_window_boundaries_report_no_op() { let mut harness = EditorHarness::with_content("abcdef");