use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders};
use super::{Component, EventContext, RenderContext};
use crate::input::{Event, Key};
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum PaneDirection {
Horizontal,
Vertical,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct PaneConfig {
id: String,
title: Option<String>,
proportion: f32,
min_size: u16,
max_size: u16,
}
impl PaneConfig {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
title: None,
proportion: 1.0,
min_size: 1,
max_size: 0,
}
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_proportion(mut self, proportion: f32) -> Self {
self.proportion = proportion.max(0.0);
self
}
pub fn with_min_size(mut self, min_size: u16) -> Self {
self.min_size = min_size.max(1);
self
}
pub fn with_max_size(mut self, max_size: u16) -> Self {
self.max_size = max_size;
self
}
pub fn id(&self) -> &str {
&self.id
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn set_title(&mut self, title: impl Into<String>) {
self.title = Some(title.into());
}
pub fn proportion(&self) -> f32 {
self.proportion
}
pub fn min_size(&self) -> u16 {
self.min_size
}
pub fn set_min_size(&mut self, min_size: u16) {
self.min_size = min_size;
}
pub fn max_size(&self) -> u16 {
self.max_size
}
pub fn set_max_size(&mut self, max_size: u16) {
self.max_size = max_size;
}
pub fn set_proportion(&mut self, proportion: f32) {
self.proportion = proportion;
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum PaneLayoutMessage {
FocusNext,
FocusPrev,
FocusPane(String),
FocusPaneIndex(usize),
GrowFocused,
ShrinkFocused,
GrowPane(String),
ShrinkPane(String),
SetProportion {
id: String,
proportion: f32,
},
ResetProportions,
}
#[derive(Clone, Debug, PartialEq)]
pub enum PaneLayoutOutput {
FocusChanged {
pane_id: String,
index: usize,
},
ProportionChanged {
pane_id: String,
proportion: f32,
},
ProportionsReset,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct PaneLayoutState {
direction: PaneDirection,
panes: Vec<PaneConfig>,
focused_pane: usize,
resize_step: f32,
}
impl Default for PaneLayoutState {
fn default() -> Self {
Self {
direction: PaneDirection::Horizontal,
panes: Vec::new(),
focused_pane: 0,
resize_step: 0.05,
}
}
}
impl PaneLayoutState {
pub fn new(direction: PaneDirection, panes: Vec<PaneConfig>) -> Self {
let mut state = Self {
direction,
panes,
..Self::default()
};
state.normalize_proportions();
state
}
pub fn with_resize_step(mut self, step: f32) -> Self {
self.resize_step = step.clamp(0.01, 0.5);
self
}
pub fn layout(&self, area: Rect) -> Vec<Rect> {
if self.panes.is_empty() {
return vec![];
}
let total = match self.direction {
PaneDirection::Horizontal => area.width,
PaneDirection::Vertical => area.height,
};
let sizes = self.compute_sizes(total);
let mut rects = Vec::with_capacity(self.panes.len());
let mut offset = 0u16;
for (i, &size) in sizes.iter().enumerate() {
let rect = match self.direction {
PaneDirection::Horizontal => Rect::new(
area.x + offset,
area.y,
if i == sizes.len() - 1 {
total.saturating_sub(offset)
} else {
size
},
area.height,
),
PaneDirection::Vertical => Rect::new(
area.x,
area.y + offset,
area.width,
if i == sizes.len() - 1 {
total.saturating_sub(offset)
} else {
size
},
),
};
rects.push(rect);
offset += size;
}
rects
}
pub fn pane_area(&self, area: Rect, pane_id: &str) -> Option<Rect> {
let index = self.panes.iter().position(|p| p.id == pane_id)?;
let rects = self.layout(area);
rects.into_iter().nth(index)
}
pub fn direction(&self) -> &PaneDirection {
&self.direction
}
pub fn panes(&self) -> &[PaneConfig] {
&self.panes
}
pub fn pane_count(&self) -> usize {
self.panes.len()
}
pub fn focused_pane_index(&self) -> usize {
self.focused_pane
}
pub fn focused_pane_id(&self) -> Option<&str> {
self.panes.get(self.focused_pane).map(|p| p.id.as_str())
}
pub fn pane(&self, id: &str) -> Option<&PaneConfig> {
self.panes.iter().find(|p| p.id == id)
}
pub fn resize_step(&self) -> f32 {
self.resize_step
}
pub fn update(&mut self, msg: PaneLayoutMessage) -> Option<PaneLayoutOutput> {
PaneLayout::update(self, msg)
}
fn normalize_proportions(&mut self) {
let total: f32 = self.panes.iter().map(|p| p.proportion).sum();
if total > 0.0 {
for pane in &mut self.panes {
pane.proportion /= total;
}
}
}
fn compute_sizes(&self, total: u16) -> Vec<u16> {
let n = self.panes.len();
if n == 0 {
return vec![];
}
let total_f = total as f32;
let mut sizes: Vec<u16> = self
.panes
.iter()
.map(|p| {
let raw = (p.proportion * total_f).round() as u16;
let clamped_min = raw.max(p.min_size);
if p.max_size > 0 {
clamped_min.min(p.max_size)
} else {
clamped_min
}
})
.collect();
let computed_total: u16 = sizes.iter().sum();
if computed_total != total && !sizes.is_empty() {
let diff = total as i32 - computed_total as i32;
let last = sizes.len() - 1;
sizes[last] = (sizes[last] as i32 + diff).max(1) as u16;
}
sizes
}
fn grow_pane(&mut self, index: usize) -> Option<PaneLayoutOutput> {
if self.panes.len() < 2 || index >= self.panes.len() {
return None;
}
let step = self.resize_step;
let min_proportion = 0.05;
let neighbor = if index + 1 < self.panes.len() {
index + 1
} else {
index - 1
};
if self.panes[neighbor].proportion - step < min_proportion {
return None;
}
self.panes[index].proportion += step;
self.panes[neighbor].proportion -= step;
self.normalize_proportions();
Some(PaneLayoutOutput::ProportionChanged {
pane_id: self.panes[index].id.clone(),
proportion: self.panes[index].proportion,
})
}
fn shrink_pane(&mut self, index: usize) -> Option<PaneLayoutOutput> {
if self.panes.len() < 2 || index >= self.panes.len() {
return None;
}
let step = self.resize_step;
let min_proportion = 0.05;
if self.panes[index].proportion - step < min_proportion {
return None;
}
let neighbor = if index + 1 < self.panes.len() {
index + 1
} else {
index - 1
};
self.panes[index].proportion -= step;
self.panes[neighbor].proportion += step;
self.normalize_proportions();
Some(PaneLayoutOutput::ProportionChanged {
pane_id: self.panes[index].id.clone(),
proportion: self.panes[index].proportion,
})
}
}
pub struct PaneLayout;
impl Component for PaneLayout {
type State = PaneLayoutState;
type Message = PaneLayoutMessage;
type Output = PaneLayoutOutput;
fn init() -> Self::State {
PaneLayoutState::default()
}
fn handle_event(
_state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled {
return None;
}
let key = event.as_key()?;
let ctrl = key.modifiers.ctrl();
match key.code {
Key::Tab if key.modifiers.shift() => Some(PaneLayoutMessage::FocusPrev),
Key::Tab if !ctrl => Some(PaneLayoutMessage::FocusNext),
Key::Right | Key::Down if ctrl => Some(PaneLayoutMessage::GrowFocused),
Key::Left | Key::Up if ctrl => Some(PaneLayoutMessage::ShrinkFocused),
Key::Char('0') if ctrl => Some(PaneLayoutMessage::ResetProportions),
_ => None,
}
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
PaneLayoutMessage::FocusNext => {
if state.panes.is_empty() {
return None;
}
state.focused_pane = (state.focused_pane + 1) % state.panes.len();
Some(PaneLayoutOutput::FocusChanged {
pane_id: state.panes[state.focused_pane].id.clone(),
index: state.focused_pane,
})
}
PaneLayoutMessage::FocusPrev => {
if state.panes.is_empty() {
return None;
}
state.focused_pane = state
.focused_pane
.checked_sub(1)
.unwrap_or(state.panes.len() - 1);
Some(PaneLayoutOutput::FocusChanged {
pane_id: state.panes[state.focused_pane].id.clone(),
index: state.focused_pane,
})
}
PaneLayoutMessage::FocusPane(id) => {
if let Some(index) = state.panes.iter().position(|p| p.id == id) {
state.focused_pane = index;
Some(PaneLayoutOutput::FocusChanged { pane_id: id, index })
} else {
None
}
}
PaneLayoutMessage::FocusPaneIndex(index) => {
if index >= state.panes.len() {
return None;
}
state.focused_pane = index;
Some(PaneLayoutOutput::FocusChanged {
pane_id: state.panes[index].id.clone(),
index,
})
}
PaneLayoutMessage::GrowFocused => {
let index = state.focused_pane;
state.grow_pane(index)
}
PaneLayoutMessage::ShrinkFocused => {
let index = state.focused_pane;
state.shrink_pane(index)
}
PaneLayoutMessage::GrowPane(id) => {
if let Some(index) = state.panes.iter().position(|p| p.id == id) {
state.grow_pane(index)
} else {
None
}
}
PaneLayoutMessage::ShrinkPane(id) => {
if let Some(index) = state.panes.iter().position(|p| p.id == id) {
state.shrink_pane(index)
} else {
None
}
}
PaneLayoutMessage::SetProportion { id, proportion } => {
if let Some(index) = state.panes.iter().position(|p| p.id == id) {
state.panes[index].proportion = proportion.max(0.0);
state.normalize_proportions();
Some(PaneLayoutOutput::ProportionChanged {
pane_id: id,
proportion: state.panes[index].proportion,
})
} else {
None
}
}
PaneLayoutMessage::ResetProportions => {
if state.panes.is_empty() {
return None;
}
let equal = 1.0 / state.panes.len() as f32;
for pane in &mut state.panes {
pane.proportion = equal;
}
Some(PaneLayoutOutput::ProportionsReset)
}
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
crate::annotation::with_registry(|reg| {
reg.register(
ctx.area,
crate::annotation::Annotation::new(crate::annotation::WidgetType::PaneLayout)
.with_id("pane_layout")
.with_focus(ctx.focused)
.with_disabled(ctx.disabled),
);
});
let rects = state.layout(ctx.area);
for (i, (pane, rect)) in state.panes.iter().zip(rects.iter()).enumerate() {
let is_focused_pane = ctx.focused && i == state.focused_pane;
let border_style = if ctx.disabled {
ctx.theme.disabled_style()
} else if is_focused_pane {
ctx.theme.focused_border_style()
} else {
ctx.theme.border_style()
};
let mut block = Block::default()
.borders(Borders::ALL)
.border_style(border_style);
if let Some(title) = &pane.title {
block = block.title(format!(" {} ", title));
}
ctx.frame.render_widget(block, *rect);
}
}
}
#[cfg(test)]
mod tests;