Skip to main content

yarli_cli/dashboard/
copy_mode.rs

1//! Copy mode — Zellij-inspired escape hatch for native text selection (Section 16.3).
2//!
3//! When active, disables mouse capture and strips panel borders so the user
4//! can select and copy text with their terminal's native selection mechanism.
5
6use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
7use crossterm::ExecutableCommand;
8use std::io::{self, Write};
9
10use ratatui::style::{Color, Modifier, Style};
11use ratatui::text::{Line, Span};
12
13/// Manages copy mode state and terminal mouse capture.
14pub struct CopyMode {
15    /// Whether copy mode is currently active.
16    active: bool,
17    /// Whether mouse capture was enabled before entering copy mode.
18    mouse_was_enabled: bool,
19}
20
21impl CopyMode {
22    /// Create a new CopyMode in inactive state.
23    pub fn new() -> Self {
24        Self {
25            active: false,
26            mouse_was_enabled: false,
27        }
28    }
29
30    /// Whether copy mode is currently active.
31    pub fn is_active(&self) -> bool {
32        self.active
33    }
34
35    /// Toggle copy mode on/off. Manages mouse capture accordingly.
36    pub fn toggle<W: Write>(&mut self, writer: &mut W) -> io::Result<()> {
37        if self.active {
38            self.deactivate(writer)
39        } else {
40            self.activate(writer)
41        }
42    }
43
44    /// Enter copy mode: disable mouse capture.
45    pub fn activate<W: Write>(&mut self, writer: &mut W) -> io::Result<()> {
46        if !self.active {
47            self.mouse_was_enabled = true;
48            writer.execute(DisableMouseCapture)?;
49            self.active = true;
50        }
51        Ok(())
52    }
53
54    /// Exit copy mode: re-enable mouse capture if it was previously enabled.
55    pub fn deactivate<W: Write>(&mut self, writer: &mut W) -> io::Result<()> {
56        if self.active {
57            if self.mouse_was_enabled {
58                writer.execute(EnableMouseCapture)?;
59            }
60            self.active = false;
61        }
62        Ok(())
63    }
64
65    /// Build a status indicator span for the key hints bar.
66    pub fn status_indicator(&self) -> Option<Span<'static>> {
67        if self.active {
68            Some(Span::styled(
69                "[COPY] ",
70                Style::default()
71                    .fg(Color::Yellow)
72                    .add_modifier(Modifier::BOLD),
73            ))
74        } else {
75            None
76        }
77    }
78
79    /// Whether panel borders should be stripped in copy mode.
80    pub fn strip_borders(&self) -> bool {
81        self.active
82    }
83
84    /// Build a copy mode banner line for display at the top of the screen.
85    pub fn banner_line(&self) -> Option<Line<'static>> {
86        if self.active {
87            Some(Line::from(vec![
88                Span::styled(
89                    " COPY MODE ",
90                    Style::default()
91                        .fg(Color::Black)
92                        .bg(Color::Yellow)
93                        .add_modifier(Modifier::BOLD),
94                ),
95                Span::styled(
96                    " Select text with your terminal. Press c to exit.",
97                    Style::default().fg(Color::Yellow),
98                ),
99            ]))
100        } else {
101            None
102        }
103    }
104}
105
106impl Default for CopyMode {
107    fn default() -> Self {
108        Self::new()
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn new_is_inactive() {
118        let cm = CopyMode::new();
119        assert!(!cm.is_active());
120        assert!(!cm.strip_borders());
121    }
122
123    #[test]
124    fn status_indicator_when_inactive() {
125        let cm = CopyMode::new();
126        assert!(cm.status_indicator().is_none());
127    }
128
129    #[test]
130    fn status_indicator_when_active() {
131        let mut cm = CopyMode::new();
132        cm.active = true;
133        let span = cm.status_indicator().unwrap();
134        assert!(span.content.contains("COPY"));
135    }
136
137    #[test]
138    fn strip_borders_follows_active() {
139        let mut cm = CopyMode::new();
140        assert!(!cm.strip_borders());
141        cm.active = true;
142        assert!(cm.strip_borders());
143    }
144
145    #[test]
146    fn banner_when_inactive() {
147        let cm = CopyMode::new();
148        assert!(cm.banner_line().is_none());
149    }
150
151    #[test]
152    fn banner_when_active() {
153        let mut cm = CopyMode::new();
154        cm.active = true;
155        let line = cm.banner_line().unwrap();
156        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
157        assert!(text.contains("COPY MODE"));
158        assert!(text.contains("Press c to exit"));
159    }
160
161    #[test]
162    fn default_is_inactive() {
163        let cm = CopyMode::default();
164        assert!(!cm.is_active());
165    }
166
167    #[test]
168    fn activate_sets_active() {
169        let mut cm = CopyMode::new();
170        // Can't test actual stdout interaction in unit tests, so test state directly.
171        cm.active = true;
172        cm.mouse_was_enabled = true;
173        assert!(cm.is_active());
174        assert!(cm.strip_borders());
175    }
176
177    #[test]
178    fn deactivate_clears_active() {
179        let mut cm = CopyMode::new();
180        cm.active = true;
181        cm.mouse_was_enabled = true;
182        // Simulate deactivation without stdout.
183        cm.active = false;
184        assert!(!cm.is_active());
185        assert!(!cm.strip_borders());
186    }
187}