use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
};
use crate::traits::{ClickRegion, ClickRegionRegistry, FocusId, Focusable};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SplitPaneAction {
FirstPaneClick,
SecondPaneClick,
DividerDrag,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Orientation {
#[default]
Horizontal,
Vertical,
}
#[derive(Debug, Clone)]
pub struct SplitPaneState {
pub split_percent: u16,
pub focused: bool,
pub divider_focused: bool,
pub is_dragging: bool,
drag_start_pos: u16,
drag_start_percent: u16,
total_size: u16,
pub focus_id: FocusId,
}
impl SplitPaneState {
pub fn new(split_percent: u16) -> Self {
Self {
split_percent: split_percent.clamp(0, 100),
focused: false,
divider_focused: false,
is_dragging: false,
drag_start_pos: 0,
drag_start_percent: 0,
total_size: 0,
focus_id: FocusId::default(),
}
}
pub fn half() -> Self {
Self::new(50)
}
pub fn start_drag(&mut self, pos: u16) {
self.is_dragging = true;
self.drag_start_pos = pos;
self.drag_start_percent = self.split_percent;
}
pub fn update_drag(&mut self, pos: u16, min_percent: u16, max_percent: u16) {
if !self.is_dragging || self.total_size == 0 {
return;
}
let delta = (pos as i32) - (self.drag_start_pos as i32);
let percent_delta = (delta * 100) / (self.total_size as i32);
let new_percent = ((self.drag_start_percent as i32) + percent_delta)
.clamp(min_percent as i32, max_percent as i32) as u16;
self.split_percent = new_percent;
}
pub fn end_drag(&mut self) {
self.is_dragging = false;
}
pub fn adjust_split(&mut self, delta: i16, min_percent: u16, max_percent: u16) {
let new_percent = ((self.split_percent as i16) + delta)
.clamp(min_percent as i16, max_percent as i16) as u16;
self.split_percent = new_percent;
}
pub fn set_split_percent(&mut self, percent: u16) {
self.split_percent = percent.clamp(0, 100);
}
pub fn split_percent(&self) -> u16 {
self.split_percent
}
pub fn is_dragging(&self) -> bool {
self.is_dragging
}
pub fn set_total_size(&mut self, size: u16) {
self.total_size = size;
}
}
impl Default for SplitPaneState {
fn default() -> Self {
Self::half()
}
}
impl Focusable for SplitPaneState {
fn focus_id(&self) -> FocusId {
self.focus_id
}
fn is_focused(&self) -> bool {
self.focused
}
fn set_focused(&mut self, focused: bool) {
self.focused = focused;
if !focused {
self.divider_focused = false;
}
}
fn focused_style(&self) -> Style {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
}
fn unfocused_style(&self) -> Style {
Style::default().fg(Color::White)
}
}
#[derive(Debug, Clone)]
pub struct SplitPaneStyle {
pub divider_style: Style,
pub divider_focused_style: Style,
pub divider_dragging_style: Style,
pub divider_hover_style: Style,
pub divider_char: Option<&'static str>,
pub divider_size: u16,
pub show_grab_indicator: bool,
}
impl Default for SplitPaneStyle {
fn default() -> Self {
Self {
divider_style: Style::default().bg(Color::DarkGray),
divider_focused_style: Style::default().bg(Color::Yellow).fg(Color::Black),
divider_dragging_style: Style::default().bg(Color::Cyan).fg(Color::Black),
divider_hover_style: Style::default().bg(Color::Gray),
divider_char: None, divider_size: 1,
show_grab_indicator: true,
}
}
}
impl From<&crate::theme::Theme> for SplitPaneStyle {
fn from(theme: &crate::theme::Theme) -> Self {
let p = &theme.palette;
Self {
divider_style: Style::default().bg(Color::DarkGray),
divider_focused_style: Style::default().bg(p.primary).fg(p.highlight_fg),
divider_dragging_style: Style::default().bg(p.secondary).fg(p.highlight_fg),
divider_hover_style: Style::default().bg(p.text_dim),
divider_char: None,
divider_size: 1,
show_grab_indicator: true,
}
}
}
impl SplitPaneStyle {
pub fn minimal() -> Self {
Self {
divider_style: Style::default().fg(Color::DarkGray),
divider_focused_style: Style::default().fg(Color::Yellow),
divider_dragging_style: Style::default().fg(Color::Cyan),
divider_hover_style: Style::default().fg(Color::Gray),
divider_char: None,
divider_size: 1,
show_grab_indicator: false,
}
}
pub fn prominent() -> Self {
Self {
divider_style: Style::default().bg(Color::Blue).fg(Color::White),
divider_focused_style: Style::default().bg(Color::Yellow).fg(Color::Black),
divider_dragging_style: Style::default().bg(Color::Green).fg(Color::Black),
divider_hover_style: Style::default().bg(Color::LightBlue).fg(Color::Black),
divider_char: None,
divider_size: 1,
show_grab_indicator: true,
}
}
pub fn divider_char(mut self, char: &'static str) -> Self {
self.divider_char = Some(char);
self
}
pub fn divider_size(mut self, size: u16) -> Self {
self.divider_size = size.max(1);
self
}
}
pub struct SplitPane {
orientation: Orientation,
style: SplitPaneStyle,
min_size: u16,
min_percent: u16,
max_percent: u16,
}
impl SplitPane {
pub fn new() -> Self {
Self {
orientation: Orientation::default(),
style: SplitPaneStyle::default(),
min_size: 5,
min_percent: 10,
max_percent: 90,
}
}
pub fn orientation(mut self, orientation: Orientation) -> Self {
self.orientation = orientation;
self
}
pub fn style(mut self, style: SplitPaneStyle) -> Self {
self.style = style;
self
}
pub fn theme(self, theme: &crate::theme::Theme) -> Self {
self.style(SplitPaneStyle::from(theme))
}
pub fn min_size(mut self, min_size: u16) -> Self {
self.min_size = min_size;
self
}
pub fn min_percent(mut self, min_percent: u16) -> Self {
self.min_percent = min_percent.clamp(0, 100);
self
}
pub fn max_percent(mut self, max_percent: u16) -> Self {
self.max_percent = max_percent.clamp(0, 100);
self
}
pub fn divider_char(mut self, char: &'static str) -> Self {
self.style.divider_char = Some(char);
self
}
pub fn calculate_areas(&self, area: Rect, split_percent: u16) -> (Rect, Rect, Rect) {
let total_size = match self.orientation {
Orientation::Horizontal => area.width,
Orientation::Vertical => area.height,
};
let divider_size = self.style.divider_size;
let available_size = total_size.saturating_sub(divider_size);
let first_size = ((available_size as u32) * (split_percent as u32) / 100) as u16;
let first_size =
first_size.clamp(self.min_size, available_size.saturating_sub(self.min_size));
let second_size = available_size.saturating_sub(first_size);
match self.orientation {
Orientation::Horizontal => {
let first_area = Rect::new(area.x, area.y, first_size, area.height);
let divider_area =
Rect::new(area.x + first_size, area.y, divider_size, area.height);
let second_area = Rect::new(
area.x + first_size + divider_size,
area.y,
second_size,
area.height,
);
(first_area, divider_area, second_area)
}
Orientation::Vertical => {
let first_area = Rect::new(area.x, area.y, area.width, first_size);
let divider_area = Rect::new(area.x, area.y + first_size, area.width, divider_size);
let second_area = Rect::new(
area.x,
area.y + first_size + divider_size,
area.width,
second_size,
);
(first_area, divider_area, second_area)
}
}
}
fn render_divider(&self, state: &SplitPaneState, divider_area: Rect, buf: &mut Buffer) {
let divider_style = if state.is_dragging {
self.style.divider_dragging_style
} else if state.divider_focused {
self.style.divider_focused_style
} else {
self.style.divider_style
};
let divider_char = self.style.divider_char.unwrap_or(match self.orientation {
Orientation::Horizontal => "│",
Orientation::Vertical => "─",
});
match self.orientation {
Orientation::Horizontal => {
for y in divider_area.y..divider_area.y + divider_area.height {
for x in divider_area.x..divider_area.x + divider_area.width {
let char_to_draw = if self.style.show_grab_indicator {
let mid_y = divider_area.y + divider_area.height / 2;
if y == mid_y {
"┃"
} else if y == mid_y.saturating_sub(1) || y == mid_y + 1 {
"║"
} else {
divider_char
}
} else {
divider_char
};
buf.set_string(x, y, char_to_draw, divider_style);
}
}
}
Orientation::Vertical => {
for y in divider_area.y..divider_area.y + divider_area.height {
for x in divider_area.x..divider_area.x + divider_area.width {
let char_to_draw = if self.style.show_grab_indicator {
let mid_x = divider_area.x + divider_area.width / 2;
if x == mid_x {
"━"
} else if x == mid_x.saturating_sub(1) || x == mid_x + 1 {
"═"
} else {
divider_char
}
} else {
divider_char
};
buf.set_string(x, y, char_to_draw, divider_style);
}
}
}
}
}
pub fn render_with_content<F1, F2>(
&self,
area: Rect,
buf: &mut Buffer,
state: &mut SplitPaneState,
first_pane_renderer: F1,
second_pane_renderer: F2,
registry: &mut ClickRegionRegistry<SplitPaneAction>,
) where
F1: FnOnce(Rect, &mut Buffer),
F2: FnOnce(Rect, &mut Buffer),
{
let total_size = match self.orientation {
Orientation::Horizontal => area.width,
Orientation::Vertical => area.height,
};
state.set_total_size(total_size);
let (first_area, divider_area, second_area) =
self.calculate_areas(area, state.split_percent);
registry.register(first_area, SplitPaneAction::FirstPaneClick);
registry.register(divider_area, SplitPaneAction::DividerDrag);
registry.register(second_area, SplitPaneAction::SecondPaneClick);
first_pane_renderer(first_area, buf);
second_pane_renderer(second_area, buf);
self.render_divider(state, divider_area, buf);
}
pub fn render_divider_only(
&self,
area: Rect,
buf: &mut Buffer,
state: &mut SplitPaneState,
) -> (Rect, Rect, Rect) {
let total_size = match self.orientation {
Orientation::Horizontal => area.width,
Orientation::Vertical => area.height,
};
state.set_total_size(total_size);
let (first_area, divider_area, second_area) =
self.calculate_areas(area, state.split_percent);
self.render_divider(state, divider_area, buf);
(first_area, divider_area, second_area)
}
pub fn divider_click_region(
&self,
area: Rect,
split_percent: u16,
) -> ClickRegion<SplitPaneAction> {
let (_, divider_area, _) = self.calculate_areas(area, split_percent);
ClickRegion::new(divider_area, SplitPaneAction::DividerDrag)
}
pub fn get_orientation(&self) -> Orientation {
self.orientation
}
pub fn get_min_percent(&self) -> u16 {
self.min_percent
}
pub fn get_max_percent(&self) -> u16 {
self.max_percent
}
}
impl Default for SplitPane {
fn default() -> Self {
Self::new()
}
}
pub fn handle_split_pane_key(
state: &mut SplitPaneState,
key: &crossterm::event::KeyEvent,
orientation: Orientation,
step: i16,
min_percent: u16,
max_percent: u16,
) -> bool {
use crossterm::event::KeyCode;
if !state.divider_focused {
return false;
}
match key.code {
KeyCode::Left if orientation == Orientation::Horizontal => {
state.adjust_split(-step, min_percent, max_percent);
true
}
KeyCode::Right if orientation == Orientation::Horizontal => {
state.adjust_split(step, min_percent, max_percent);
true
}
KeyCode::Up if orientation == Orientation::Vertical => {
state.adjust_split(-step, min_percent, max_percent);
true
}
KeyCode::Down if orientation == Orientation::Vertical => {
state.adjust_split(step, min_percent, max_percent);
true
}
KeyCode::Home => {
state.set_split_percent(min_percent);
true
}
KeyCode::End => {
state.set_split_percent(max_percent);
true
}
_ => false,
}
}
pub fn handle_split_pane_mouse(
state: &mut SplitPaneState,
mouse: &crossterm::event::MouseEvent,
orientation: Orientation,
registry: &ClickRegionRegistry<SplitPaneAction>,
min_percent: u16,
max_percent: u16,
) -> Option<SplitPaneAction> {
use crossterm::event::{MouseButton, MouseEventKind};
let pos = match orientation {
Orientation::Horizontal => mouse.column,
Orientation::Vertical => mouse.row,
};
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
if let Some(&action) = registry.handle_click(mouse.column, mouse.row) {
if action == SplitPaneAction::DividerDrag {
state.start_drag(pos);
}
return Some(action);
}
}
MouseEventKind::Up(MouseButton::Left) => {
if state.is_dragging {
state.end_drag();
}
}
MouseEventKind::Drag(MouseButton::Left) => {
if state.is_dragging {
state.update_drag(pos, min_percent, max_percent);
}
}
_ => {}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_state_creation() {
let state = SplitPaneState::new(30);
assert_eq!(state.split_percent, 30);
assert!(!state.is_dragging);
assert!(!state.focused);
}
#[test]
fn test_state_half() {
let state = SplitPaneState::half();
assert_eq!(state.split_percent, 50);
}
#[test]
fn test_split_percent_clamping() {
let state = SplitPaneState::new(150);
assert_eq!(state.split_percent, 100);
let mut state2 = SplitPaneState::new(50);
state2.set_split_percent(200);
assert_eq!(state2.split_percent, 100);
}
#[test]
fn test_drag_operations() {
let mut state = SplitPaneState::new(50);
state.set_total_size(100);
state.start_drag(50);
assert!(state.is_dragging);
state.update_drag(60, 10, 90);
assert_eq!(state.split_percent, 60);
state.end_drag();
assert!(!state.is_dragging);
}
#[test]
fn test_drag_respects_limits() {
let mut state = SplitPaneState::new(50);
state.set_total_size(100);
state.start_drag(50);
state.update_drag(5, 10, 90);
assert!(state.split_percent >= 10);
state.update_drag(95, 10, 90);
assert!(state.split_percent <= 90);
}
#[test]
fn test_adjust_split() {
let mut state = SplitPaneState::new(50);
state.adjust_split(10, 10, 90);
assert_eq!(state.split_percent, 60);
state.adjust_split(-20, 10, 90);
assert_eq!(state.split_percent, 40);
}
#[test]
fn test_calculate_areas_horizontal() {
let split_pane = SplitPane::new().orientation(Orientation::Horizontal);
let area = Rect::new(0, 0, 100, 50);
let (first, divider, second) = split_pane.calculate_areas(area, 50);
assert_eq!(first.width + divider.width + second.width, area.width);
assert_eq!(divider.width, 1);
}
#[test]
fn test_calculate_areas_vertical() {
let split_pane = SplitPane::new().orientation(Orientation::Vertical);
let area = Rect::new(0, 0, 100, 50);
let (first, divider, second) = split_pane.calculate_areas(area, 50);
assert_eq!(first.height + divider.height + second.height, area.height);
assert_eq!(divider.height, 1);
}
#[test]
fn test_focusable_trait() {
let mut state = SplitPaneState::new(50);
assert!(!state.is_focused());
state.set_focused(true);
assert!(state.is_focused());
state.divider_focused = true;
state.set_focused(false);
assert!(!state.divider_focused);
}
}