envision/component/split_panel/
mod.rs1use ratatui::prelude::*;
31use ratatui::widgets::{Block, Borders};
32
33use super::{Component, Focusable};
34use crate::input::{Event, KeyCode, KeyModifiers};
35use crate::theme::Theme;
36
37#[derive(Clone, Debug, PartialEq, Eq)]
39#[cfg_attr(
40 feature = "serialization",
41 derive(serde::Serialize, serde::Deserialize)
42)]
43pub enum SplitOrientation {
44 Vertical,
46 Horizontal,
48}
49
50#[derive(Clone, Debug, PartialEq)]
52pub enum SplitPanelMessage {
53 FocusOther,
55 FocusFirst,
57 FocusSecond,
59 GrowFirst,
61 ShrinkFirst,
63 SetRatio(f32),
65 ResetRatio,
67}
68
69#[derive(Clone, Debug, PartialEq)]
71pub enum SplitPanelOutput {
72 FocusedFirst,
74 FocusedSecond,
76 RatioChanged(f32),
78}
79
80#[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#[derive(Clone, Debug)]
96#[cfg_attr(
97 feature = "serialization",
98 derive(serde::Serialize, serde::Deserialize)
99)]
100pub struct SplitPanelState {
101 orientation: SplitOrientation,
103 ratio: f32,
105 focused_pane: Pane,
107 focused: bool,
109 disabled: bool,
111 resize_step: f32,
113 min_ratio: f32,
115 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 pub fn new(orientation: SplitOrientation) -> Self {
160 Self {
161 orientation,
162 ..Default::default()
163 }
164 }
165
166 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 pub fn orientation(&self) -> &SplitOrientation {
186 &self.orientation
187 }
188
189 pub fn set_orientation(&mut self, orientation: SplitOrientation) {
191 self.orientation = orientation;
192 }
193
194 pub fn ratio(&self) -> f32 {
199 self.ratio
200 }
201
202 pub fn set_ratio(&mut self, ratio: f32) {
204 self.ratio = ratio.clamp(self.min_ratio, self.max_ratio);
205 }
206
207 pub fn is_first_pane_focused(&self) -> bool {
209 self.focused_pane == Pane::First
210 }
211
212 pub fn is_second_pane_focused(&self) -> bool {
214 self.focused_pane == Pane::Second
215 }
216
217 pub fn resize_step(&self) -> f32 {
219 self.resize_step
220 }
221
222 pub fn with_resize_step(mut self, step: f32) -> Self {
234 self.resize_step = step;
235 self
236 }
237
238 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 pub fn is_focused(&self) -> bool {
257 self.focused
258 }
259
260 pub fn set_focused(&mut self, focused: bool) {
262 self.focused = focused;
263 }
264
265 pub fn is_disabled(&self) -> bool {
267 self.disabled
268 }
269
270 pub fn set_disabled(&mut self, disabled: bool) {
272 self.disabled = disabled;
273 }
274
275 pub fn with_disabled(mut self, disabled: bool) -> Self {
277 self.disabled = disabled;
278 self
279 }
280
281 pub fn handle_event(&self, event: &Event) -> Option<SplitPanelMessage> {
283 SplitPanel::handle_event(self, event)
284 }
285
286 pub fn dispatch_event(&mut self, event: &Event) -> Option<SplitPanelOutput> {
288 SplitPanel::dispatch_event(self, event)
289 }
290
291 pub fn update(&mut self, msg: SplitPanelMessage) -> Option<SplitPanelOutput> {
293 SplitPanel::update(self, msg)
294 }
295
296 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
322pub 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 if key.code == KeyCode::Tab || key.code == KeyCode::BackTab {
377 return Some(SplitPanelMessage::FocusOther);
378 }
379
380 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;