diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index 9d47ce2..e1d3d1a 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -114,6 +114,16 @@ 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, + #[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 { @@ -163,6 +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(), + side_panel_full: "".into(), + main_panel_full: "".into(), + reset_side_panel: "".into(), } } } @@ -379,41 +394,31 @@ 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 modifier if let Some(key) = inner.strip_prefix("c-") { let ch = key.chars().next()?; - return Some(KeyEvent::new( - KeyCode::Char(ch), - KeyModifiers::CONTROL, - )); + 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)), "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)), @@ -428,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..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 { @@ -179,49 +200,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 +282,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 68970fa..95f373a 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1506,6 +1506,39 @@ impl Gui { let keybindings = &self.config.user_config.keybinding; + // 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); + return Ok(()); + } + if matches_key(key, &keybindings.universal.side_panel_full) { + // 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) { + // 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) { + self.layout.side_panel_ratio = self.config.user_config.gui.side_panel_width; + return Ok(()); + } + // When diff panel is focused, handle diff-specific keys if self.diff_focused { return self.handle_diff_focused_key(key); 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).