Skip to content

Commit 49214b2

Browse files
committed
feat(tui): enable window management bindings
Enable the default `Ctrl-w` window keymap so split, close, navigation, resize, balance, and maximize commands are available without manual config edits. Implement balance and maximize actions so every enabled binding has visible window behavior, and cover the default config plus layout behavior with tests.
1 parent 9969cdf commit 49214b2

4 files changed

Lines changed: 178 additions & 24 deletions

File tree

default_config.toml

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -115,28 +115,6 @@ Esc = { EnterMode = "Normal" }
115115
"/" = { EnterMode = "Search" }
116116
# "|" = "Split"
117117
# "_" = "SplitVertical"
118-
# Window management (uncomment to enable)
119-
# "Ctrl-w" = {
120-
# "h" = "MoveWindowLeft",
121-
# "j" = "MoveWindowDown",
122-
# "k" = "MoveWindowUp",
123-
# "l" = "MoveWindowRight",
124-
# "w" = "NextWindow",
125-
# "W" = "PreviousWindow",
126-
# "p" = "PreviousWindow",
127-
# "s" = "SplitHorizontal",
128-
# "v" = "SplitVertical",
129-
# "-" = "SplitHorizontal",
130-
# "|" = "SplitVertical",
131-
# "c" = "CloseWindow",
132-
# "q" = "CloseWindow",
133-
# "Ctrl-w" = "NextWindow",
134-
# "+" = { ResizeWindowDown = 1 },
135-
# "<" = { ResizeWindowLeft = 1 },
136-
# ">" = { ResizeWindowRight = 1 },
137-
# "=" = "BalanceWindows",
138-
# "_" = "MaximizeWindow"
139-
# }
140118
"Ctrl-p" = "FilePicker"
141119
"Ctrl-z" = "Suspend"
142120
"Ctrl-e" = { PluginCommand = "NeoTree" }
@@ -156,6 +134,27 @@ Esc = { EnterMode = "Normal" }
156134
[keys.normal."<"]
157135
"<" = "UnindentLine"
158136

137+
[keys.normal."Ctrl-w"]
138+
"h" = "MoveWindowLeft"
139+
"j" = "MoveWindowDown"
140+
"k" = "MoveWindowUp"
141+
"l" = "MoveWindowRight"
142+
"w" = "NextWindow"
143+
"W" = "PreviousWindow"
144+
"p" = "PreviousWindow"
145+
"s" = "SplitHorizontal"
146+
"v" = "SplitVertical"
147+
"-" = "SplitHorizontal"
148+
"|" = "SplitVertical"
149+
"c" = "CloseWindow"
150+
"q" = "CloseWindow"
151+
"Ctrl-w" = "NextWindow"
152+
"+" = { ResizeWindowDown = 1 }
153+
"<" = { ResizeWindowLeft = 1 }
154+
">" = { ResizeWindowRight = 1 }
155+
"=" = "BalanceWindows"
156+
"_" = "MaximizeWindow"
157+
159158
[keys.normal." "]
160159
" " = "NextBuffer"
161160
"n" = "NextBuffer"

src/config.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,43 @@ theme = "theme/nightfox.json"
540540
);
541541
}
542542

543+
#[test]
544+
fn default_config_enables_window_management_prefix() {
545+
let config: Config = toml::from_str(include_str!("../default_config.toml")).unwrap();
546+
let Some(KeyAction::Nested(ctrl_w)) = config.keys.normal.get("Ctrl-w") else {
547+
panic!("default config should map Ctrl-w to window management actions");
548+
};
549+
550+
assert_eq!(
551+
ctrl_w.get("s"),
552+
Some(&KeyAction::Single(Action::SplitHorizontal))
553+
);
554+
assert_eq!(
555+
ctrl_w.get("v"),
556+
Some(&KeyAction::Single(Action::SplitVertical))
557+
);
558+
assert_eq!(
559+
ctrl_w.get("w"),
560+
Some(&KeyAction::Single(Action::NextWindow))
561+
);
562+
assert_eq!(
563+
ctrl_w.get("W"),
564+
Some(&KeyAction::Single(Action::PreviousWindow))
565+
);
566+
assert_eq!(
567+
ctrl_w.get("c"),
568+
Some(&KeyAction::Single(Action::CloseWindow))
569+
);
570+
assert_eq!(
571+
ctrl_w.get("="),
572+
Some(&KeyAction::Single(Action::BalanceWindows))
573+
);
574+
assert_eq!(
575+
ctrl_w.get("_"),
576+
Some(&KeyAction::Single(Action::MaximizeWindow))
577+
);
578+
}
579+
543580
#[test]
544581
fn cursor_config_defaults_match_current_behavior() {
545582
let config: Config = toml::from_str(

src/editor.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4883,10 +4883,14 @@ impl Editor {
48834883
}
48844884
}
48854885
Action::BalanceWindows => {
4886-
// TODO: Implement window balancing
4886+
if self.update_window_layout(WindowManager::balance_windows) {
4887+
self.render(buffer)?;
4888+
}
48874889
}
48884890
Action::MaximizeWindow => {
4889-
// TODO: Implement window maximizing
4891+
if self.update_window_layout(WindowManager::maximize_window) {
4892+
self.render(buffer)?;
4893+
}
48904894
}
48914895
}
48924896

