1use crate::app::bell::BellState;
9use crate::app::mouse::MouseState;
10use crate::app::render_cache::RenderCache;
11use crate::config::Config;
12use crate::scroll_state::ScrollState;
13use crate::session_logger::{SharedSessionLogger, create_shared_logger};
14use crate::tab::build_shell_env;
15use crate::terminal::TerminalManager;
16use std::sync::Arc;
17use tokio::runtime::Runtime;
18use tokio::sync::Mutex;
19use tokio::task::JoinHandle;
20
21pub use par_term_config::PaneId;
23
24#[derive(Debug, Clone)]
26pub enum RestartState {
27 AwaitingInput,
29 AwaitingDelay(std::time::Instant),
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
35pub enum SplitDirection {
36 Horizontal,
38 Vertical,
40}
41
42#[derive(Debug, Clone, Copy, Default)]
44pub struct PaneBounds {
45 pub x: f32,
47 pub y: f32,
49 pub width: f32,
51 pub height: f32,
53}
54
55impl PaneBounds {
56 pub fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
58 Self {
59 x,
60 y,
61 width,
62 height,
63 }
64 }
65
66 pub fn contains(&self, px: f32, py: f32) -> bool {
68 px >= self.x && px < self.x + self.width && py >= self.y && py < self.y + self.height
69 }
70
71 pub fn center(&self) -> (f32, f32) {
73 (self.x + self.width / 2.0, self.y + self.height / 2.0)
74 }
75
76 pub fn grid_size(&self, cell_width: f32, cell_height: f32) -> (usize, usize) {
78 let cols = (self.width / cell_width).floor() as usize;
79 let rows = (self.height / cell_height).floor() as usize;
80 (cols.max(1), rows.max(1))
81 }
82}
83
84pub use par_term_config::{DividerRect, PaneBackground};
86
87pub struct Pane {
89 pub id: PaneId,
91 pub terminal: Arc<Mutex<TerminalManager>>,
93 pub scroll_state: ScrollState,
95 pub mouse: MouseState,
97 pub bell: BellState,
99 pub cache: RenderCache,
101 pub refresh_task: Option<JoinHandle<()>>,
103 pub working_directory: Option<String>,
105 pub last_activity_time: std::time::Instant,
107 pub last_seen_generation: u64,
109 pub anti_idle_last_activity: std::time::Instant,
111 pub anti_idle_last_generation: u64,
113 pub silence_notified: bool,
115 pub exit_notified: bool,
117 pub session_logger: SharedSessionLogger,
119 pub bounds: PaneBounds,
121 pub background: PaneBackground,
123 pub restart_state: Option<RestartState>,
125 pub(crate) shutdown_fast: bool,
127}
128
129impl Pane {
130 pub fn new(
132 id: PaneId,
133 config: &Config,
134 _runtime: Arc<Runtime>,
135 working_directory: Option<String>,
136 ) -> anyhow::Result<Self> {
137 let mut terminal = TerminalManager::new_with_scrollback(
139 config.cols,
140 config.rows,
141 config.scrollback_lines,
142 )?;
143
144 terminal.set_theme(config.load_theme());
146
147 terminal.set_max_clipboard_sync_events(config.clipboard_max_sync_events);
149 terminal.set_max_clipboard_event_bytes(config.clipboard_max_event_bytes);
150
151 if !config.answerback_string.is_empty() {
153 terminal.set_answerback_string(Some(config.answerback_string.clone()));
154 }
155
156 let width_config = par_term_emu_core_rust::WidthConfig::new(
158 config.unicode_version,
159 config.ambiguous_width,
160 );
161 terminal.set_width_config(width_config);
162
163 terminal.set_normalization_form(config.normalization_form);
165
166 {
168 use crate::config::CursorStyle as ConfigCursorStyle;
169 use par_term_emu_core_rust::cursor::CursorStyle as TermCursorStyle;
170 let term_style = if config.cursor_blink {
171 match config.cursor_style {
172 ConfigCursorStyle::Block => TermCursorStyle::BlinkingBlock,
173 ConfigCursorStyle::Underline => TermCursorStyle::BlinkingUnderline,
174 ConfigCursorStyle::Beam => TermCursorStyle::BlinkingBar,
175 }
176 } else {
177 match config.cursor_style {
178 ConfigCursorStyle::Block => TermCursorStyle::SteadyBlock,
179 ConfigCursorStyle::Underline => TermCursorStyle::SteadyUnderline,
180 ConfigCursorStyle::Beam => TermCursorStyle::SteadyBar,
181 }
182 };
183 terminal.set_cursor_style(term_style);
184 }
185
186 let work_dir = working_directory
188 .as_deref()
189 .or(config.working_directory.as_deref());
190
191 #[allow(unused_mut)] let (shell_cmd, mut shell_args) = if let Some(ref custom) = config.custom_shell {
194 (custom.clone(), config.shell_args.clone())
195 } else {
196 #[cfg(target_os = "windows")]
197 {
198 ("powershell.exe".to_string(), None)
199 }
200 #[cfg(not(target_os = "windows"))]
201 {
202 (
203 std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()),
204 None,
205 )
206 }
207 };
208
209 #[cfg(not(target_os = "windows"))]
211 if config.login_shell {
212 let args = shell_args.get_or_insert_with(Vec::new);
213 if !args.iter().any(|a| a == "-l" || a == "--login") {
214 args.insert(0, "-l".to_string());
215 }
216 }
217
218 let shell_args_deref = shell_args.as_deref();
219 let shell_env = build_shell_env(config.shell_env.as_ref());
220 terminal.spawn_custom_shell_with_dir(
221 &shell_cmd,
222 shell_args_deref,
223 work_dir,
224 shell_env.as_ref(),
225 )?;
226
227 let session_logger = create_shared_logger();
229
230 let terminal = Arc::new(Mutex::new(terminal));
231
232 Ok(Self {
233 id,
234 terminal,
235 scroll_state: ScrollState::new(),
236 mouse: MouseState::new(),
237 bell: BellState::new(),
238 cache: RenderCache::new(),
239 refresh_task: None,
240 working_directory: working_directory.or_else(|| config.working_directory.clone()),
241 last_activity_time: std::time::Instant::now(),
242 last_seen_generation: 0,
243 anti_idle_last_activity: std::time::Instant::now(),
244 anti_idle_last_generation: 0,
245 silence_notified: false,
246 exit_notified: false,
247 session_logger,
248 bounds: PaneBounds::default(),
249 background: PaneBackground::new(),
250 restart_state: None,
251 shutdown_fast: false,
252 })
253 }
254
255 pub fn new_for_tmux(
260 id: PaneId,
261 config: &Config,
262 _runtime: Arc<Runtime>,
263 ) -> anyhow::Result<Self> {
264 let mut terminal = TerminalManager::new_with_scrollback(
266 config.cols,
267 config.rows,
268 config.scrollback_lines,
269 )?;
270
271 terminal.set_theme(config.load_theme());
273
274 terminal.set_max_clipboard_sync_events(config.clipboard_max_sync_events);
276 terminal.set_max_clipboard_event_bytes(config.clipboard_max_event_bytes);
277
278 if !config.answerback_string.is_empty() {
280 terminal.set_answerback_string(Some(config.answerback_string.clone()));
281 }
282
283 let width_config = par_term_emu_core_rust::WidthConfig::new(
285 config.unicode_version,
286 config.ambiguous_width,
287 );
288 terminal.set_width_config(width_config);
289
290 terminal.set_normalization_form(config.normalization_form);
292
293 {
295 use crate::config::CursorStyle as ConfigCursorStyle;
296 use par_term_emu_core_rust::cursor::CursorStyle as TermCursorStyle;
297 let term_style = if config.cursor_blink {
298 match config.cursor_style {
299 ConfigCursorStyle::Block => TermCursorStyle::BlinkingBlock,
300 ConfigCursorStyle::Underline => TermCursorStyle::BlinkingUnderline,
301 ConfigCursorStyle::Beam => TermCursorStyle::BlinkingBar,
302 }
303 } else {
304 match config.cursor_style {
305 ConfigCursorStyle::Block => TermCursorStyle::SteadyBlock,
306 ConfigCursorStyle::Underline => TermCursorStyle::SteadyUnderline,
307 ConfigCursorStyle::Beam => TermCursorStyle::SteadyBar,
308 }
309 };
310 terminal.set_cursor_style(term_style);
311 }
312
313 let session_logger = create_shared_logger();
316
317 let terminal = Arc::new(Mutex::new(terminal));
318
319 Ok(Self {
320 id,
321 terminal,
322 scroll_state: ScrollState::new(),
323 mouse: MouseState::new(),
324 bell: BellState::new(),
325 cache: RenderCache::new(),
326 refresh_task: None,
327 working_directory: None,
328 last_activity_time: std::time::Instant::now(),
329 last_seen_generation: 0,
330 anti_idle_last_activity: std::time::Instant::now(),
331 anti_idle_last_generation: 0,
332 silence_notified: false,
333 exit_notified: false,
334 session_logger,
335 bounds: PaneBounds::default(),
336 background: PaneBackground::new(),
337 restart_state: None,
338 shutdown_fast: false,
339 })
340 }
341
342 pub fn is_bell_active(&self) -> bool {
344 const FLASH_DURATION_MS: u128 = 150;
345 if let Some(flash_start) = self.bell.visual_flash {
346 flash_start.elapsed().as_millis() < FLASH_DURATION_MS
347 } else {
348 false
349 }
350 }
351
352 pub fn is_running(&self) -> bool {
354 if let Ok(term) = self.terminal.try_lock() {
355 let running = term.is_running();
356 if !running {
357 crate::debug_info!(
358 "PANE_EXIT",
359 "Pane {} terminal detected as NOT running (shell exited)",
360 self.id
361 );
362 }
363 running
364 } else {
365 true }
367 }
368
369 pub fn get_cwd(&self) -> Option<String> {
371 if let Ok(term) = self.terminal.try_lock() {
372 term.shell_integration_cwd()
373 } else {
374 self.working_directory.clone()
375 }
376 }
377
378 pub fn set_background(&mut self, background: PaneBackground) {
380 self.background = background;
381 }
382
383 pub fn background(&self) -> &PaneBackground {
385 &self.background
386 }
387
388 pub fn set_background_image(&mut self, path: Option<String>) {
390 self.background.image_path = path;
391 }
392
393 pub fn get_background_image(&self) -> Option<&str> {
395 self.background.image_path.as_deref()
396 }
397
398 pub fn respawn_shell(&mut self, config: &Config) -> anyhow::Result<()> {
403 self.restart_state = None;
405 self.exit_notified = false;
406
407 #[allow(unused_mut)]
409 let (shell_cmd, mut shell_args) = if let Some(ref custom) = config.custom_shell {
410 (custom.clone(), config.shell_args.clone())
411 } else {
412 #[cfg(target_os = "windows")]
413 {
414 ("powershell.exe".to_string(), None)
415 }
416 #[cfg(not(target_os = "windows"))]
417 {
418 (
419 std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()),
420 None,
421 )
422 }
423 };
424
425 #[cfg(not(target_os = "windows"))]
427 if config.login_shell {
428 let args = shell_args.get_or_insert_with(Vec::new);
429 if !args.iter().any(|a| a == "-l" || a == "--login") {
430 args.insert(0, "-l".to_string());
431 }
432 }
433
434 let work_dir = self
436 .get_cwd()
437 .or_else(|| self.working_directory.clone())
438 .or_else(|| config.working_directory.clone());
439
440 let shell_args_deref = shell_args.as_deref();
441 let shell_env = build_shell_env(config.shell_env.as_ref());
442
443 if let Ok(mut term) = self.terminal.try_lock() {
445 term.process_data(b"\x1b[2J\x1b[H");
448
449 term.spawn_custom_shell_with_dir(
451 &shell_cmd,
452 shell_args_deref,
453 work_dir.as_deref(),
454 shell_env.as_ref(),
455 )?;
456
457 log::info!("Respawned shell in pane {}", self.id);
458 }
459
460 Ok(())
461 }
462
463 pub fn write_restart_prompt(&self) {
465 if let Ok(term) = self.terminal.try_lock() {
466 let message = "\r\n[Process exited. Press Enter to restart...]\r\n";
468 term.process_data(message.as_bytes());
469 }
470 }
471
472 pub fn get_title(&self) -> String {
474 if let Ok(term) = self.terminal.try_lock() {
475 let osc_title = term.get_title();
476 if !osc_title.is_empty() {
477 return osc_title;
478 }
479 if let Some(cwd) = term.shell_integration_cwd() {
480 let abbreviated = if let Some(home) = dirs::home_dir() {
482 cwd.replace(&home.to_string_lossy().to_string(), "~")
483 } else {
484 cwd
485 };
486 if let Some(last) = abbreviated.rsplit('/').next()
488 && !last.is_empty()
489 {
490 return last.to_string();
491 }
492 return abbreviated;
493 }
494 }
495 format!("Pane {}", self.id)
496 }
497
498 pub fn start_refresh_task(
500 &mut self,
501 runtime: Arc<Runtime>,
502 window: Arc<winit::window::Window>,
503 max_fps: u32,
504 ) {
505 let terminal_clone = Arc::clone(&self.terminal);
506 let refresh_interval_ms = 1000 / max_fps.max(1);
507
508 let handle = runtime.spawn(async move {
509 let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(
510 refresh_interval_ms as u64,
511 ));
512 let mut last_gen = 0;
513
514 loop {
515 interval.tick().await;
516
517 let should_redraw = if let Ok(term) = terminal_clone.try_lock() {
518 let current_gen = term.update_generation();
519 if current_gen > last_gen {
520 last_gen = current_gen;
521 true
522 } else {
523 term.has_updates()
524 }
525 } else {
526 false
527 };
528
529 if should_redraw {
530 window.request_redraw();
531 }
532 }
533 });
534
535 self.refresh_task = Some(handle);
536 }
537
538 pub fn stop_refresh_task(&mut self) {
540 if let Some(handle) = self.refresh_task.take() {
541 handle.abort();
542 }
543 }
544
545 pub fn resize_terminal(&self, cols: usize, rows: usize) {
547 if let Ok(mut term) = self.terminal.try_lock()
548 && term.dimensions() != (cols, rows)
549 {
550 let _ = term.resize(cols, rows);
551 }
552 }
553}
554
555impl Drop for Pane {
556 fn drop(&mut self) {
557 if self.shutdown_fast {
558 log::info!(
559 "Fast-dropping pane {} (cleanup handled externally)",
560 self.id
561 );
562 return;
563 }
564
565 log::info!("Dropping pane {}", self.id);
566
567 if let Some(ref mut logger) = *self.session_logger.lock() {
569 match logger.stop() {
570 Ok(path) => {
571 log::info!("Session log saved to: {:?}", path);
572 }
573 Err(e) => {
574 log::warn!("Failed to stop session logging: {}", e);
575 }
576 }
577 }
578
579 self.stop_refresh_task();
580
581 std::thread::sleep(std::time::Duration::from_millis(50));
583
584 if let Ok(mut term) = self.terminal.try_lock()
586 && term.is_running()
587 {
588 log::info!("Killing terminal for pane {}", self.id);
589 let _ = term.kill();
590 }
591 }
592}
593
594pub enum PaneNode {
600 Leaf(Box<Pane>),
602 Split {
604 direction: SplitDirection,
606 ratio: f32,
610 first: Box<PaneNode>,
612 second: Box<PaneNode>,
614 },
615}
616
617impl PaneNode {
618 pub fn leaf(pane: Pane) -> Self {
620 PaneNode::Leaf(Box::new(pane))
621 }
622
623 pub fn split(direction: SplitDirection, ratio: f32, first: PaneNode, second: PaneNode) -> Self {
625 PaneNode::Split {
626 direction,
627 ratio: ratio.clamp(0.1, 0.9), first: Box::new(first),
629 second: Box::new(second),
630 }
631 }
632
633 pub fn is_leaf(&self) -> bool {
635 matches!(self, PaneNode::Leaf(_))
636 }
637
638 pub fn as_pane(&self) -> Option<&Pane> {
640 match self {
641 PaneNode::Leaf(pane) => Some(pane),
642 PaneNode::Split { .. } => None,
643 }
644 }
645
646 pub fn as_pane_mut(&mut self) -> Option<&mut Pane> {
648 match self {
649 PaneNode::Leaf(pane) => Some(pane),
650 PaneNode::Split { .. } => None,
651 }
652 }
653
654 pub fn find_pane(&self, id: PaneId) -> Option<&Pane> {
656 match self {
657 PaneNode::Leaf(pane) => {
658 if pane.id == id {
659 Some(pane)
660 } else {
661 None
662 }
663 }
664 PaneNode::Split { first, second, .. } => {
665 first.find_pane(id).or_else(|| second.find_pane(id))
666 }
667 }
668 }
669
670 pub fn find_pane_mut(&mut self, id: PaneId) -> Option<&mut Pane> {
672 match self {
673 PaneNode::Leaf(pane) => {
674 if pane.id == id {
675 Some(pane)
676 } else {
677 None
678 }
679 }
680 PaneNode::Split { first, second, .. } => first
681 .find_pane_mut(id)
682 .or_else(move || second.find_pane_mut(id)),
683 }
684 }
685
686 pub fn find_pane_at(&self, x: f32, y: f32) -> Option<&Pane> {
688 match self {
689 PaneNode::Leaf(pane) => {
690 if pane.bounds.contains(x, y) {
691 Some(pane)
692 } else {
693 None
694 }
695 }
696 PaneNode::Split { first, second, .. } => first
697 .find_pane_at(x, y)
698 .or_else(|| second.find_pane_at(x, y)),
699 }
700 }
701
702 pub fn all_pane_ids(&self) -> Vec<PaneId> {
704 match self {
705 PaneNode::Leaf(pane) => vec![pane.id],
706 PaneNode::Split { first, second, .. } => {
707 let mut ids = first.all_pane_ids();
708 ids.extend(second.all_pane_ids());
709 ids
710 }
711 }
712 }
713
714 pub fn all_panes(&self) -> Vec<&Pane> {
716 match self {
717 PaneNode::Leaf(pane) => vec![pane],
718 PaneNode::Split { first, second, .. } => {
719 let mut panes = first.all_panes();
720 panes.extend(second.all_panes());
721 panes
722 }
723 }
724 }
725
726 pub fn all_panes_mut(&mut self) -> Vec<&mut Pane> {
728 match self {
729 PaneNode::Leaf(pane) => vec![pane],
730 PaneNode::Split { first, second, .. } => {
731 let mut panes = first.all_panes_mut();
732 panes.extend(second.all_panes_mut());
733 panes
734 }
735 }
736 }
737
738 pub fn pane_count(&self) -> usize {
740 match self {
741 PaneNode::Leaf(_) => 1,
742 PaneNode::Split { first, second, .. } => first.pane_count() + second.pane_count(),
743 }
744 }
745
746 pub fn calculate_bounds(&mut self, bounds: PaneBounds, divider_width: f32) {
751 match self {
752 PaneNode::Leaf(pane) => {
753 pane.bounds = bounds;
754 }
755 PaneNode::Split {
756 direction,
757 ratio,
758 first,
759 second,
760 } => {
761 let (first_bounds, second_bounds) = match direction {
762 SplitDirection::Horizontal => {
763 let first_height = (bounds.height - divider_width) * *ratio;
765 let second_height = bounds.height - first_height - divider_width;
766 (
767 PaneBounds::new(bounds.x, bounds.y, bounds.width, first_height),
768 PaneBounds::new(
769 bounds.x,
770 bounds.y + first_height + divider_width,
771 bounds.width,
772 second_height,
773 ),
774 )
775 }
776 SplitDirection::Vertical => {
777 let first_width = (bounds.width - divider_width) * *ratio;
779 let second_width = bounds.width - first_width - divider_width;
780 (
781 PaneBounds::new(bounds.x, bounds.y, first_width, bounds.height),
782 PaneBounds::new(
783 bounds.x + first_width + divider_width,
784 bounds.y,
785 second_width,
786 bounds.height,
787 ),
788 )
789 }
790 };
791
792 first.calculate_bounds(first_bounds, divider_width);
793 second.calculate_bounds(second_bounds, divider_width);
794 }
795 }
796 }
797
798 pub fn find_pane_in_direction(
803 &self,
804 from_id: PaneId,
805 direction: NavigationDirection,
806 ) -> Option<PaneId> {
807 let from_pane = self.find_pane(from_id)?;
809 let from_center = from_pane.bounds.center();
810
811 let all_panes = self.all_panes();
813
814 let mut best: Option<(PaneId, f32)> = None;
816
817 for pane in all_panes {
818 if pane.id == from_id {
819 continue;
820 }
821
822 let pane_center = pane.bounds.center();
823 let is_in_direction = match direction {
824 NavigationDirection::Left => pane_center.0 < from_center.0,
825 NavigationDirection::Right => pane_center.0 > from_center.0,
826 NavigationDirection::Up => pane_center.1 < from_center.1,
827 NavigationDirection::Down => pane_center.1 > from_center.1,
828 };
829
830 if is_in_direction {
831 let dx = (pane_center.0 - from_center.0).abs();
833 let dy = (pane_center.1 - from_center.1).abs();
834
835 let distance = match direction {
837 NavigationDirection::Left | NavigationDirection::Right => dx + dy * 2.0,
838 NavigationDirection::Up | NavigationDirection::Down => dy + dx * 2.0,
839 };
840
841 if best.is_none() || distance < best.unwrap().1 {
842 best = Some((pane.id, distance));
843 }
844 }
845 }
846
847 best.map(|(id, _)| id)
848 }
849
850 pub fn collect_dividers(&self, bounds: PaneBounds, divider_width: f32) -> Vec<DividerRect> {
856 let mut dividers = Vec::new();
857 self.collect_dividers_recursive(bounds, divider_width, &mut dividers);
858 dividers
859 }
860
861 fn collect_dividers_recursive(
863 &self,
864 bounds: PaneBounds,
865 divider_width: f32,
866 dividers: &mut Vec<DividerRect>,
867 ) {
868 match self {
869 PaneNode::Leaf(_) => {
870 }
872 PaneNode::Split {
873 direction,
874 ratio,
875 first,
876 second,
877 } => {
878 let (first_bounds, divider, second_bounds) = match direction {
880 SplitDirection::Horizontal => {
881 let first_height = (bounds.height - divider_width) * *ratio;
883 let second_height = bounds.height - first_height - divider_width;
884 (
885 PaneBounds::new(bounds.x, bounds.y, bounds.width, first_height),
886 DividerRect::new(
887 bounds.x,
888 bounds.y + first_height,
889 bounds.width,
890 divider_width,
891 true, ),
893 PaneBounds::new(
894 bounds.x,
895 bounds.y + first_height + divider_width,
896 bounds.width,
897 second_height,
898 ),
899 )
900 }
901 SplitDirection::Vertical => {
902 let first_width = (bounds.width - divider_width) * *ratio;
904 let second_width = bounds.width - first_width - divider_width;
905 (
906 PaneBounds::new(bounds.x, bounds.y, first_width, bounds.height),
907 DividerRect::new(
908 bounds.x + first_width,
909 bounds.y,
910 divider_width,
911 bounds.height,
912 false, ),
914 PaneBounds::new(
915 bounds.x + first_width + divider_width,
916 bounds.y,
917 second_width,
918 bounds.height,
919 ),
920 )
921 }
922 };
923
924 dividers.push(divider);
926
927 first.collect_dividers_recursive(first_bounds, divider_width, dividers);
929 second.collect_dividers_recursive(second_bounds, divider_width, dividers);
930 }
931 }
932 }
933}
934
935#[derive(Debug, Clone, Copy, PartialEq, Eq)]
937pub enum NavigationDirection {
938 Left,
939 Right,
940 Up,
941 Down,
942}
943
944#[cfg(test)]
945mod tests {
946 use super::*;
947
948 #[test]
949 fn test_pane_bounds_contains() {
950 let bounds = PaneBounds::new(10.0, 20.0, 100.0, 50.0);
951
952 assert!(bounds.contains(50.0, 40.0));
954 assert!(bounds.contains(10.0, 20.0)); assert!(!bounds.contains(5.0, 40.0)); assert!(!bounds.contains(150.0, 40.0)); assert!(!bounds.contains(50.0, 10.0)); assert!(!bounds.contains(50.0, 80.0)); }
962
963 #[test]
964 fn test_pane_bounds_grid_size() {
965 let bounds = PaneBounds::new(0.0, 0.0, 800.0, 600.0);
966 let (cols, rows) = bounds.grid_size(10.0, 20.0);
967 assert_eq!(cols, 80);
968 assert_eq!(rows, 30);
969 }
970
971 #[test]
972 fn test_split_direction_clone() {
973 let dir = SplitDirection::Horizontal;
974 let cloned = dir;
975 assert_eq!(dir, cloned);
976 }
977}