Skip to main content

tui/components/
split_panel.rs

1use super::component::{Component, Event};
2use crate::focus::{FocusOutcome, FocusRing};
3use crate::rendering::frame::{Cursor, Frame, FramePart};
4use crate::rendering::line::Line;
5use crate::rendering::render_context::ViewContext;
6use crate::style::Style;
7use crossterm::event::KeyCode;
8
9pub struct SplitWidths {
10    pub left: u16,
11    pub right: u16,
12}
13
14pub struct SplitLayout {
15    base: SplitBase,
16    step: i16,
17    min_left: usize,
18}
19
20enum SplitBase {
21    Fraction { numer: usize, denom: usize, min: usize, max: usize },
22    Fixed(usize),
23}
24
25impl SplitLayout {
26    pub fn fraction(numer: usize, denom: usize, min: usize, max: usize) -> Self {
27        Self { base: SplitBase::Fraction { numer, denom, min, max }, step: 4, min_left: 12 }
28    }
29
30    pub fn fixed(width: usize) -> Self {
31        Self { base: SplitBase::Fixed(width), step: 4, min_left: 12 }
32    }
33
34    pub fn with_step(mut self, step: i16) -> Self {
35        self.step = step;
36        self
37    }
38
39    pub fn with_min_left(mut self, min: usize) -> Self {
40        self.min_left = min;
41        self
42    }
43
44    fn widths(&self, total_width: u16, delta: i16) -> SplitWidths {
45        let total = total_width as usize;
46        let base = match self.base {
47            SplitBase::Fraction { numer, denom, min, max } => (total * numer / denom).clamp(min, max),
48            SplitBase::Fixed(w) => w,
49        };
50        let left = base.saturating_add_signed(delta.into()).clamp(self.min_left, total / 2);
51        let right = total.saturating_sub(left + 1);
52        SplitWidths { left: u16::try_from(left).unwrap_or(u16::MAX), right: u16::try_from(right).unwrap_or(u16::MAX) }
53    }
54
55    fn step(&self) -> i16 {
56        self.step
57    }
58}
59
60pub enum Either<L, R> {
61    Left(L),
62    Right(R),
63}
64
65pub struct SplitPanel<L: Component, R: Component> {
66    left: L,
67    right: R,
68    layout: SplitLayout,
69    delta: i16,
70    focus: FocusRing,
71    separator: Option<(String, Style)>,
72    resize_keys: bool,
73}
74
75impl<L: Component, R: Component> SplitPanel<L, R> {
76    pub fn new(left: L, right: R, layout: SplitLayout) -> Self {
77        Self {
78            left,
79            right,
80            layout,
81            delta: 0,
82            focus: FocusRing::new(2).without_wrap(),
83            separator: None,
84            resize_keys: false,
85        }
86    }
87
88    pub fn with_separator(mut self, text: impl Into<String>, style: Style) -> Self {
89        self.separator = Some((text.into(), style));
90        self
91    }
92
93    pub fn with_resize_keys(mut self) -> Self {
94        self.resize_keys = true;
95        self
96    }
97
98    pub fn left(&self) -> &L {
99        &self.left
100    }
101
102    pub fn left_mut(&mut self) -> &mut L {
103        &mut self.left
104    }
105
106    pub fn right(&self) -> &R {
107        &self.right
108    }
109
110    pub fn right_mut(&mut self) -> &mut R {
111        &mut self.right
112    }
113
114    pub fn focus_left(&mut self) {
115        self.focus.focus(0);
116    }
117
118    pub fn focus_right(&mut self) {
119        self.focus.focus(1);
120    }
121
122    pub fn is_left_focused(&self) -> bool {
123        self.focus.is_focused(0)
124    }
125
126    pub fn widths(&self, total_width: u16) -> SplitWidths {
127        self.layout.widths(total_width, self.delta)
128    }
129
130    fn widen(&mut self) {
131        self.delta += self.layout.step();
132    }
133
134    fn narrow(&mut self) {
135        self.delta -= self.layout.step();
136    }
137
138    pub fn set_separator_style(&mut self, style: Style) {
139        if let Some((_, s)) = &mut self.separator {
140            *s = style;
141        }
142    }
143}
144
145impl<L: Component, R: Component> Component for SplitPanel<L, R> {
146    type Message = Either<L::Message, R::Message>;
147
148    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
149        if let Event::Key(key) = event {
150            if self.resize_keys {
151                match key.code {
152                    KeyCode::Char('<') => {
153                        self.narrow();
154                        return Some(vec![]);
155                    }
156                    KeyCode::Char('>') => {
157                        self.widen();
158                        return Some(vec![]);
159                    }
160                    _ => {}
161                }
162            }
163
164            let outcome = self.focus.handle_key(*key);
165            if matches!(outcome, FocusOutcome::FocusChanged) {
166                return Some(vec![]);
167            }
168        }
169
170        if self.focus.is_focused(0) {
171            self.left.on_event(event).await.map(|msgs| msgs.into_iter().map(Either::Left).collect())
172        } else {
173            self.right.on_event(event).await.map(|msgs| msgs.into_iter().map(Either::Right).collect())
174        }
175    }
176
177    fn render(&mut self, ctx: &ViewContext) -> Frame {
178        let widths = self.widths(ctx.size.width);
179        let max_rows = ctx.size.height;
180        let total_width = ctx.size.width;
181
182        let mut left = self.left.render(&ctx.with_width(widths.left));
183        let mut right = self.right.render(&ctx.with_width(widths.right));
184
185        // Only the focused side may contribute the merged cursor — suppress the
186        // other side's cursor before composition so hstack picks the right one.
187        if !self.focus.is_focused(0) {
188            left = left.with_cursor(Cursor::hidden());
189        }
190        if !self.focus.is_focused(1) {
191            right = right.with_cursor(Cursor::hidden());
192        }
193
194        let left_part = FramePart::wrap(left, widths.left);
195        let right_part = FramePart::wrap(right, widths.right);
196
197        let merged = if let Some((text, style)) = &self.separator {
198            let sep_proto = Line::with_style(text.clone(), *style);
199            let sep_width = u16::try_from(sep_proto.display_width()).unwrap_or(0);
200            let sep_rows = left_part.frame.lines().len().max(right_part.frame.lines().len()).max(usize::from(max_rows));
201            let sep_lines: Vec<Line> = (0..sep_rows).map(|_| sep_proto.clone()).collect();
202            Frame::hstack([left_part, FramePart::new(Frame::new(sep_lines), sep_width), right_part])
203        } else {
204            Frame::hstack([left_part, right_part])
205        };
206
207        merged.fit_height(max_rows, total_width)
208    }
209}