src/window.rs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,35 @@ mod tests {
136136
assert_eq!(manager.active_window_id(), 0);
137137
}
138138

139+
#[test]
140+
fn balance_windows_restores_even_split_ratios() {
141+
let mut manager = WindowManager::new(0, (80, 26));
142+
manager.split_vertical(0).unwrap();
143+
manager.set_active(0);
144+
145+
assert!(manager.resize_window(Direction::Right, 4).is_some());
146+
let resized_width = manager.active_window().unwrap().size.0;
147+
148+
assert!(manager.balance_windows().is_some());
149+
150+
assert!(manager.active_window().unwrap().size.0 < resized_width);
151+
assert_eq!(manager.active_window().unwrap().size.0, 39);
152+
assert_eq!(manager.active_window_id(), 0);
153+
}
154+
155+
#[test]
156+
fn maximize_window_expands_active_window() {
157+
let mut manager = WindowManager::new(0, (80, 26));
158+
manager.split_vertical(0).unwrap();
159+
manager.set_active(1);
160+
let initial_width = manager.active_window().unwrap().size.0;
161+
162+
assert!(manager.maximize_window().is_some());
163+
164+
assert!(manager.active_window().unwrap().size.0 > initial_width);
165+
assert_eq!(manager.active_window_id(), 1);
166+
}
167+
139168
#[test]
140169
fn snapshot_round_trips_split_layout() {
141170
let mut manager = WindowManager::new(0, (80, 26));
@@ -709,6 +738,91 @@ impl WindowManager {
709738
}
710739
}
711740

741+
/// Resets every split ratio so windows share space evenly.
742+
pub fn balance_windows(&mut self) -> Option<()> {
743+
if self.root.windows().len() <= 1 {
744+
return None;
745+
}
746+
747+
let (width, height) = self.get_terminal_bounds();
748+
Self::balance_split(&mut self.root);
749+
self.root.layout(Point::new(0, 0), (width, height));
750+
self.set_active(self.active_window_id);
751+
Some(())
752+
}
753+
754+
fn balance_split(node: &mut Split) {
755+
match node {
756+
Split::Window(_) => {}
757+
Split::Horizontal { top, bottom, ratio } => {
758+
*ratio = 0.5;
759+
Self::balance_split(top);
760+
Self::balance_split(bottom);
761+
}
762+
Split::Vertical { left, right, ratio } => {
763+
*ratio = 0.5;
764+
Self::balance_split(left);
765+
Self::balance_split(right);
766+
}
767+
}
768+
}
769+
770+
/// Expands the active window along each ancestor split while preserving layout.
771+
pub fn maximize_window(&mut self) -> Option<()> {
772+
if self.root.windows().len() <= 1 {
773+
return None;
774+
}
775+
776+
let (width, height) = self.get_terminal_bounds();
777+
let mut current_id = 0;
778+
let maximized =
779+
Self::maximize_window_recursive(&mut self.root, &mut current_id, self.active_window_id);
780+
781+
if maximized {
782+
self.root.layout(Point::new(0, 0), (width, height));
783+
self.set_active(self.active_window_id);
784+
Some(())
785+
} else {
786+
None
787+
}
788+
}
789+
790+
fn maximize_window_recursive(
791+
node: &mut Split,
792+
current_id: &mut usize,
793+
target_id: usize,
794+
) -> bool {
795+
match node {
796+
Split::Window(_) => {
797+
let found = *current_id == target_id;
798+
*current_id += 1;
799+
found
800+
}
801+
Split::Horizontal { top, bottom, ratio } => {
802+
if Self::maximize_window_recursive(top, current_id, target_id) {
803+
*ratio = 0.9;
804+
true
805+
} else if Self::maximize_window_recursive(bottom, current_id, target_id) {
806+
*ratio = 0.1;
807+
true
808+
} else {
809+
false
810+
}
811+
}
812+
Split::Vertical { left, right, ratio } => {
813+
if Self::maximize_window_recursive(left, current_id, target_id) {
814+
*ratio = 0.9;
815+
true
816+
} else if Self::maximize_window_recursive(right, current_id, target_id) {
817+
*ratio = 0.1;
818+
true
819+
} else {
820+
false
821+
}
822+
}
823+
}
824+
}
825+
712826
/// Adjust the split ratio for the window in the given direction
713827
fn adjust_split_ratio(
714828
node: &mut Split,

0 commit comments

Comments
 (0)