Skip to main content

envision/component/split_panel/
mod.rs

1//! A resizable split panel layout component.
2//!
3//! `SplitPanel` divides an area into two panes (horizontal or vertical)
4//! with a draggable split ratio. The parent controls what to render in
5//! each pane — this component only manages the layout and focus.
6//!
7//! # Example
8//!
9//! ```rust
10//! use envision::component::{
11//!     Component, Focusable, SplitPanel, SplitPanelState,
12//!     SplitPanelMessage, SplitOrientation,
13//! };
14//!
15//! let mut state = SplitPanelState::new(SplitOrientation::Vertical);
16//! SplitPanel::set_focused(&mut state, true);
17//!
18//! assert_eq!(state.ratio(), 0.5);
19//! assert!(state.is_first_pane_focused());
20//!
21//! // Resize: shift split 10% to the right
22//! SplitPanel::update(&mut state, SplitPanelMessage::GrowFirst);
23//! assert!((state.ratio() - 0.6).abs() < f32::EPSILON);
24//!
25//! // Toggle focus to the second pane
26//! SplitPanel::update(&mut state, SplitPanelMessage::FocusOther);
27//! assert!(state.is_second_pane_focused());
28//! ```
29
30use ratatui::prelude::*;
31use ratatui::widgets::{Block, Borders};
32
33use super::{Component, Focusable};
34use crate::input::{Event, KeyCode, KeyModifiers};
35use crate::theme::Theme;
36
37/// The orientation of a split panel.
38#[derive(Clone, Debug, PartialEq, Eq)]
39#[cfg_attr(
40    feature = "serialization",
41    derive(serde::Serialize, serde::Deserialize)
42)]
43pub enum SplitOrientation {
44    /// Panes are arranged left-right (vertical divider).
45    Vertical,
46    /// Panes are arranged top-bottom (horizontal divider).
47    Horizontal,
48}
49
50/// Messages that can be sent to a SplitPanel.
51#[derive(Clone, Debug, PartialEq)]
52pub enum SplitPanelMessage {
53    /// Toggle focus between the two panes.
54    FocusOther,
55    /// Focus the first pane.
56    FocusFirst,
57    /// Focus the second pane.
58    FocusSecond,
59    /// Increase the first pane's share by the resize step.
60    GrowFirst,
61    /// Decrease the first pane's share by the resize step.
62    ShrinkFirst,
63    /// Set the split ratio directly (0.0 to 1.0).
64    SetRatio(f32),
65    /// Reset the split to 50/50.
66    ResetRatio,
67}
68
69/// Output messages from a SplitPanel.
70#[derive(Clone, Debug, PartialEq)]
71pub enum SplitPanelOutput {
72    /// Focus changed to the first pane.
73    FocusedFirst,
74    /// Focus changed to the second pane.
75    FocusedSecond,
76    /// The split ratio changed.
77    RatioChanged(f32),
78}
79
80/// Identifies which pane has focus.
81#[derive(Clone, Debug, PartialEq, Eq)]
82#[cfg_attr(
83    feature = "serialization",
84    derive(serde::Serialize, serde::Deserialize)
85)]
86enum Pane {
87    First,
88    Second,
89}
90
91/// State for a SplitPanel component.
92///
93/// Manages the split ratio, orientation, and which pane has focus.
94/// The parent is responsible for rendering content into each pane.
95#[derive(Clone, Debug)]
96#[cfg_attr(
97    feature = "serialization",
98    derive(serde::Serialize, serde::Deserialize)
99)]
100pub struct SplitPanelState {
101    /// The orientation of the split.
102    orientation: SplitOrientation,
103    /// Split ratio: 0.0 = all second pane, 1.0 = all first pane.
104    ratio: f32,
105    /// Which pane currently has focus.
106    focused_pane: Pane,
107    /// Whether the overall component is focused.
108    focused: bool,
109    /// Whether the component is disabled.
110    disabled: bool,
111    /// How much the ratio changes per resize step.
112    resize_step: f32,
113    /// Minimum ratio (prevents collapsing first pane).
114    min_ratio: f32,
115    /// Maximum ratio (prevents collapsing second pane).
116    max_ratio: f32,
117}
118
119impl PartialEq for SplitPanelState {
120    fn eq(&self, other: &Self) -> bool {
121        self.orientation == other.orientation
122            && (self.ratio - other.ratio).abs() < f32::EPSILON
123            && self.focused_pane == other.focused_pane
124            && self.focused == other.focused
125            && self.disabled == other.disabled
126            && (self.resize_step - other.resize_step).abs() < f32::EPSILON
127            && (self.min_ratio - other.min_ratio).abs() < f32::EPSILON
128            && (self.max_ratio - other.max_ratio).abs() < f32::EPSILON
129    }
130}
131
132impl Default for SplitPanelState {
133    fn default() -> Self {
134        Self {
135            orientation: SplitOrientation::Vertical,
136            ratio: 0.5,
137            focused_pane: Pane::First,
138            focused: false,
139            disabled: false,
140            resize_step: 0.1,
141            min_ratio: 0.1,
142            max_ratio: 0.9,
143        }
144    }
145}
146
147impl SplitPanelState {
148    /// Creates a new split panel with the given orientation and a 50/50 split.
149    ///
150    /// # Example
151    ///
152    /// ```rust
153    /// use envision::component::{SplitPanelState, SplitOrientation};
154    ///
155    /// let state = SplitPanelState::new(SplitOrientation::Horizontal);
156    /// assert_eq!(state.ratio(), 0.5);
157    /// assert_eq!(state.orientation(), &SplitOrientation::Horizontal);
158    /// ```
159    pub fn new(orientation: SplitOrientation) -> Self {
160        Self {
161            orientation,
162            ..Default::default()
163        }
164    }
165
166    /// Creates a split panel with a custom ratio.
167    ///
168    /// The ratio is clamped to `[min_ratio, max_ratio]`.
169    ///
170    /// # Example
171    ///
172    /// ```rust
173    /// use envision::component::{SplitPanelState, SplitOrientation};
174    ///
175    /// let state = SplitPanelState::with_ratio(SplitOrientation::Vertical, 0.3);
176    /// assert!((state.ratio() - 0.3).abs() < f32::EPSILON);
177    /// ```
178    pub fn with_ratio(orientation: SplitOrientation, ratio: f32) -> Self {
179        let mut state = Self::new(orientation);
180        state.ratio = ratio.clamp(state.min_ratio, state.max_ratio);
181        state
182    }
183
184    /// Returns the current orientation.
185    pub fn orientation(&self) -> &SplitOrientation {
186        &self.orientation
187    }
188
189    /// Sets the orientation.
190    pub fn set_orientation(&mut self, orientation: SplitOrientation) {
191        self.orientation = orientation;
192    }
193
194    /// Returns the current split ratio.
195    ///
196    /// 0.5 means equal split. Values closer to 1.0 give more space
197    /// to the first pane.
198    pub fn ratio(&self) -> f32 {
199        self.ratio
200    }
201
202    /// Sets the split ratio, clamped to `[min_ratio, max_ratio]`.
203    pub fn set_ratio(&mut self, ratio: f32) {
204        self.ratio = ratio.clamp(self.min_ratio, self.max_ratio);
205    }
206
207    /// Returns true if the first pane has focus.
208    pub fn is_first_pane_focused(&self) -> bool {
209        self.focused_pane == Pane::First
210    }
211
212    /// Returns true if the second pane has focus.
213    pub fn is_second_pane_focused(&self) -> bool {
214        self.focused_pane == Pane::Second
215    }
216
217    /// Returns the resize step size (default 0.1 = 10%).
218    pub fn resize_step(&self) -> f32 {
219        self.resize_step
220    }
221
222    /// Sets the resize step size.
223    ///
224    /// # Example
225    ///
226    /// ```rust
227    /// use envision::component::{SplitPanelState, SplitOrientation};
228    ///
229    /// let state = SplitPanelState::new(SplitOrientation::Vertical)
230    ///     .with_resize_step(0.05);
231    /// assert!((state.resize_step() - 0.05).abs() < f32::EPSILON);
232    /// ```
233    pub fn with_resize_step(mut self, step: f32) -> Self {
234        self.resize_step = step;
235        self
236    }
237
238    /// Sets the minimum and maximum ratio bounds.
239    ///
240    /// # Example
241    ///
242    /// ```rust
243    /// use envision::component::{SplitPanelState, SplitOrientation};
244    ///
245    /// let state = SplitPanelState::new(SplitOrientation::Vertical)
246    ///     .with_bounds(0.2, 0.8);
247    /// ```
248    pub fn with_bounds(mut self, min: f32, max: f32) -> Self {
249        self.min_ratio = min;
250        self.max_ratio = max;
251        self.ratio = self.ratio.clamp(min, max);
252        self
253    }
254
255    /// Returns true if the component is focused.
256    pub fn is_focused(&self) -> bool {
257        self.focused
258    }
259
260    /// Sets the focus state.
261    pub fn set_focused(&mut self, focused: bool) {
262        self.focused = focused;
263    }
264
265    /// Returns true if the component is disabled.
266    pub fn is_disabled(&self) -> bool {
267        self.disabled
268    }
269
270    /// Sets the disabled state.
271    pub fn set_disabled(&mut self, disabled: bool) {
272        self.disabled = disabled;
273    }
274
275    /// Sets the disabled state (builder pattern).
276    pub fn with_disabled(mut self, disabled: bool) -> Self {
277        self.disabled = disabled;
278        self
279    }
280
281    /// Maps an input event to a split panel message.
282    pub fn handle_event(&self, event: &Event) -> Option<SplitPanelMessage> {
283        SplitPanel::handle_event(self, event)
284    }
285
286    /// Dispatches an event, updating state and returning any output.
287    pub fn dispatch_event(&mut self, event: &Event) -> Option<SplitPanelOutput> {
288        SplitPanel::dispatch_event(self, event)
289    }
290
291    /// Updates the state with a message, returning any output.
292    pub fn update(&mut self, msg: SplitPanelMessage) -> Option<SplitPanelOutput> {
293        SplitPanel::update(self, msg)
294    }
295
296    /// Computes the layout areas for the two panes.
297    ///
298    /// Returns `(first_pane_area, second_pane_area)`.
299    pub fn layout(&self, area: Rect) -> (Rect, Rect) {
300        let direction = match self.orientation {
301            SplitOrientation::Vertical => Direction::Horizontal,
302            SplitOrientation::Horizontal => Direction::Vertical,
303        };
304
305        let total = match self.orientation {
306            SplitOrientation::Vertical => area.width,
307            SplitOrientation::Horizontal => area.height,
308        };
309
310        let first_size = ((total as f32) * self.ratio).round() as u16;
311        let first_size = first_size.min(total);
312
313        let chunks = Layout::default()
314            .direction(direction)
315            .constraints([Constraint::Length(first_size), Constraint::Min(0)])
316            .split(area);
317
318        (chunks[0], chunks[1])
319    }
320}
321
322/// A resizable split panel layout component.
323///
324/// `SplitPanel` manages the split ratio and pane focus for a two-pane
325/// layout. The parent uses [`SplitPanelState::layout()`] to get the
326/// pane areas and renders content into them.
327///
328/// # Navigation
329///
330/// - `Tab` — Toggle focus between panes
331/// - `Ctrl+Left/Up` — Grow first pane (shrink second)
332/// - `Ctrl+Right/Down` — Shrink first pane (grow second)
333/// - `Ctrl+0` — Reset to 50/50 split
334///
335/// # Rendering
336///
337/// The `view()` method renders placeholder borders for each pane.
338/// For real use, call `state.layout(area)` to get pane areas and
339/// render your own content.
340///
341/// # Example
342///
343/// ```rust
344/// use envision::component::{
345///     Component, Focusable, SplitPanel, SplitPanelState,
346///     SplitPanelMessage, SplitOrientation,
347/// };
348///
349/// let mut state = SplitPanelState::new(SplitOrientation::Vertical);
350/// SplitPanel::set_focused(&mut state, true);
351///
352/// // Get layout areas for rendering
353/// let area = ratatui::layout::Rect::new(0, 0, 80, 24);
354/// let (left, right) = state.layout(area);
355/// assert!(left.width > 0);
356/// assert!(right.width > 0);
357/// ```
358pub struct SplitPanel;
359
360impl Component for SplitPanel {
361    type State = SplitPanelState;
362    type Message = SplitPanelMessage;
363    type Output = SplitPanelOutput;
364
365    fn init() -> Self::State {
366        SplitPanelState::default()
367    }
368
369    fn handle_event(state: &Self::State, event: &Event) -> Option<Self::Message> {
370        if !state.focused || state.disabled {
371            return None;
372        }
373
374        if let Some(key) = event.as_key() {
375            // Tab toggles pane focus
376            if key.code == KeyCode::Tab || key.code == KeyCode::BackTab {
377                return Some(SplitPanelMessage::FocusOther);
378            }
379
380            // Ctrl+arrow resizes
381            if key.modifiers.contains(KeyModifiers::CONTROL) {
382                match key.code {
383                    KeyCode::Left | KeyCode::Up => return Some(SplitPanelMessage::ShrinkFirst),
384                    KeyCode::Right | KeyCode::Down => return Some(SplitPanelMessage::GrowFirst),
385                    KeyCode::Char('0') => return Some(SplitPanelMessage::ResetRatio),
386                    _ => {}
387                }
388            }
389
390            None
391        } else {
392            None
393        }
394    }
395
396    fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
397        if state.disabled {
398            return None;
399        }
400
401        match msg {
402            SplitPanelMessage::FocusOther => {
403                state.focused_pane = match state.focused_pane {
404                    Pane::First => Pane::Second,
405                    Pane::Second => Pane::First,
406                };
407                match state.focused_pane {
408                    Pane::First => Some(SplitPanelOutput::FocusedFirst),
409                    Pane::Second => Some(SplitPanelOutput::FocusedSecond),
410                }
411            }
412            SplitPanelMessage::FocusFirst => {
413                if state.focused_pane != Pane::First {
414                    state.focused_pane = Pane::First;
415                    Some(SplitPanelOutput::FocusedFirst)
416                } else {
417                    None
418                }
419            }
420            SplitPanelMessage::FocusSecond => {
421                if state.focused_pane != Pane::Second {
422                    state.focused_pane = Pane::Second;
423                    Some(SplitPanelOutput::FocusedSecond)
424                } else {
425                    None
426                }
427            }
428            SplitPanelMessage::GrowFirst => {
429                let new_ratio = (state.ratio + state.resize_step).min(state.max_ratio);
430                if (new_ratio - state.ratio).abs() > f32::EPSILON {
431                    state.ratio = new_ratio;
432                    Some(SplitPanelOutput::RatioChanged(new_ratio))
433                } else {
434                    None
435                }
436            }
437            SplitPanelMessage::ShrinkFirst => {
438                let new_ratio = (state.ratio - state.resize_step).max(state.min_ratio);
439                if (new_ratio - state.ratio).abs() > f32::EPSILON {
440                    state.ratio = new_ratio;
441                    Some(SplitPanelOutput::RatioChanged(new_ratio))
442                } else {
443                    None
444                }
445            }
446            SplitPanelMessage::SetRatio(ratio) => {
447                let clamped = ratio.clamp(state.min_ratio, state.max_ratio);
448                if (clamped - state.ratio).abs() > f32::EPSILON {
449                    state.ratio = clamped;
450                    Some(SplitPanelOutput::RatioChanged(clamped))
451                } else {
452                    None
453                }
454            }
455            SplitPanelMessage::ResetRatio => {
456                let target = 0.5_f32.clamp(state.min_ratio, state.max_ratio);
457                if (target - state.ratio).abs() > f32::EPSILON {
458                    state.ratio = target;
459                    Some(SplitPanelOutput::RatioChanged(target))
460                } else {
461                    None
462                }
463            }
464        }
465    }
466
467    fn view(state: &Self::State, frame: &mut Frame, area: Rect, theme: &Theme) {
468        let (first_area, second_area) = state.layout(area);
469
470        let first_focused = state.focused && state.focused_pane == Pane::First;
471        let second_focused = state.focused && state.focused_pane == Pane::Second;
472
473        let first_border = if state.disabled {
474            theme.disabled_style()
475        } else if first_focused {
476            theme.focused_border_style()
477        } else {
478            theme.border_style()
479        };
480
481        let second_border = if state.disabled {
482            theme.disabled_style()
483        } else if second_focused {
484            theme.focused_border_style()
485        } else {
486            theme.border_style()
487        };
488
489        let first_block = Block::default()
490            .borders(Borders::ALL)
491            .border_style(first_border)
492            .title(" Pane 1 ");
493
494        let second_block = Block::default()
495            .borders(Borders::ALL)
496            .border_style(second_border)
497            .title(" Pane 2 ");
498
499        frame.render_widget(first_block, first_area);
500        frame.render_widget(second_block, second_area);
501    }
502}
503
504impl Focusable for SplitPanel {
505    fn is_focused(state: &Self::State) -> bool {
506        state.focused
507    }
508
509    fn set_focused(state: &mut Self::State, focused: bool) {
510        state.focused = focused;
511    }
512}
513
514#[cfg(test)]
515mod tests;