use crate::event::Key;
use crate::layout::Rect;
use crate::render::Cell;
use crate::style::Color;
use crate::widget::theme::DARK_GRAY;
use crate::widget::traits::{RenderContext, View, WidgetProps};
use crate::{impl_props_builders, impl_styled_view};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SplitOrientation {
#[default]
Horizontal,
Vertical,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SplitterStyle {
#[default]
Line,
Double,
Thick,
Hidden,
}
impl SplitterStyle {
fn char(&self, orientation: SplitOrientation) -> char {
match (self, orientation) {
(SplitterStyle::Line, SplitOrientation::Horizontal) => '│',
(SplitterStyle::Line, SplitOrientation::Vertical) => '─',
(SplitterStyle::Double, SplitOrientation::Horizontal) => '║',
(SplitterStyle::Double, SplitOrientation::Vertical) => '═',
(SplitterStyle::Thick, SplitOrientation::Horizontal) => '┃',
(SplitterStyle::Thick, SplitOrientation::Vertical) => '━',
(SplitterStyle::Hidden, _) => ' ',
}
}
}
pub struct Pane {
pub id: String,
pub min_size: u16,
pub max_size: u16,
pub ratio: f32,
pub collapsible: bool,
pub collapsed: bool,
}
impl Pane {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
min_size: 5,
max_size: 0,
ratio: 0.5,
collapsible: false,
collapsed: false,
}
}
pub fn min_size(mut self, size: u16) -> Self {
self.min_size = size;
self
}
pub fn max_size(mut self, size: u16) -> Self {
self.max_size = size;
self
}
pub fn ratio(mut self, ratio: f32) -> Self {
self.ratio = ratio.clamp(0.0, 1.0);
self
}
pub fn collapsible(mut self) -> Self {
self.collapsible = true;
self
}
pub fn toggle_collapse(&mut self) {
if self.collapsible {
self.collapsed = !self.collapsed;
}
}
}
pub struct Splitter {
panes: Vec<Pane>,
orientation: SplitOrientation,
style: SplitterStyle,
color: Color,
active_color: Color,
active_divider: Option<usize>,
focused_pane: usize,
splitter_width: u16,
props: WidgetProps,
}
impl Splitter {
pub fn new() -> Self {
Self {
panes: Vec::new(),
orientation: SplitOrientation::Horizontal,
style: SplitterStyle::Line,
color: DARK_GRAY,
active_color: Color::CYAN,
active_divider: None,
focused_pane: 0,
splitter_width: 1,
props: WidgetProps::new(),
}
}
pub fn orientation(mut self, orientation: SplitOrientation) -> Self {
self.orientation = orientation;
self
}
pub fn horizontal(mut self) -> Self {
self.orientation = SplitOrientation::Horizontal;
self
}
pub fn vertical(mut self) -> Self {
self.orientation = SplitOrientation::Vertical;
self
}
pub fn pane(mut self, pane: Pane) -> Self {
self.panes.push(pane);
self
}
pub fn panes(mut self, panes: Vec<Pane>) -> Self {
self.panes.extend(panes);
self
}
pub fn style(mut self, style: SplitterStyle) -> Self {
self.style = style;
self
}
pub fn color(mut self, color: Color) -> Self {
self.color = color;
self
}
pub fn active_color(mut self, color: Color) -> Self {
self.active_color = color;
self
}
pub fn pane_areas(&self, area: Rect) -> Vec<(String, Rect)> {
let mut areas = Vec::new();
let visible_panes: Vec<_> = self.panes.iter().filter(|p| !p.collapsed).collect();
if visible_panes.is_empty() {
return areas;
}
let total_splitter_width =
(visible_panes.len().saturating_sub(1)) as u16 * self.splitter_width;
let available = match self.orientation {
SplitOrientation::Horizontal => area.width.saturating_sub(total_splitter_width),
SplitOrientation::Vertical => area.height.saturating_sub(total_splitter_width),
};
let total_ratio: f32 = visible_panes.iter().map(|p| p.ratio).sum();
let mut offset = 0u16;
for (i, pane) in visible_panes.iter().enumerate() {
let ratio = if total_ratio > 0.0 {
pane.ratio / total_ratio
} else {
1.0 / visible_panes.len() as f32
};
let size = (available as f32 * ratio).clamp(0.0, available as f32);
let mut size = size as u16;
size = size.max(pane.min_size);
if pane.max_size > 0 {
size = size.min(pane.max_size);
}
if i == visible_panes.len() - 1 {
size = available.saturating_sub(offset);
}
let pane_area = match self.orientation {
SplitOrientation::Horizontal => {
Rect::new(area.x + offset, area.y, size, area.height)
}
SplitOrientation::Vertical => Rect::new(area.x, area.y + offset, area.width, size),
};
areas.push((pane.id.clone(), pane_area));
offset += size + self.splitter_width;
}
areas
}
pub fn focused(&self) -> Option<&str> {
self.panes.get(self.focused_pane).map(|p| p.id.as_str())
}
pub fn focus_next(&mut self) {
let visible: Vec<_> = self
.panes
.iter()
.enumerate()
.filter(|(_, p)| !p.collapsed)
.map(|(i, _)| i)
.collect();
if let Some(pos) = visible.iter().position(|&i| i == self.focused_pane) {
let next = (pos + 1) % visible.len();
self.focused_pane = visible[next];
}
}
pub fn focus_prev(&mut self) {
let visible: Vec<_> = self
.panes
.iter()
.enumerate()
.filter(|(_, p)| !p.collapsed)
.map(|(i, _)| i)
.collect();
if let Some(pos) = visible.iter().position(|&i| i == self.focused_pane) {
let prev = if pos == 0 { visible.len() - 1 } else { pos - 1 };
self.focused_pane = visible[prev];
}
}
pub fn start_resize(&mut self, divider: usize) {
if divider < self.panes.len() - 1 {
self.active_divider = Some(divider);
}
}
pub fn stop_resize(&mut self) {
self.active_divider = None;
}
pub fn resize(&mut self, delta: i16) {
if let Some(divider) = self.active_divider {
if divider < self.panes.len() - 1 {
let current_ratio = self.panes[divider].ratio;
let next_ratio = self.panes[divider + 1].ratio;
let change = delta as f32 * 0.01;
let new_current = (current_ratio + change).clamp(0.1, 0.9);
let new_next = (next_ratio - change).clamp(0.1, 0.9);
self.panes[divider].ratio = new_current;
self.panes[divider + 1].ratio = new_next;
}
}
}
pub fn toggle_pane(&mut self, index: usize) {
if let Some(pane) = self.panes.get_mut(index) {
pane.toggle_collapse();
}
}
pub fn handle_key(&mut self, key: &Key) -> bool {
match key {
Key::Tab => {
self.focus_next();
true
}
Key::Left | Key::Char('h') if self.active_divider.is_some() => {
self.resize(-5);
true
}
Key::Right | Key::Char('l') if self.active_divider.is_some() => {
self.resize(5);
true
}
Key::Up | Key::Char('k') if self.active_divider.is_some() => {
self.resize(-5);
true
}
Key::Down | Key::Char('j') if self.active_divider.is_some() => {
self.resize(5);
true
}
Key::Enter if self.active_divider.is_some() => {
self.stop_resize();
true
}
Key::Escape if self.active_divider.is_some() => {
self.stop_resize();
true
}
_ => false,
}
}
}
impl Default for Splitter {
fn default() -> Self {
Self::new()
}
}
impl View for Splitter {
crate::impl_view_meta!("Splitter");
fn render(&self, ctx: &mut RenderContext) {
let area = ctx.area;
let areas = self.pane_areas(area);
for (i, (_, pane_area)) in areas.iter().enumerate().take(areas.len().saturating_sub(1)) {
let is_active = self.active_divider == Some(i);
let color = if is_active {
self.active_color
} else {
self.color
};
let ch = self.style.char(self.orientation);
match self.orientation {
SplitOrientation::Horizontal => {
let x = pane_area.x + pane_area.width - area.x;
for y in 0..area.height {
let mut cell = Cell::new(ch);
cell.fg = Some(color);
ctx.set(x, y, cell);
}
}
SplitOrientation::Vertical => {
let y = pane_area.y + pane_area.height - area.y;
for x in 0..area.width {
let mut cell = Cell::new(ch);
cell.fg = Some(color);
ctx.set(x, y, cell);
}
}
}
}
}
}
impl_styled_view!(Splitter);
impl_props_builders!(Splitter);
pub struct HSplit {
pub ratio: f32,
pub min_left: u16,
pub min_right: u16,
pub show_splitter: bool,
pub color: Color,
}
impl HSplit {
pub fn new(ratio: f32) -> Self {
Self {
ratio: ratio.clamp(0.1, 0.9),
min_left: 5,
min_right: 5,
show_splitter: true,
color: DARK_GRAY,
}
}
pub fn min_widths(mut self, left: u16, right: u16) -> Self {
self.min_left = left;
self.min_right = right;
self
}
pub fn hide_splitter(mut self) -> Self {
self.show_splitter = false;
self
}
pub fn areas(&self, area: Rect) -> (Rect, Rect) {
let splitter_width = if self.show_splitter { 1 } else { 0 };
let available = area.width.saturating_sub(splitter_width);
let left_width = (available as f32 * self.ratio).clamp(0.0, available as f32);
let mut left_width = left_width as u16;
left_width = left_width.max(self.min_left);
left_width = left_width.min(available.saturating_sub(self.min_right));
let right_width = available.saturating_sub(left_width);
let left = Rect::new(area.x, area.y, left_width, area.height);
let right = Rect::new(
area.x + left_width + splitter_width,
area.y,
right_width,
area.height,
);
(left, right)
}
}
impl View for HSplit {
fn render(&self, ctx: &mut RenderContext) {
if !self.show_splitter {
return;
}
let (left, _) = self.areas(ctx.area);
let x = left.x + left.width - ctx.area.x;
for y in 0..ctx.area.height {
let mut cell = Cell::new('│');
cell.fg = Some(self.color);
ctx.set(x, y, cell);
}
}
}
pub struct VSplit {
pub ratio: f32,
pub min_top: u16,
pub min_bottom: u16,
pub show_splitter: bool,
pub color: Color,
}
impl VSplit {
pub fn new(ratio: f32) -> Self {
Self {
ratio: ratio.clamp(0.1, 0.9),
min_top: 3,
min_bottom: 3,
show_splitter: true,
color: DARK_GRAY,
}
}
pub fn areas(&self, area: Rect) -> (Rect, Rect) {
let splitter_height = if self.show_splitter { 1 } else { 0 };
let available = area.height.saturating_sub(splitter_height);
let top_height = (available as f32 * self.ratio).clamp(0.0, available as f32);
let mut top_height = top_height as u16;
top_height = top_height.max(self.min_top);
top_height = top_height.min(available.saturating_sub(self.min_bottom));
let bottom_height = available.saturating_sub(top_height);
let top = Rect::new(area.x, area.y, area.width, top_height);
let bottom = Rect::new(
area.x,
area.y + top_height + splitter_height,
area.width,
bottom_height,
);
(top, bottom)
}
}
impl View for VSplit {
fn render(&self, ctx: &mut RenderContext) {
if !self.show_splitter {
return;
}
let (top, _) = self.areas(ctx.area);
let y = top.y + top.height - ctx.area.y;
for x in 0..ctx.area.width {
let mut cell = Cell::new('─');
cell.fg = Some(self.color);
ctx.set(x, y, cell);
}
}
}
pub fn splitter() -> Splitter {
Splitter::new()
}
pub fn pane(id: impl Into<String>) -> Pane {
Pane::new(id)
}
pub fn hsplit(ratio: f32) -> HSplit {
HSplit::new(ratio)
}
pub fn vsplit(ratio: f32) -> VSplit {
VSplit::new(ratio)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::Buffer;
#[test]
fn test_pane_new() {
let p = Pane::new("left");
assert_eq!(p.id, "left");
assert_eq!(p.ratio, 0.5);
assert_eq!(p.min_size, 5);
assert!(!p.collapsible);
assert!(!p.collapsed);
}
#[test]
fn test_pane_builder() {
let p = Pane::new("main")
.ratio(0.7)
.min_size(10)
.max_size(50)
.collapsible();
assert_eq!(p.ratio, 0.7);
assert_eq!(p.min_size, 10);
assert_eq!(p.max_size, 50);
assert!(p.collapsible);
}
#[test]
fn test_pane_ratio_clamped() {
let p = Pane::new("x").ratio(1.5);
assert_eq!(p.ratio, 1.0);
let p = Pane::new("x").ratio(-0.5);
assert_eq!(p.ratio, 0.0);
}
#[test]
fn test_pane_toggle_collapse() {
let mut p = Pane::new("x").collapsible();
assert!(!p.collapsed);
p.toggle_collapse();
assert!(p.collapsed);
p.toggle_collapse();
assert!(!p.collapsed);
}
#[test]
fn test_pane_toggle_collapse_not_collapsible() {
let mut p = Pane::new("x");
p.toggle_collapse(); assert!(!p.collapsed);
}
#[test]
fn test_splitter_new() {
let s = Splitter::new();
assert_eq!(s.orientation, SplitOrientation::Horizontal);
assert!(s.panes.is_empty());
}
#[test]
fn test_splitter_orientation() {
let s = Splitter::new().vertical();
assert_eq!(s.orientation, SplitOrientation::Vertical);
let s = Splitter::new().horizontal();
assert_eq!(s.orientation, SplitOrientation::Horizontal);
}
#[test]
fn test_splitter_pane_areas_horizontal() {
let s = Splitter::new()
.pane(Pane::new("left").ratio(0.5))
.pane(Pane::new("right").ratio(0.5));
let area = Rect::new(0, 0, 81, 24); let areas = s.pane_areas(area);
assert_eq!(areas.len(), 2);
assert_eq!(areas[0].0, "left");
assert_eq!(areas[1].0, "right");
assert!(areas[0].1.width > 0);
assert!(areas[1].1.width > 0);
}
#[test]
fn test_splitter_pane_areas_empty() {
let s = Splitter::new();
let area = Rect::new(0, 0, 80, 24);
let areas = s.pane_areas(area);
assert!(areas.is_empty());
}
#[test]
fn test_splitter_focus_navigation() {
let mut s = Splitter::new()
.pane(Pane::new("a"))
.pane(Pane::new("b"))
.pane(Pane::new("c"));
assert_eq!(s.focused(), Some("a"));
s.focus_next();
assert_eq!(s.focused(), Some("b"));
s.focus_next();
assert_eq!(s.focused(), Some("c"));
s.focus_next(); assert_eq!(s.focused(), Some("a"));
s.focus_prev(); assert_eq!(s.focused(), Some("c"));
}
#[test]
fn test_splitter_style_char() {
assert_eq!(SplitterStyle::Line.char(SplitOrientation::Horizontal), '│');
assert_eq!(SplitterStyle::Line.char(SplitOrientation::Vertical), '─');
assert_eq!(
SplitterStyle::Double.char(SplitOrientation::Horizontal),
'║'
);
assert_eq!(
SplitterStyle::Hidden.char(SplitOrientation::Horizontal),
' '
);
}
#[test]
fn test_hsplit_new() {
let h = hsplit(0.3);
assert_eq!(h.ratio, 0.3);
}
#[test]
fn test_vsplit_new() {
let v = vsplit(0.6);
assert_eq!(v.ratio, 0.6);
}
#[test]
fn test_hsplit_areas() {
let h = HSplit::new(0.5);
let area = Rect::new(0, 0, 81, 24);
let (left, right) = h.areas(area);
assert!(left.width > 0);
assert!(right.width > 0);
assert_eq!(left.height, 24);
assert_eq!(right.height, 24);
}
#[test]
fn test_vsplit_areas() {
let v = VSplit::new(0.5);
let area = Rect::new(0, 0, 80, 25);
let (top, bottom) = v.areas(area);
assert!(top.height > 0);
assert!(bottom.height > 0);
assert_eq!(top.width, 80);
assert_eq!(bottom.width, 80);
}
#[test]
fn test_splitter_render_no_panic() {
let mut buf = Buffer::new(80, 24);
let area = Rect::new(0, 0, 80, 24);
let mut ctx = RenderContext::new(&mut buf, area);
let s = Splitter::new()
.pane(Pane::new("a").ratio(0.5))
.pane(Pane::new("b").ratio(0.5));
s.render(&mut ctx);
}
}