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 = ((self.drag_start_percent as i32) + percent_delta)
115 .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)
128 .clamp(min_percent as i16, max_percent as i16) as u16;
129 self.split_percent = new_percent;
130 }
131
132 pub fn set_split_percent(&mut self, percent: u16) {
134 self.split_percent = percent.clamp(0, 100);
135 }
136
137 pub fn split_percent(&self) -> u16 {
139 self.split_percent
140 }
141
142 pub fn is_dragging(&self) -> bool {
144 self.is_dragging
145 }
146
147 pub fn set_total_size(&mut self, size: u16) {
149 self.total_size = size;
150 }
151}
152
153impl Default for SplitPaneState {
154 fn default() -> Self {
155 Self::half()
156 }
157}
158
159impl Focusable for SplitPaneState {
160 fn focus_id(&self) -> FocusId {
161 self.focus_id
162 }
163
164 fn is_focused(&self) -> bool {
165 self.focused
166 }
167
168 fn set_focused(&mut self, focused: bool) {
169 self.focused = focused;
170 if !focused {
171 self.divider_focused = false;
172 }
173 }
174
175 fn focused_style(&self) -> Style {
176 Style::default()
177 .fg(Color::Yellow)
178 .add_modifier(Modifier::BOLD)
179 }
180
181 fn unfocused_style(&self) -> Style {
182 Style::default().fg(Color::White)
183 }
184}
185
186#[derive(Debug, Clone)]
188pub struct SplitPaneStyle {
189 pub divider_style: Style,
191 pub divider_focused_style: Style,
193 pub divider_dragging_style: Style,
195 pub divider_hover_style: Style,
197 pub divider_char: Option<&'static str>,
199 pub divider_size: u16,
201 pub show_grab_indicator: bool,
203}
204
205impl Default for SplitPaneStyle {
206 fn default() -> Self {
207 Self {
208 divider_style: Style::default().bg(Color::DarkGray),
209 divider_focused_style: Style::default().bg(Color::Yellow).fg(Color::Black),
210 divider_dragging_style: Style::default().bg(Color::Cyan).fg(Color::Black),
211 divider_hover_style: Style::default().bg(Color::Gray),
212 divider_char: None, divider_size: 1,
214 show_grab_indicator: true,
215 }
216 }
217}
218
219impl From<&crate::theme::Theme> for SplitPaneStyle {
220 fn from(theme: &crate::theme::Theme) -> Self {
221 let p = &theme.palette;
222 Self {
223 divider_style: Style::default().bg(Color::DarkGray),
224 divider_focused_style: Style::default().bg(p.primary).fg(p.highlight_fg),
225 divider_dragging_style: Style::default().bg(p.secondary).fg(p.highlight_fg),
226 divider_hover_style: Style::default().bg(p.text_dim),
227 divider_char: None,
228 divider_size: 1,
229 show_grab_indicator: true,
230 }
231 }
232}
233
234impl SplitPaneStyle {
235 pub fn minimal() -> Self {
237 Self {
238 divider_style: Style::default().fg(Color::DarkGray),
239 divider_focused_style: Style::default().fg(Color::Yellow),
240 divider_dragging_style: Style::default().fg(Color::Cyan),
241 divider_hover_style: Style::default().fg(Color::Gray),
242 divider_char: None,
243 divider_size: 1,
244 show_grab_indicator: false,
245 }
246 }
247
248 pub fn prominent() -> Self {
250 Self {
251 divider_style: Style::default().bg(Color::Blue).fg(Color::White),
252 divider_focused_style: Style::default().bg(Color::Yellow).fg(Color::Black),
253 divider_dragging_style: Style::default().bg(Color::Green).fg(Color::Black),
254 divider_hover_style: Style::default().bg(Color::LightBlue).fg(Color::Black),
255 divider_char: None,
256 divider_size: 1,
257 show_grab_indicator: true,
258 }
259 }
260
261 pub fn divider_char(mut self, char: &'static str) -> Self {
263 self.divider_char = Some(char);
264 self
265 }
266
267 pub fn divider_size(mut self, size: u16) -> Self {
269 self.divider_size = size.max(1);
270 self
271 }
272}
273
274pub struct SplitPane {
276 orientation: Orientation,
277 style: SplitPaneStyle,
278 min_size: u16,
279 min_percent: u16,
280 max_percent: u16,
281}
282
283impl SplitPane {
284 pub fn new() -> Self {
286 Self {
287 orientation: Orientation::default(),
288 style: SplitPaneStyle::default(),
289 min_size: 5,
290 min_percent: 10,
291 max_percent: 90,
292 }
293 }
294
295 pub fn orientation(mut self, orientation: Orientation) -> Self {
297 self.orientation = orientation;
298 self
299 }
300
301 pub fn style(mut self, style: SplitPaneStyle) -> Self {
303 self.style = style;
304 self
305 }
306
307 pub fn theme(self, theme: &crate::theme::Theme) -> Self {
309 self.style(SplitPaneStyle::from(theme))
310 }
311
312 pub fn min_size(mut self, min_size: u16) -> Self {
314 self.min_size = min_size;
315 self
316 }
317
318 pub fn min_percent(mut self, min_percent: u16) -> Self {
320 self.min_percent = min_percent.clamp(0, 100);
321 self
322 }
323
324 pub fn max_percent(mut self, max_percent: u16) -> Self {
326 self.max_percent = max_percent.clamp(0, 100);
327 self
328 }
329
330 pub fn divider_char(mut self, char: &'static str) -> Self {
332 self.style.divider_char = Some(char);
333 self
334 }
335
336 pub fn calculate_areas(&self, area: Rect, split_percent: u16) -> (Rect, Rect, Rect) {
340 let total_size = match self.orientation {
341 Orientation::Horizontal => area.width,
342 Orientation::Vertical => area.height,
343 };
344
345 let divider_size = self.style.divider_size;
346 let available_size = total_size.saturating_sub(divider_size);
347
348 let first_size = ((available_size as u32) * (split_percent as u32) / 100) as u16;
350 let first_size =
351 first_size.clamp(self.min_size, available_size.saturating_sub(self.min_size));
352
353 let second_size = available_size.saturating_sub(first_size);
355
356 match self.orientation {
357 Orientation::Horizontal => {
358 let first_area = Rect::new(area.x, area.y, first_size, area.height);
359 let divider_area =
360 Rect::new(area.x + first_size, area.y, divider_size, area.height);
361 let second_area = Rect::new(
362 area.x + first_size + divider_size,
363 area.y,
364 second_size,
365 area.height,
366 );
367 (first_area, divider_area, second_area)
368 }
369 Orientation::Vertical => {
370 let first_area = Rect::new(area.x, area.y, area.width, first_size);
371 let divider_area = Rect::new(area.x, area.y + first_size, area.width, divider_size);
372 let second_area = Rect::new(
373 area.x,
374 area.y + first_size + divider_size,
375 area.width,
376 second_size,
377 );
378 (first_area, divider_area, second_area)
379 }
380 }
381 }
382
383 fn render_divider(&self, state: &SplitPaneState, divider_area: Rect, buf: &mut Buffer) {
385 let divider_style = if state.is_dragging {
386 self.style.divider_dragging_style
387 } else if state.divider_focused {
388 self.style.divider_focused_style
389 } else {
390 self.style.divider_style
391 };
392
393 let divider_char = self.style.divider_char.unwrap_or(match self.orientation {
394 Orientation::Horizontal => "│",
395 Orientation::Vertical => "─",
396 });
397
398 match self.orientation {
399 Orientation::Horizontal => {
400 for y in divider_area.y..divider_area.y + divider_area.height {
401 for x in divider_area.x..divider_area.x + divider_area.width {
402 let char_to_draw = if self.style.show_grab_indicator {
404 let mid_y = divider_area.y + divider_area.height / 2;
405 if y == mid_y {
406 "┃"
407 } else if y == mid_y.saturating_sub(1) || y == mid_y + 1 {
408 "║"
409 } else {
410 divider_char
411 }
412 } else {
413 divider_char
414 };
415 buf.set_string(x, y, char_to_draw, divider_style);
416 }
417 }
418 }
419 Orientation::Vertical => {
420 for y in divider_area.y..divider_area.y + divider_area.height {
421 for x in divider_area.x..divider_area.x + divider_area.width {
422 let char_to_draw = if self.style.show_grab_indicator {
424 let mid_x = divider_area.x + divider_area.width / 2;
425 if x == mid_x {
426 "━"
427 } else if x == mid_x.saturating_sub(1) || x == mid_x + 1 {
428 "═"
429 } else {
430 divider_char
431 }
432 } else {
433 divider_char
434 };
435 buf.set_string(x, y, char_to_draw, divider_style);
436 }
437 }
438 }
439 }
440 }
441
442 pub fn render_with_content<F1, F2>(
444 &self,
445 area: Rect,
446 buf: &mut Buffer,
447 state: &mut SplitPaneState,
448 first_pane_renderer: F1,
449 second_pane_renderer: F2,
450 registry: &mut ClickRegionRegistry<SplitPaneAction>,
451 ) where
452 F1: FnOnce(Rect, &mut Buffer),
453 F2: FnOnce(Rect, &mut Buffer),
454 {
455 let total_size = match self.orientation {
457 Orientation::Horizontal => area.width,
458 Orientation::Vertical => area.height,
459 };
460 state.set_total_size(total_size);
461
462 let (first_area, divider_area, second_area) =
463 self.calculate_areas(area, state.split_percent);
464
465 registry.register(first_area, SplitPaneAction::FirstPaneClick);
467 registry.register(divider_area, SplitPaneAction::DividerDrag);
468 registry.register(second_area, SplitPaneAction::SecondPaneClick);
469
470 first_pane_renderer(first_area, buf);
472 second_pane_renderer(second_area, buf);
473
474 self.render_divider(state, divider_area, buf);
476 }
477
478 pub fn render_divider_only(
480 &self,
481 area: Rect,
482 buf: &mut Buffer,
483 state: &mut SplitPaneState,
484 ) -> (Rect, Rect, Rect) {
485 let total_size = match self.orientation {
487 Orientation::Horizontal => area.width,
488 Orientation::Vertical => area.height,
489 };
490 state.set_total_size(total_size);
491
492 let (first_area, divider_area, second_area) =
493 self.calculate_areas(area, state.split_percent);
494 self.render_divider(state, divider_area, buf);
495 (first_area, divider_area, second_area)
496 }
497
498 pub fn divider_click_region(
500 &self,
501 area: Rect,
502 split_percent: u16,
503 ) -> ClickRegion<SplitPaneAction> {
504 let (_, divider_area, _) = self.calculate_areas(area, split_percent);
505 ClickRegion::new(divider_area, SplitPaneAction::DividerDrag)
506 }
507
508 pub fn get_orientation(&self) -> Orientation {
510 self.orientation
511 }
512
513 pub fn get_min_percent(&self) -> u16 {
515 self.min_percent
516 }
517
518 pub fn get_max_percent(&self) -> u16 {
520 self.max_percent
521 }
522}
523
524impl Default for SplitPane {
525 fn default() -> Self {
526 Self::new()
527 }
528}
529
530pub fn handle_split_pane_key(
534 state: &mut SplitPaneState,
535 key: &crossterm::event::KeyEvent,
536 orientation: Orientation,
537 step: i16,
538 min_percent: u16,
539 max_percent: u16,
540) -> bool {
541 use crossterm::event::KeyCode;
542
543 if !state.divider_focused {
544 return false;
545 }
546
547 match key.code {
548 KeyCode::Left if orientation == Orientation::Horizontal => {
549 state.adjust_split(-step, min_percent, max_percent);
550 true
551 }
552 KeyCode::Right if orientation == Orientation::Horizontal => {
553 state.adjust_split(step, min_percent, max_percent);
554 true
555 }
556 KeyCode::Up if orientation == Orientation::Vertical => {
557 state.adjust_split(-step, min_percent, max_percent);
558 true
559 }
560 KeyCode::Down if orientation == Orientation::Vertical => {
561 state.adjust_split(step, min_percent, max_percent);
562 true
563 }
564 KeyCode::Home => {
565 state.set_split_percent(min_percent);
566 true
567 }
568 KeyCode::End => {
569 state.set_split_percent(max_percent);
570 true
571 }
572 _ => false,
573 }
574}
575
576pub fn handle_split_pane_mouse(
580 state: &mut SplitPaneState,
581 mouse: &crossterm::event::MouseEvent,
582 orientation: Orientation,
583 registry: &ClickRegionRegistry<SplitPaneAction>,
584 min_percent: u16,
585 max_percent: u16,
586) -> Option<SplitPaneAction> {
587 use crossterm::event::{MouseButton, MouseEventKind};
588
589 let pos = match orientation {
590 Orientation::Horizontal => mouse.column,
591 Orientation::Vertical => mouse.row,
592 };
593
594 match mouse.kind {
595 MouseEventKind::Down(MouseButton::Left) => {
596 if let Some(&action) = registry.handle_click(mouse.column, mouse.row) {
597 if action == SplitPaneAction::DividerDrag {
598 state.start_drag(pos);
599 }
600 return Some(action);
601 }
602 }
603 MouseEventKind::Up(MouseButton::Left) => {
604 if state.is_dragging {
605 state.end_drag();
606 }
607 }
608 MouseEventKind::Drag(MouseButton::Left) => {
609 if state.is_dragging {
610 state.update_drag(pos, min_percent, max_percent);
611 }
612 }
613 _ => {}
614 }
615
616 None
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622
623 #[test]
624 fn test_state_creation() {
625 let state = SplitPaneState::new(30);
626 assert_eq!(state.split_percent, 30);
627 assert!(!state.is_dragging);
628 assert!(!state.focused);
629 }
630
631 #[test]
632 fn test_state_half() {
633 let state = SplitPaneState::half();
634 assert_eq!(state.split_percent, 50);
635 }
636
637 #[test]
638 fn test_split_percent_clamping() {
639 let state = SplitPaneState::new(150);
640 assert_eq!(state.split_percent, 100);
641
642 let mut state2 = SplitPaneState::new(50);
643 state2.set_split_percent(200);
644 assert_eq!(state2.split_percent, 100);
645 }
646
647 #[test]
648 fn test_drag_operations() {
649 let mut state = SplitPaneState::new(50);
650 state.set_total_size(100);
651
652 state.start_drag(50);
653 assert!(state.is_dragging);
654
655 state.update_drag(60, 10, 90);
656 assert_eq!(state.split_percent, 60);
657
658 state.end_drag();
659 assert!(!state.is_dragging);
660 }
661
662 #[test]
663 fn test_drag_respects_limits() {
664 let mut state = SplitPaneState::new(50);
665 state.set_total_size(100);
666
667 state.start_drag(50);
668 state.update_drag(5, 10, 90);
669 assert!(state.split_percent >= 10);
670
671 state.update_drag(95, 10, 90);
672 assert!(state.split_percent <= 90);
673 }
674
675 #[test]
676 fn test_adjust_split() {
677 let mut state = SplitPaneState::new(50);
678
679 state.adjust_split(10, 10, 90);
680 assert_eq!(state.split_percent, 60);
681
682 state.adjust_split(-20, 10, 90);
683 assert_eq!(state.split_percent, 40);
684 }
685
686 #[test]
687 fn test_calculate_areas_horizontal() {
688 let split_pane = SplitPane::new().orientation(Orientation::Horizontal);
689
690 let area = Rect::new(0, 0, 100, 50);
691 let (first, divider, second) = split_pane.calculate_areas(area, 50);
692
693 assert_eq!(first.width + divider.width + second.width, area.width);
694 assert_eq!(divider.width, 1);
695 }
696
697 #[test]
698 fn test_calculate_areas_vertical() {
699 let split_pane = SplitPane::new().orientation(Orientation::Vertical);
700
701 let area = Rect::new(0, 0, 100, 50);
702 let (first, divider, second) = split_pane.calculate_areas(area, 50);
703
704 assert_eq!(first.height + divider.height + second.height, area.height);
705 assert_eq!(divider.height, 1);
706 }
707
708 #[test]
709 fn test_focusable_trait() {
710 let mut state = SplitPaneState::new(50);
711 assert!(!state.is_focused());
712
713 state.set_focused(true);
714 assert!(state.is_focused());
715
716 state.divider_focused = true;
717 state.set_focused(false);
718 assert!(!state.divider_focused);
719 }
720}