From 9519ea7c165e3e434376fa113237866da6f4bb5b Mon Sep 17 00:00:00 2001 From: felipe cavalcante miranda Date: Mon, 18 May 2026 23:52:35 -0300 Subject: [PATCH 1/4] feat: add Ctrl+Shift+H/L keybindings to resize side panel width --- src/config/keybindings.rs | 22 ++++++++++++++++------ src/gui/mod.rs | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index 9d47ce2..8f5323d 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -114,6 +114,10 @@ pub struct UniversalKeybinding { pub revert_block: String, #[serde(rename = "undoRevertBlock")] pub undo_revert_block: String, + #[serde(rename = "shrinkSidePanel")] + pub shrink_side_panel: String, + #[serde(rename = "expandSidePanel")] + pub expand_side_panel: String, } impl Default for UniversalKeybinding { @@ -163,6 +167,8 @@ impl Default for UniversalKeybinding { create_patch_options_menu: "".into(), revert_block: "".into(), undo_revert_block: "u".into(), + shrink_side_panel: "".into(), + expand_side_panel: "".into(), } } } @@ -390,15 +396,21 @@ pub fn parse_key(s: &str) -> Option { if s.starts_with('<') && s.ends_with('>') { let inner = &s[1..s.len() - 1]; - // Ctrl modifier - if let Some(key) = inner.strip_prefix("c-") { + // Ctrl+Shift modifier combo: + if let Some(key) = inner.strip_prefix("c-s-") { let ch = key.chars().next()?; return Some(KeyEvent::new( KeyCode::Char(ch), - KeyModifiers::CONTROL, + KeyModifiers::CONTROL | KeyModifiers::SHIFT, )); } + // Ctrl modifier + if let Some(key) = inner.strip_prefix("c-") { + let ch = key.chars().next()?; + return Some(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::CONTROL)); + } + // Alt modifier if let Some(key) = inner.strip_prefix("a-") { let ch = key.chars().next()?; @@ -411,9 +423,7 @@ pub fn parse_key(s: &str) -> Option { "escape" | "esc" => Some(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), "tab" => Some(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)), "backtab" | "shift-tab" => Some(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)), - "backspace" | "bs" => { - Some(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)) - } + "backspace" | "bs" => Some(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)), "delete" | "del" => Some(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)), "space" => Some(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)), "up" => Some(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)), diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 68970fa..943722b 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1506,6 +1506,28 @@ impl Gui { let keybindings = &self.config.user_config.keybinding; + // Side-panel resize: Ctrl+Shift+H shrinks, Ctrl+Shift+L expands. + // When the left (side) panel is focused the keys act directly; + // when the right (diff) panel is focused the meaning is inverted so + // that the user always "pushes" the divider toward or away from their + // current focus. + let shrink_key = matches_key(key, &keybindings.universal.shrink_side_panel); + let expand_key = matches_key(key, &keybindings.universal.expand_side_panel); + if shrink_key || expand_key { + const STEP: f64 = 0.05; + const MIN_RATIO: f64 = 0.10; + const MAX_RATIO: f64 = 0.70; + let do_shrink = shrink_key; + if do_shrink { + self.layout.side_panel_ratio = + (self.layout.side_panel_ratio - STEP).max(MIN_RATIO); + } else { + self.layout.side_panel_ratio = + (self.layout.side_panel_ratio + STEP).min(MAX_RATIO); + } + return Ok(()); + } + // When diff panel is focused, handle diff-specific keys if self.diff_focused { return self.handle_diff_focused_key(key); From f6ce72724c98b4927529cf4d6d1db8b98ed323bb Mon Sep 17 00:00:00 2001 From: felipe Date: Wed, 20 May 2026 06:12:24 -0300 Subject: [PATCH 2/4] feat: side panel resize with border expansion and quick-jump bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Alt+h/l: shrink/expand side panel by 5% steps - Alt+H: jump main (diff) panel to full width - Alt+L: jump side panel to full width - Alt+r: reset side panel to configured default width - Panel can now reach both borders (0%–100%) - Side panel fully collapsed hides side panels entirely - Side panel fully expanded hides diff panel entirely (no border artifact) - Fix parse_key: with uppercase X now includes SHIFT modifier - Fix parse_key: Ctrl+Shift combo parsed before --- src/config/keybindings.rs | 28 ++++++------- src/gui/layout.rs | 82 ++++++++++++++++++++++++--------------- src/gui/mod.rs | 30 +++++++------- src/gui/views.rs | 4 +- 4 files changed, 79 insertions(+), 65 deletions(-) diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index 8f5323d..f876e80 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -118,6 +118,12 @@ pub struct UniversalKeybinding { pub shrink_side_panel: String, #[serde(rename = "expandSidePanel")] pub expand_side_panel: String, + #[serde(rename = "sidePanelFull")] + pub side_panel_full: String, + #[serde(rename = "mainPanelFull")] + pub main_panel_full: String, + #[serde(rename = "resetSidePanel")] + pub reset_side_panel: String, } impl Default for UniversalKeybinding { @@ -167,8 +173,11 @@ impl Default for UniversalKeybinding { create_patch_options_menu: "".into(), revert_block: "".into(), undo_revert_block: "u".into(), - shrink_side_panel: "".into(), - expand_side_panel: "".into(), + shrink_side_panel: "".into(), + expand_side_panel: "".into(), + side_panel_full: "".into(), + main_panel_full: "".into(), + reset_side_panel: "".into(), } } } @@ -385,39 +394,25 @@ impl Default for CommitMessageKeybinding { } } -/// Parse a keybinding string like "q", "", "", "" into a KeyEvent. pub fn parse_key(s: &str) -> Option { let s = s.trim(); if s.is_empty() { return None; } - // Check for modifier+key combos like , if s.starts_with('<') && s.ends_with('>') { let inner = &s[1..s.len() - 1]; - // Ctrl+Shift modifier combo: - if let Some(key) = inner.strip_prefix("c-s-") { - let ch = key.chars().next()?; - return Some(KeyEvent::new( - KeyCode::Char(ch), - KeyModifiers::CONTROL | KeyModifiers::SHIFT, - )); - } - - // Ctrl modifier if let Some(key) = inner.strip_prefix("c-") { let ch = key.chars().next()?; return Some(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::CONTROL)); } - // Alt modifier if let Some(key) = inner.strip_prefix("a-") { let ch = key.chars().next()?; return Some(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::ALT)); } - // Special keys return match inner { "enter" => Some(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), "escape" | "esc" => Some(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), @@ -438,7 +433,6 @@ pub fn parse_key(s: &str) -> Option { }; } - // Single character if s.len() == 1 { let ch = s.chars().next()?; let modifiers = if ch.is_uppercase() { diff --git a/src/gui/layout.rs b/src/gui/layout.rs index 97720c1..9611d5c 100644 --- a/src/gui/layout.rs +++ b/src/gui/layout.rs @@ -179,49 +179,44 @@ pub fn compute_layout_with_details( _ => side_ratio, }; - // Split main area into side panel and main content - let side_width = ((main_area.width as f64) * effective_ratio) as u16; - let max_side = if screen_mode == ScreenMode::Half { - main_area.width.saturating_sub(20) // leave at least 20 cols for main - } else { - main_area.width / 2 - }; - let side_width = side_width.max(20).min(max_side); + // Side panel width: ratio 0.0 collapses to the left border; ratio 1.0 + // expands to the right border. + let side_width = ((main_area.width as f64) * effective_ratio).round() as u16; + let side_width = side_width.min(main_area.width); + + if side_width == 0 { + return FrameLayout { + side_panels: Vec::new(), + main_panel: main_area, + status_bar, + portrait: false, + commit_details_panel: None, + }; + } + + if side_width >= main_area.width { + let side_panels = split_side_panels(main_area, panel_count, active_panel_index); + return FrameLayout { + side_panels, + main_panel: Rect::default(), + status_bar, + portrait: false, + commit_details_panel: None, + }; + } let horizontal = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Length(side_width), - Constraint::Min(1), + Constraint::Min(0), ]) .split(main_area); let side_area = horizontal[0]; let main_panel = horizontal[1]; - // Side panel sizing: active panel expands, others collapse. - // On very short terminals (< 21 rows) unfocused panels shrink to 1 line. - let side_height = side_area.height; - let collapsed: u16 = if side_height < 21 { 1 } else { 3 }; - - let expand_index = if active_panel_index == 0 { 1 } else { active_panel_index }; - let panel_constraints: Vec = (0..panel_count) - .map(|i| { - if i == 0 { - Constraint::Length(STATUS_PANEL_HEIGHT) - } else if i == expand_index { - Constraint::Min(collapsed) - } else { - Constraint::Length(collapsed) - } - }) - .collect(); - - let side_panels = Layout::default() - .direction(Direction::Vertical) - .constraints(panel_constraints) - .split(side_area) - .to_vec(); + let side_panels = split_side_panels(side_area, panel_count, active_panel_index); // Carve a compact commit-details box off the top of main_panel. Target // size is 7 rows (2 borders + 5 content lines); shrink gracefully on @@ -266,3 +261,26 @@ pub fn compute_layout_with_details( }, } } + +/// Splits `area` vertically into one rect per side panel. +/// The active panel expands; the status panel (index 0) is fixed height. +fn split_side_panels(area: Rect, panel_count: usize, active_panel_index: usize) -> Vec { + let collapsed: u16 = if area.height < 21 { 1 } else { 3 }; + let expand_index = if active_panel_index == 0 { 1 } else { active_panel_index }; + let constraints: Vec = (0..panel_count) + .map(|i| { + if i == 0 { + Constraint::Length(STATUS_PANEL_HEIGHT) + } else if i == expand_index { + Constraint::Min(collapsed) + } else { + Constraint::Length(collapsed) + } + }) + .collect(); + Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(area) + .to_vec() +} diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 943722b..682a5fe 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1506,25 +1506,25 @@ impl Gui { let keybindings = &self.config.user_config.keybinding; - // Side-panel resize: Ctrl+Shift+H shrinks, Ctrl+Shift+L expands. - // When the left (side) panel is focused the keys act directly; - // when the right (diff) panel is focused the meaning is inverted so - // that the user always "pushes" the divider toward or away from their - // current focus. + // Side-panel resize: Alt+h/l shrink/expand, Alt+H/L jump to border, Alt+r reset. let shrink_key = matches_key(key, &keybindings.universal.shrink_side_panel); let expand_key = matches_key(key, &keybindings.universal.expand_side_panel); if shrink_key || expand_key { const STEP: f64 = 0.05; - const MIN_RATIO: f64 = 0.10; - const MAX_RATIO: f64 = 0.70; - let do_shrink = shrink_key; - if do_shrink { - self.layout.side_panel_ratio = - (self.layout.side_panel_ratio - STEP).max(MIN_RATIO); - } else { - self.layout.side_panel_ratio = - (self.layout.side_panel_ratio + STEP).min(MAX_RATIO); - } + let delta = if shrink_key { -STEP } else { STEP }; + self.layout.side_panel_ratio = (self.layout.side_panel_ratio + delta).clamp(0.0, 1.0); + return Ok(()); + } + if matches_key(key, &keybindings.universal.side_panel_full) { + self.layout.side_panel_ratio = 1.0; + return Ok(()); + } + if matches_key(key, &keybindings.universal.main_panel_full) { + self.layout.side_panel_ratio = 0.0; + return Ok(()); + } + if matches_key(key, &keybindings.universal.reset_side_panel) { + self.layout.side_panel_ratio = self.config.user_config.gui.side_panel_width; return Ok(()); } diff --git a/src/gui/views.rs b/src/gui/views.rs index e05de86..76f183a 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -576,7 +576,8 @@ pub fn render( } } - // Render main panel + // Render main panel (skipped when side panel is fully expanded) + if fl.main_panel.width > 0 { if ctx_mgr.active() == ContextId::Status { // Status view: show logo + copyright in the main content area let status_block = Block::default() @@ -612,6 +613,7 @@ pub fn render( let widget = Paragraph::new(info).block(block); frame.render_widget(widget, fl.main_panel); } + } // end main_panel.width > 0 // Normal/Half mode: compact details box sits at the bottom of the active // sidebar panel (layout carves the rect out of the active side panel). From f214503937c18bc9d27cc90c3c2cecc6fcbcbb5b Mon Sep 17 00:00:00 2001 From: felipe Date: Wed, 20 May 2026 07:43:14 -0300 Subject: [PATCH 3/4] feat: update keybindings for side panel full toggle with shift modifier --- src/config/keybindings.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index f876e80..ff2c948 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -175,8 +175,8 @@ impl Default for UniversalKeybinding { undo_revert_block: "u".into(), shrink_side_panel: "".into(), expand_side_panel: "".into(), - side_panel_full: "".into(), - main_panel_full: "".into(), + side_panel_full: "".into(), + main_panel_full: "".into(), reset_side_panel: "".into(), } } @@ -410,7 +410,12 @@ pub fn parse_key(s: &str) -> Option { if let Some(key) = inner.strip_prefix("a-") { let ch = key.chars().next()?; - return Some(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::ALT)); + let modifiers = if ch.is_uppercase() { + KeyModifiers::ALT | KeyModifiers::SHIFT + } else { + KeyModifiers::ALT + }; + return Some(KeyEvent::new(KeyCode::Char(ch), modifiers)); } return match inner { From 524136cb22ead8366803cb8c120d6bcd445c3ce7 Mon Sep 17 00:00:00 2001 From: felipe Date: Wed, 20 May 2026 09:11:30 -0300 Subject: [PATCH 4/4] feat: update side panel keybindings for improved layout handling --- src/config/keybindings.rs | 11 +++-------- src/gui/layout.rs | 35 ++++++++++++++++++++++++++++------- src/gui/mod.rs | 19 +++++++++++++++---- 3 files changed, 46 insertions(+), 19 deletions(-) diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index ff2c948..e1d3d1a 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -175,8 +175,8 @@ impl Default for UniversalKeybinding { undo_revert_block: "u".into(), shrink_side_panel: "".into(), expand_side_panel: "".into(), - side_panel_full: "".into(), - main_panel_full: "".into(), + side_panel_full: "".into(), + main_panel_full: "".into(), reset_side_panel: "".into(), } } @@ -410,12 +410,7 @@ pub fn parse_key(s: &str) -> Option { if let Some(key) = inner.strip_prefix("a-") { let ch = key.chars().next()?; - let modifiers = if ch.is_uppercase() { - KeyModifiers::ALT | KeyModifiers::SHIFT - } else { - KeyModifiers::ALT - }; - return Some(KeyEvent::new(KeyCode::Char(ch), modifiers)); + return Some(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::ALT)); } return match inner { diff --git a/src/gui/layout.rs b/src/gui/layout.rs index 9611d5c..af18bbf 100644 --- a/src/gui/layout.rs +++ b/src/gui/layout.rs @@ -117,15 +117,36 @@ pub fn compute_layout_with_details( ScreenMode::Half => 0.5, _ => side_ratio, }; + if effective_ratio <= 0.0 { + return FrameLayout { + side_panels: Vec::new(), + main_panel: main_area, + status_bar, + portrait: true, + commit_details_panel: None, + }; + } + + if effective_ratio >= 1.0 { + let side_panels = split_side_panels(main_area, panel_count, active_panel_index); + return FrameLayout { + side_panels, + main_panel: Rect::default(), + status_bar, + portrait: true, + commit_details_panel: None, + }; + } + let max_side_height = if screen_mode == ScreenMode::Half { - main_area.height.saturating_sub(5) // leave at least 5 rows for main + main_area.height.saturating_sub(5) } else { - main_area.height / 2 + main_area.height.saturating_sub(1) + }; + let side_height = { + let h = (main_area.height as f64 * effective_ratio).round() as u16; + h.max(1).min(max_side_height) }; - let side_height = (main_area.height as f64 * effective_ratio).round() as u16; - let side_height = side_height - .max(panel_count as u16 * 2) - .min(max_side_height); let vertical = Layout::default() .direction(Direction::Vertical) @@ -142,7 +163,7 @@ pub fn compute_layout_with_details( // When Status (index 0) is focused it stays compact, so expand Files // (index 1) instead — otherwise the sidebar leaves a large empty gap. let expand_index = if active_panel_index == 0 { 1 } else { active_panel_index }; - let collapsed: u16 = if side_area.height < 21 { 1 } else { 3 }; + let collapsed: u16 = 1; let panel_constraints: Vec = (0..panel_count) .map(|i| { if i == 0 { diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 682a5fe..95f373a 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1506,21 +1506,32 @@ impl Gui { let keybindings = &self.config.user_config.keybinding; - // Side-panel resize: Alt+h/l shrink/expand, Alt+H/L jump to border, Alt+r reset. + // Side-panel resize: orientation-aware. + // Portrait (vertical stack): side on top, diff on bottom. + // Alt+h/l → shrink/expand by step + // Alt+k → diff pane full (ratio 0.0), Alt+j → side pane full (ratio 1.0) + // Landscape (horizontal split): side on left, diff on right. + // Alt+h/l → shrink/expand by step, Alt+k → side full, Alt+j → main full + let portrait = self.screen_mode != ScreenMode::Full + && self.layout.width <= 84 + && self.layout.height > 25; let shrink_key = matches_key(key, &keybindings.universal.shrink_side_panel); let expand_key = matches_key(key, &keybindings.universal.expand_side_panel); if shrink_key || expand_key { const STEP: f64 = 0.05; let delta = if shrink_key { -STEP } else { STEP }; - self.layout.side_panel_ratio = (self.layout.side_panel_ratio + delta).clamp(0.0, 1.0); + self.layout.side_panel_ratio = + (self.layout.side_panel_ratio + delta).clamp(0.0, 1.0); return Ok(()); } if matches_key(key, &keybindings.universal.side_panel_full) { - self.layout.side_panel_ratio = 1.0; + // Alt+k: diff full in portrait, side full in landscape + self.layout.side_panel_ratio = if portrait { 0.0 } else { 1.0 }; return Ok(()); } if matches_key(key, &keybindings.universal.main_panel_full) { - self.layout.side_panel_ratio = 0.0; + // Alt+j: side full in portrait, main full in landscape + self.layout.side_panel_ratio = if portrait { 1.0 } else { 0.0 }; return Ok(()); } if matches_key(key, &keybindings.universal.reset_side_panel) {