1use ratatui::{
30 buffer::Buffer,
31 layout::Rect,
32 style::{Color, Modifier, Style},
33};
34
35use crate::traits::{ClickRegion, ClickRegionRegistry, FocusId, Focusable};
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum SplitPaneAction {
40 FirstPaneClick,
42 SecondPaneClick,
44 DividerDrag,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum Orientation {
51 #[default]
53 Horizontal,
54 Vertical,
56}
57
58#[derive(Debug, Clone)]
60pub struct SplitPaneState {
61 pub split_percent: u16,
63 pub focused: bool,
65 pub divider_focused: bool,
67 pub is_dragging: bool,
69 drag_start_pos: u16,
71 drag_start_percent: u16,
73 total_size: u16,
75 pub focus_id: FocusId,
77}
78
79impl SplitPaneState {
80 pub fn new(split_percent: u16) -> Self {
82 Self {
83 split_percent: split_percent.clamp(0, 100),
84 focused: false,
85 divider_focused: false,
86 is_dragging: false,
87 drag_start_pos: 0,
88 drag_start_percent: 0,
89 total_size: 0,
90 focus_id: FocusId::default(),
91 }
92 }
93
94 pub fn half() -> Self {
96 Self::new(50)
97 }
98
99 pub fn start_drag(&mut self, pos: u16) {
101 self.is_dragging = true;
102 self.drag_start_pos = pos;
103 self.drag_start_percent = self.split_percent;
104 }
105
106 pub fn update_drag(&mut self, pos: u16, min_percent: u16, max_percent: u16) {
108 if !self.is_dragging || self.total_size == 0 {
109 return;
110 }
111
112 let delta = (pos as i32) - (self.drag_start_pos as i32);
113 let percent_delta = (delta * 100) / (self.total_size as i32);
114 let new_percent =
115 ((self.drag_start_percent as i32) + percent_delta).clamp(min_percent as i32, max_percent as i32) as u16;
116
117 self.split_percent = new_percent;
118 }
119
120 pub fn end_drag(&mut self) {
122 self.is_dragging = false;
123 }
124
125 pub fn adjust_split(&mut self, delta: i16, min_percent: u16, max_percent: u16) {
127 let new_percent = ((self.split_percent as i16) + delta).clamp(min_percent as i16, max_percent as i16) as u16;
128 self.split_percent = new_percent;
129 }
130
131 pub fn set_split_percent(&mut self, percent: u16) {
133 self.split_percent = percent.clamp(0, 100);
134 }
135
136 pub fn split_percent(&self) -> u16 {
138 self.split_percent
139 }
140
141 pub fn is_dragging(&self) -> bool {
143 self.is_dragging
144 }
145
146 pub fn set_total_size(&mut self, size: u16) {
148 self.total_size = size;
149 }
150}
151
152impl Default for SplitPaneState {
153 fn default() -> Self {
154 Self::half()
155 }
156}
157
158impl Focusable for SplitPaneState {
159 fn focus_id(&self) -> FocusId {
160 self.focus_id
161 }
162
163 fn is_focused(&self) -> bool {
164 self.focused
165 }
166
167 fn set_focused(&mut self, focused: bool) {
168 self.focused = focused;
169 if !focused {
170 self.divider_focused = false;
171 }
172 }
173
174 fn focused_style(&self) -> Style {
175 Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
176 }
177
178 fn unfocused_style(&self) -> Style {
179 Style::default().fg(Color::White)
180 }
181}
182
183#[derive(Debug, Clone)]
185pub struct SplitPaneStyle {
186 pub divider_style: Style,
188 pub divider_focused_style: Style,
190 pub divider_dragging_style: Style,
192 pub divider_hover_style: Style,
194 pub divider_char: Option<&'static str>,
196 pub divider_size: u16,
198 pub show_grab_indicator: bool,
200}
201
202impl Default for SplitPaneStyle {
203 fn default() -> Self {
204 Self {
205 divider_style: Style::default().bg(Color::DarkGray),
206 divider_focused_style: Style::default().bg(Color::Yellow).fg(Color::Black),
207 divider_dragging_style: Style::default().bg(Color::Cyan).fg(Color::Black),
208 divider_hover_style: Style::default().bg(Color::Gray),
209 divider_char: None, divider_size: 1,
211 show_grab_indicator: true,
212 }
213 }
214}
215
216impl SplitPaneStyle {
217 pub fn minimal() -> Self {
219 Self {
220 divider_style: Style::default().fg(Color::DarkGray),
221 divider_focused_style: Style::default().fg(Color::Yellow),
222 divider_dragging_style: Style::default().fg(Color::Cyan),
223 divider_hover_style: Style::default().fg(Color::Gray),
224 divider_char: None,
225 divider_size: 1,
226 show_grab_indicator: false,
227 }
228 }
229
230 pub fn prominent() -> Self {
232 Self {
233 divider_style: Style::default().bg(Color::Blue).fg(Color::White),
234 divider_focused_style: Style::default().bg(Color::Yellow).fg(Color::Black),
235 divider_dragging_style: Style::default().bg(Color::Green).fg(Color::Black),
236 divider_hover_style: Style::default().bg(Color::LightBlue).fg(Color::Black),
237 divider_char: None,
238 divider_size: 1,
239 show_grab_indicator: true,
240 }
241 }
242
243 pub fn divider_char(mut self, char: &'static str) -> Self {
245 self.divider_char = Some(char);
246 self
247 }
248
249 pub fn divider_size(mut self, size: u16) -> Self {
251 self.divider_size = size.max(1);
252 self
253 }
254}
255
256pub struct SplitPane {
258 orientation: Orientation,
259 style: SplitPaneStyle,
260 min_size: u16,
261 min_percent: u16,
262 max_percent: u16,
263}
264
265impl SplitPane {
266 pub fn new() -> Self {
268 Self {
269 orientation: Orientation::default(),
270 style: SplitPaneStyle::default(),
271 min_size: 5,
272 min_percent: 10,
273 max_percent: 90,
274 }
275 }
276
277 pub fn orientation(mut self, orientation: Orientation) -> Self {
279 self.orientation = orientation;
280 self
281 }
282
283 pub fn style(mut self, style: SplitPaneStyle) -> Self {
285 self.style = style;
286 self
287 }
288
289 pub fn min_size(mut self, min_size: u16) -> Self {
291 self.min_size = min_size;
292 self
293 }
294
295 pub fn min_percent(mut self, min_percent: u16) -> Self {
297 self.min_percent = min_percent.clamp(0, 100);
298 self
299 }
300
301 pub fn max_percent(mut self, max_percent: u16) -> Self {
303 self.max_percent = max_percent.clamp(0, 100);
304 self
305 }
306
307 pub fn divider_char(mut self, char: &'static str) -> Self {
309 self.style.divider_char = Some(char);
310 self
311 }
312
313 pub fn calculate_areas(&self, area: Rect, split_percent: u16) -> (Rect, Rect, Rect) {
317 let total_size = match self.orientation {
318 Orientation::Horizontal => area.width,
319 Orientation::Vertical => area.height,
320 };
321
322 let divider_size = self.style.divider_size;
323 let available_size = total_size.saturating_sub(divider_size);
324
325 let first_size = ((available_size as u32) * (split_percent as u32) / 100) as u16;
327 let first_size = first_size.clamp(self.min_size, available_size.saturating_sub(self.min_size));
328
329 let second_size = available_size.saturating_sub(first_size);
331
332 match self.orientation {
333 Orientation::Horizontal => {
334 let first_area = Rect::new(area.x, area.y, first_size, area.height);
335 let divider_area = Rect::new(area.x + first_size, area.y, divider_size, area.height);
336 let second_area = Rect::new(
337 area.x + first_size + divider_size,
338 area.y,
339 second_size,
340 area.height,
341 );
342 (first_area, divider_area, second_area)
343 }
344 Orientation::Vertical => {
345 let first_area = Rect::new(area.x, area.y, area.width, first_size);
346 let divider_area = Rect::new(area.x, area.y + first_size, area.width, divider_size);
347 let second_area = Rect::new(
348 area.x,
349 area.y + first_size + divider_size,
350 area.width,
351 second_size,
352 );
353 (first_area, divider_area, second_area)
354 }
355 }
356 }
357
358 fn render_divider(&self, state: &SplitPaneState, divider_area: Rect, buf: &mut Buffer) {
360 let divider_style = if state.is_dragging {
361 self.style.divider_dragging_style
362 } else if state.divider_focused {
363 self.style.divider_focused_style
364 } else {
365 self.style.divider_style
366 };
367
368 let divider_char = self.style.divider_char.unwrap_or(match self.orientation {
369 Orientation::Horizontal => "│",
370 Orientation::Vertical => "─",
371 });
372
373 match self.orientation {
374 Orientation::Horizontal => {
375 for y in divider_area.y..divider_area.y + divider_area.height {
376 for x in divider_area.x..divider_area.x + divider_area.width {
377 let char_to_draw = if self.style.show_grab_indicator {
379 let mid_y = divider_area.y + divider_area.height / 2;
380 if y == mid_y {
381 "┃"
382 } else if y == mid_y.saturating_sub(1) || y == mid_y + 1 {
383 "║"
384 } else {
385 divider_char
386 }
387 } else {
388 divider_char
389 };
390 buf.set_string(x, y, char_to_draw, divider_style);
391 }
392 }
393 }
394 Orientation::Vertical => {
395 for y in divider_area.y..divider_area.y + divider_area.height {
396 for x in divider_area.x..divider_area.x + divider_area.width {
397 let char_to_draw = if self.style.show_grab_indicator {
399 let mid_x = divider_area.x + divider_area.width / 2;
400 if x == mid_x {
401 "━"
402 } else if x == mid_x.saturating_sub(1) || x == mid_x + 1 {
403 "═"
404 } else {
405 divider_char
406 }
407 } else {
408 divider_char
409 };
410 buf.set_string(x, y, char_to_draw, divider_style);
411 }
412 }
413 }
414 }
415 }
416
417 pub fn render_with_content<F1, F2>(
419 &self,
420 area: Rect,
421 buf: &mut Buffer,
422 state: &mut SplitPaneState,
423 first_pane_renderer: F1,
424 second_pane_renderer: F2,
425 registry: &mut ClickRegionRegistry<SplitPaneAction>,
426 ) where
427 F1: FnOnce(Rect, &mut Buffer),
428 F2: FnOnce(Rect, &mut Buffer),
429 {
430 let total_size = match self.orientation {
432 Orientation::Horizontal => area.width,
433 Orientation::Vertical => area.height,
434 };
435 state.set_total_size(total_size);
436
437 let (first_area, divider_area, second_area) = self.calculate_areas(area, state.split_percent);
438
439 registry.register(first_area, SplitPaneAction::FirstPaneClick);
441 registry.register(divider_area, SplitPaneAction::DividerDrag);
442 registry.register(second_area, SplitPaneAction::SecondPaneClick);
443
444 first_pane_renderer(first_area, buf);
446 second_pane_renderer(second_area, buf);
447
448 self.render_divider(state, divider_area, buf);
450 }
451
452 pub fn render_divider_only(
454 &self,
455 area: Rect,
456 buf: &mut Buffer,
457 state: &mut SplitPaneState,
458 ) -> (Rect, Rect, Rect) {
459 let total_size = match self.orientation {
461 Orientation::Horizontal => area.width,
462 Orientation::Vertical => area.height,
463 };
464 state.set_total_size(total_size);
465
466 let (first_area, divider_area, second_area) = self.calculate_areas(area, state.split_percent);
467 self.render_divider(state, divider_area, buf);
468 (first_area, divider_area, second_area)
469 }
470
471 pub fn divider_click_region(&self, area: Rect, split_percent: u16) -> ClickRegion<SplitPaneAction> {
473 let (_, divider_area, _) = self.calculate_areas(area, split_percent);
474 ClickRegion::new(divider_area, SplitPaneAction::DividerDrag)
475 }
476
477 pub fn get_orientation(&self) -> Orientation {
479 self.orientation
480 }
481
482 pub fn get_min_percent(&self) -> u16 {
484 self.min_percent
485 }
486
487 pub fn get_max_percent(&self) -> u16 {
489 self.max_percent
490 }
491}
492
493impl Default for SplitPane {
494 fn default() -> Self {
495 Self::new()
496 }
497}
498
499pub fn handle_split_pane_key(
503 state: &mut SplitPaneState,
504 key: &crossterm::event::KeyEvent,
505 orientation: Orientation,
506 step: i16,
507 min_percent: u16,
508 max_percent: u16,
509) -> bool {
510 use crossterm::event::KeyCode;
511
512 if !state.divider_focused {
513 return false;
514 }
515
516 match key.code {
517 KeyCode::Left if orientation == Orientation::Horizontal => {
518 state.adjust_split(-step, min_percent, max_percent);
519 true
520 }
521 KeyCode::Right if orientation == Orientation::Horizontal => {
522 state.adjust_split(step, min_percent, max_percent);
523 true
524 }
525 KeyCode::Up if orientation == Orientation::Vertical => {
526 state.adjust_split(-step, min_percent, max_percent);
527 true
528 }
529 KeyCode::Down if orientation == Orientation::Vertical => {
530 state.adjust_split(step, min_percent, max_percent);
531 true
532 }
533 KeyCode::Home => {
534 state.set_split_percent(min_percent);
535 true
536 }
537 KeyCode::End => {
538 state.set_split_percent(max_percent);
539 true
540 }
541 _ => false,
542 }
543}
544
545pub fn handle_split_pane_mouse(
549 state: &mut SplitPaneState,
550 mouse: &crossterm::event::MouseEvent,
551 orientation: Orientation,
552 registry: &ClickRegionRegistry<SplitPaneAction>,
553 min_percent: u16,
554 max_percent: u16,
555) -> Option<SplitPaneAction> {
556 use crossterm::event::{MouseButton, MouseEventKind};
557
558 let pos = match orientation {
559 Orientation::Horizontal => mouse.column,
560 Orientation::Vertical => mouse.row,
561 };
562
563 match mouse.kind {
564 MouseEventKind::Down(MouseButton::Left) => {
565 if let Some(&action) = registry.handle_click(mouse.column, mouse.row) {
566 if action == SplitPaneAction::DividerDrag {
567 state.start_drag(pos);
568 }
569 return Some(action);
570 }
571 }
572 MouseEventKind::Up(MouseButton::Left) => {
573 if state.is_dragging {
574 state.end_drag();
575 }
576 }
577 MouseEventKind::Drag(MouseButton::Left) => {
578 if state.is_dragging {
579 state.update_drag(pos, min_percent, max_percent);
580 }
581 }
582 _ => {}
583 }
584
585 None
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591
592 #[test]
593 fn test_state_creation() {
594 let state = SplitPaneState::new(30);
595 assert_eq!(state.split_percent, 30);
596 assert!(!state.is_dragging);
597 assert!(!state.focused);
598 }
599
600 #[test]
601 fn test_state_half() {
602 let state = SplitPaneState::half();
603 assert_eq!(state.split_percent, 50);
604 }
605
606 #[test]
607 fn test_split_percent_clamping() {
608 let state = SplitPaneState::new(150);
609 assert_eq!(state.split_percent, 100);
610
611 let mut state2 = SplitPaneState::new(50);
612 state2.set_split_percent(200);
613 assert_eq!(state2.split_percent, 100);
614 }
615
616 #[test]
617 fn test_drag_operations() {
618 let mut state = SplitPaneState::new(50);
619 state.set_total_size(100);
620
621 state.start_drag(50);
622 assert!(state.is_dragging);
623
624 state.update_drag(60, 10, 90);
625 assert_eq!(state.split_percent, 60);
626
627 state.end_drag();
628 assert!(!state.is_dragging);
629 }
630
631 #[test]
632 fn test_drag_respects_limits() {
633 let mut state = SplitPaneState::new(50);
634 state.set_total_size(100);
635
636 state.start_drag(50);
637 state.update_drag(5, 10, 90);
638 assert!(state.split_percent >= 10);
639
640 state.update_drag(95, 10, 90);
641 assert!(state.split_percent <= 90);
642 }
643
644 #[test]
645 fn test_adjust_split() {
646 let mut state = SplitPaneState::new(50);
647
648 state.adjust_split(10, 10, 90);
649 assert_eq!(state.split_percent, 60);
650
651 state.adjust_split(-20, 10, 90);
652 assert_eq!(state.split_percent, 40);
653 }
654
655 #[test]
656 fn test_calculate_areas_horizontal() {
657 let split_pane = SplitPane::new().orientation(Orientation::Horizontal);
658
659 let area = Rect::new(0, 0, 100, 50);
660 let (first, divider, second) = split_pane.calculate_areas(area, 50);
661
662 assert_eq!(first.width + divider.width + second.width, area.width);
663 assert_eq!(divider.width, 1);
664 }
665
666 #[test]
667 fn test_calculate_areas_vertical() {
668 let split_pane = SplitPane::new().orientation(Orientation::Vertical);
669
670 let area = Rect::new(0, 0, 100, 50);
671 let (first, divider, second) = split_pane.calculate_areas(area, 50);
672
673 assert_eq!(first.height + divider.height + second.height, area.height);
674 assert_eq!(divider.height, 1);
675 }
676
677 #[test]
678 fn test_focusable_trait() {
679 let mut state = SplitPaneState::new(50);
680 assert!(!state.is_focused());
681
682 state.set_focused(true);
683 assert!(state.is_focused());
684
685 state.divider_focused = true;
686 state.set_focused(false);
687 assert!(!state.divider_focused);
688 }
689}