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 SplitOrientation {
Vertical,
Horizontal,
}
#[derive(Clone, Debug, PartialEq)]
pub enum SplitPanelMessage {
FocusOther,
FocusFirst,
FocusSecond,
GrowFirst,
ShrinkFirst,
SetRatio(f32),
ResetRatio,
}
#[derive(Clone, Debug, PartialEq)]
pub enum SplitPanelOutput {
FocusedFirst,
FocusedSecond,
RatioChanged(f32),
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
enum Pane {
First,
Second,
}
#[derive(Clone, Debug)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct SplitPanelState {
orientation: SplitOrientation,
ratio: f32,
focused_pane: Pane,
resize_step: f32,
min_ratio: f32,
max_ratio: f32,
}
impl PartialEq for SplitPanelState {
fn eq(&self, other: &Self) -> bool {
self.orientation == other.orientation
&& (self.ratio - other.ratio).abs() < f32::EPSILON
&& self.focused_pane == other.focused_pane
&& (self.resize_step - other.resize_step).abs() < f32::EPSILON
&& (self.min_ratio - other.min_ratio).abs() < f32::EPSILON
&& (self.max_ratio - other.max_ratio).abs() < f32::EPSILON
}
}
impl Default for SplitPanelState {
fn default() -> Self {
Self {
orientation: SplitOrientation::Vertical,
ratio: 0.5,
focused_pane: Pane::First,
resize_step: 0.1,
min_ratio: 0.1,
max_ratio: 0.9,
}
}
}
impl SplitPanelState {
pub fn new(orientation: SplitOrientation) -> Self {
Self {
orientation,
..Default::default()
}
}
pub fn with_ratio(mut self, ratio: f32) -> Self {
self.ratio = ratio.clamp(self.min_ratio, self.max_ratio);
self
}
pub fn orientation(&self) -> &SplitOrientation {
&self.orientation
}
pub fn set_orientation(&mut self, orientation: SplitOrientation) {
self.orientation = orientation;
}
pub fn ratio(&self) -> f32 {
self.ratio
}
pub fn set_ratio(&mut self, ratio: f32) {
self.ratio = ratio.clamp(self.min_ratio, self.max_ratio);
}
pub fn is_first_pane_focused(&self) -> bool {
self.focused_pane == Pane::First
}
pub fn is_second_pane_focused(&self) -> bool {
self.focused_pane == Pane::Second
}
pub fn resize_step(&self) -> f32 {
self.resize_step
}
pub fn with_resize_step(mut self, step: f32) -> Self {
self.resize_step = step;
self
}
pub fn with_bounds(mut self, min: f32, max: f32) -> Self {
self.min_ratio = min;
self.max_ratio = max;
self.ratio = self.ratio.clamp(min, max);
self
}
pub fn update(&mut self, msg: SplitPanelMessage) -> Option<SplitPanelOutput> {
SplitPanel::update(self, msg)
}
pub fn layout(&self, area: Rect) -> (Rect, Rect) {
let direction = match self.orientation {
SplitOrientation::Vertical => Direction::Horizontal,
SplitOrientation::Horizontal => Direction::Vertical,
};
let total = match self.orientation {
SplitOrientation::Vertical => area.width,
SplitOrientation::Horizontal => area.height,
};
let first_size = ((total as f32) * self.ratio).round() as u16;
let first_size = first_size.min(total);
let chunks = Layout::default()
.direction(direction)
.constraints([Constraint::Length(first_size), Constraint::Min(0)])
.split(area);
(chunks[0], chunks[1])
}
}
pub struct SplitPanel;
impl Component for SplitPanel {
type State = SplitPanelState;
type Message = SplitPanelMessage;
type Output = SplitPanelOutput;
fn init() -> Self::State {
SplitPanelState::default()
}
fn handle_event(
_state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled {
return None;
}
if let Some(key) = event.as_key() {
if key.code == Key::Tab {
return Some(SplitPanelMessage::FocusOther);
}
if key.modifiers.ctrl() {
match key.code {
Key::Left | Key::Up => return Some(SplitPanelMessage::ShrinkFirst),
Key::Right | Key::Down => return Some(SplitPanelMessage::GrowFirst),
Key::Char('0') => return Some(SplitPanelMessage::ResetRatio),
_ => {}
}
}
None
} else {
None
}
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
SplitPanelMessage::FocusOther => {
state.focused_pane = match state.focused_pane {
Pane::First => Pane::Second,
Pane::Second => Pane::First,
};
match state.focused_pane {
Pane::First => Some(SplitPanelOutput::FocusedFirst),
Pane::Second => Some(SplitPanelOutput::FocusedSecond),
}
}
SplitPanelMessage::FocusFirst => {
if state.focused_pane != Pane::First {
state.focused_pane = Pane::First;
Some(SplitPanelOutput::FocusedFirst)
} else {
None
}
}
SplitPanelMessage::FocusSecond => {
if state.focused_pane != Pane::Second {
state.focused_pane = Pane::Second;
Some(SplitPanelOutput::FocusedSecond)
} else {
None
}
}
SplitPanelMessage::GrowFirst => {
let new_ratio = (state.ratio + state.resize_step).min(state.max_ratio);
if (new_ratio - state.ratio).abs() > f32::EPSILON {
state.ratio = new_ratio;
Some(SplitPanelOutput::RatioChanged(new_ratio))
} else {
None
}
}
SplitPanelMessage::ShrinkFirst => {
let new_ratio = (state.ratio - state.resize_step).max(state.min_ratio);
if (new_ratio - state.ratio).abs() > f32::EPSILON {
state.ratio = new_ratio;
Some(SplitPanelOutput::RatioChanged(new_ratio))
} else {
None
}
}
SplitPanelMessage::SetRatio(ratio) => {
let clamped = ratio.clamp(state.min_ratio, state.max_ratio);
if (clamped - state.ratio).abs() > f32::EPSILON {
state.ratio = clamped;
Some(SplitPanelOutput::RatioChanged(clamped))
} else {
None
}
}
SplitPanelMessage::ResetRatio => {
let target = 0.5_f32.clamp(state.min_ratio, state.max_ratio);
if (target - state.ratio).abs() > f32::EPSILON {
state.ratio = target;
Some(SplitPanelOutput::RatioChanged(target))
} else {
None
}
}
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
crate::annotation::with_registry(|reg| {
reg.open(
ctx.area,
crate::annotation::Annotation::new(crate::annotation::WidgetType::SplitPanel)
.with_id("split_panel")
.with_focus(ctx.focused)
.with_disabled(ctx.disabled),
);
});
let (first_area, second_area) = state.layout(ctx.area);
let first_focused = ctx.focused && state.focused_pane == Pane::First;
let second_focused = ctx.focused && state.focused_pane == Pane::Second;
let first_border = if ctx.disabled {
ctx.theme.disabled_style()
} else if first_focused {
ctx.theme.focused_border_style()
} else {
ctx.theme.border_style()
};
let second_border = if ctx.disabled {
ctx.theme.disabled_style()
} else if second_focused {
ctx.theme.focused_border_style()
} else {
ctx.theme.border_style()
};
let first_block = Block::default()
.borders(Borders::ALL)
.border_style(first_border)
.title(" Pane 1 ");
let second_block = Block::default()
.borders(Borders::ALL)
.border_style(second_border)
.title(" Pane 2 ");
ctx.frame.render_widget(first_block, first_area);
ctx.frame.render_widget(second_block, second_area);
crate::annotation::with_registry(|reg| {
reg.close();
});
}
}
#[cfg(test)]
mod snapshot_tests;
#[cfg(test)]
mod tests;