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
30 changes: 17 additions & 13 deletions src/config/keybindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -163,6 +173,11 @@ impl Default for UniversalKeybinding {
create_patch_options_menu: "<c-p>".into(),
revert_block: "<enter>".into(),
undo_revert_block: "u".into(),
shrink_side_panel: "<a-h>".into(),
expand_side_panel: "<a-l>".into(),
side_panel_full: "<a-k>".into(),
main_panel_full: "<a-j>".into(),
reset_side_panel: "<a-r>".into(),
}
}
}
Expand Down Expand Up @@ -379,41 +394,31 @@ impl Default for CommitMessageKeybinding {
}
}

/// Parse a keybinding string like "q", "<c-c>", "<enter>", "<space>" into a KeyEvent.
pub fn parse_key(s: &str) -> Option<KeyEvent> {
let s = s.trim();
if s.is_empty() {
return None;
}

// Check for modifier+key combos like <c-c>, <a-x>
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)),
Expand All @@ -428,7 +433,6 @@ pub fn parse_key(s: &str) -> Option<KeyEvent> {
};
}

// Single character
if s.len() == 1 {
let ch = s.chars().next()?;
let modifiers = if ch.is_uppercase() {
Expand Down
117 changes: 78 additions & 39 deletions src/gui/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<Constraint> = (0..panel_count)
.map(|i| {
if i == 0 {
Expand Down Expand Up @@ -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<Constraint> = (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
Expand Down Expand Up @@ -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<Rect> {
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<Constraint> = (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()
}
33 changes: 33 additions & 0 deletions src/gui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion src/gui/views.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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).
Expand Down
Loading