Skip to content

Commit 2916e0b

Browse files
feat: overrideable clipboard
1 parent 709c65d commit 2916e0b

5 files changed

Lines changed: 201 additions & 18 deletions

File tree

src/core/commands.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ use crate::{
1212
minus_core::utils::display::AppendStyle,
1313
};
1414

15+
#[cfg(feature = "clipboard")]
16+
use crate::state::ClipboardHandler;
17+
1518
#[cfg(feature = "search")]
1619
use crate::search::SearchOpts;
1720

@@ -54,6 +57,8 @@ pub enum Command {
5457
// Configuration options
5558
SetExitStrategy(ExitStrategy),
5659
SetInputClassifier(Box<dyn InputClassifier + Send + Sync + 'static>),
60+
#[cfg(feature = "clipboard")]
61+
SetClipboardHandler(ClipboardHandler),
5762
AddExitCallback(Box<dyn FnMut() + Send + Sync + 'static>),
5863
AddHook(Hook, u64, HookCallback),
5964
RemoveHook(Hook, u64),
@@ -82,6 +87,8 @@ impl PartialEq for Command {
8287
| (Self::AddExitCallback(_), Self::AddExitCallback(_))
8388
| (Self::AddHook(..), Self::AddHook(..))
8489
| (Self::SetOutputSink(_), Self::SetOutputSink(_)) => true,
90+
#[cfg(feature = "clipboard")]
91+
(Self::SetClipboardHandler(_), Self::SetClipboardHandler(_)) => true,
8592
(Self::RemoveHook(h1, id1), Self::RemoveHook(h2, id2)) => h1 == h2 && id1 == id2,
8693
#[cfg(feature = "search")]
8794
(Self::IncrementalSearchCondition(_), Self::IncrementalSearchCondition(_)) => true,
@@ -102,6 +109,8 @@ impl Debug for Command {
102109
Self::LineWrapping(lw) => write!(f, "LineWrapping({lw:?})"),
103110
Self::SetExitStrategy(es) => write!(f, "SetExitStrategy({es:?})"),
104111
Self::SetInputClassifier(_) => write!(f, "SetInputClassifier"),
112+
#[cfg(feature = "clipboard")]
113+
Self::SetClipboardHandler(_) => write!(f, "SetClipboardHandler"),
105114
Self::ShowPrompt(show) => write!(f, "ShowPrompt({show:?})"),
106115
#[cfg(feature = "search")]
107116
Self::IncrementalSearchCondition(_) => write!(f, "IncrementalSearchCondition"),

src/core/ev_handler.rs

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,12 @@ pub fn handle_event(
140140

141141
#[cfg(feature = "clipboard")]
142142
Command::UserInput(InputEvent::CopySelection) => {
143-
if let Some(text) = p.extract_selection()
144-
&& let Ok(mut clipboard) = arboard::Clipboard::new()
145-
{
146-
let _ = clipboard.set_text(text);
143+
if let Some(text) = p.extract_selection() {
144+
if let Some(handler) = p.clipboard_handler.as_ref() {
145+
handler(&text);
146+
} else if let Ok(mut clipboard) = arboard::Clipboard::new() {
147+
let _ = clipboard.set_text(text);
148+
}
147149
}
148150
if p.selection.is_some() || p.selection_anchor.is_some() {
149151
p.clear_selection();
@@ -371,6 +373,8 @@ pub fn handle_event(
371373
#[cfg(feature = "search")]
372374
Command::IncrementalSearchCondition(cb) => p.search_state.incremental_search_condition = cb,
373375
Command::SetInputClassifier(clf) => p.input_classifier = clf,
376+
#[cfg(feature = "clipboard")]
377+
Command::SetClipboardHandler(handler) => p.clipboard_handler = Some(handler),
374378
Command::AddExitCallback(cb) => p.exit_callbacks.push(cb),
375379
Command::AddHook(hook, id, cb) => p.hooks.add_callback(hook, id, cb),
376380
Command::RemoveHook(hook, id) => {
@@ -797,4 +801,33 @@ mod tests {
797801
Some(Command::Io(IoCommand::RedrawDisplay))
798802
);
799803
}
804+
805+
#[test]
806+
#[cfg(feature = "clipboard")]
807+
fn copy_selection_uses_clipboard_handler() {
808+
let mut ps = PagerState::new().unwrap();
809+
ps.screen.line_wrapping = false;
810+
ps.screen.orig_text = "hello world\n".to_string();
811+
ps.reformat_display();
812+
ps.selection_anchor = ps.selection_from_coordinates(0, 0);
813+
ps.selection = ps.selection_from_coordinates(10, 0);
814+
815+
let copied = Arc::new(std::sync::Mutex::new(None::<String>));
816+
let copied_handler = copied.clone();
817+
ps.clipboard_handler = Some(Box::new(move |text| {
818+
*copied_handler.lock().unwrap() = Some(text.to_string());
819+
}));
820+
821+
let mut command_queue = CommandQueue::new_zero();
822+
handle_event(
823+
Command::UserInput(InputEvent::CopySelection),
824+
&mut ps,
825+
&mut command_queue,
826+
&Arc::new(AtomicBool::new(false)),
827+
);
828+
829+
assert_eq!(copied.lock().unwrap().as_deref(), Some("hello world"));
830+
assert_eq!(ps.selection, None);
831+
assert_eq!(ps.selection_anchor, None);
832+
}
800833
}

src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,10 @@ pub use pager::Pager;
218218
pub use sink::OutputSink;
219219
pub use state::PagerState;
220220

221+
#[cfg(feature = "clipboard")]
222+
#[cfg_attr(docsrs, cfg(feature = "clipboard"))]
223+
pub use state::ClipboardHandler;
224+
221225
/// A convenient type for `Vec<Box<dyn FnMut() + Send + Sync + 'static>>`
222226
pub type ExitCallbacks = Vec<Box<dyn FnMut() + Send + Sync + 'static>>;
223227

src/pager.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ use crate::{
1010
use crossbeam_channel::{Receiver, Sender};
1111
use std::fmt;
1212

13+
#[cfg(feature = "clipboard")]
14+
use crate::state::ClipboardHandler;
15+
1316
#[cfg(feature = "search")]
1417
use crate::search::SearchOpts;
1518

@@ -289,6 +292,24 @@ impl Pager {
289292
Ok(self.tx.send(Command::SetInputClassifier(handler))?)
290293
}
291294

295+
/// Set a callback that writes selected text to the clipboard.
296+
///
297+
/// When set, the copy action (`y` or releasing the left mouse button over
298+
/// a selection) writes the selected text through this callback instead of
299+
/// creating a fresh `arboard::Clipboard` handle, so the application can
300+
/// reuse an existing clipboard connection.
301+
///
302+
/// # Errors
303+
/// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data
304+
/// could not be sent to the receiver
305+
#[cfg(feature = "clipboard")]
306+
pub fn set_clipboard_handler(
307+
&self,
308+
handler: ClipboardHandler,
309+
) -> Result<(), MinusError> {
310+
Ok(self.tx.send(Command::SetClipboardHandler(handler))?)
311+
}
312+
292313
/// Adds a function that will be called when the user quits the pager
293314
///
294315
/// Multiple functions can be stored for calling when the user quits. These functions

src/state.rs

Lines changed: 130 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ pub struct Selection {
9494
///
9595
/// Various fields are made public so that their values can be accessed while implementing the
9696
/// trait.
97+
#[cfg(feature = "clipboard")]
98+
#[cfg_attr(docsrs, cfg(feature = "clipboard"))]
99+
pub type ClipboardHandler = Box<dyn Fn(&str) + Send + Sync + 'static>;
100+
97101
#[derive(Clone, Debug)]
98102
pub(crate) struct HelpState {
99103
pub(crate) screen: Screen,
@@ -153,6 +157,10 @@ pub struct PagerState {
153157
pub(crate) prompt: String,
154158
/// The input classifier to be called when a input is detected
155159
pub(crate) input_classifier: Box<dyn input::InputClassifier + Sync + Send>,
160+
/// Callback that writes selected text to the clipboard when set; without
161+
/// it, `CopySelection` creates its own `arboard::Clipboard` handle.
162+
#[cfg(feature = "clipboard")]
163+
pub(crate) clipboard_handler: Option<ClipboardHandler>,
156164
/// Functions to run when the pager quits
157165
pub(crate) exit_callbacks: Vec<Box<dyn FnMut() + Send + Sync + 'static>>,
158166
/// Callbacks for hooks
@@ -216,6 +224,8 @@ impl PagerState {
216224
running: &minus_core::RUNMODE,
217225
left_mark: 0,
218226
input_classifier: Box::<HashedEventRegister<RandomState>>::default(),
227+
#[cfg(feature = "clipboard")]
228+
clipboard_handler: None,
219229
exit_callbacks: Vec::with_capacity(5),
220230
hooks: Hooks::new(),
221231
message: None,
@@ -490,23 +500,24 @@ impl PagerState {
490500

491501
let mut selected = Vec::with_capacity(end_line.saturating_sub(start_line) + 1);
492502
for line_idx in start_line..=end_line {
493-
let line = *lines.get(line_idx)?;
503+
let raw_line = *lines.get(line_idx)?;
504+
let line = strip_ansi(raw_line);
494505
let line_len = line.chars().count();
495506
let start_col = if line_idx == start_line {
496-
self.selection_col_in_line(start, line_idx, line)
507+
self.selection_col_in_line(start, line_idx, &line)
497508
.min(line_len)
498509
} else {
499510
0
500511
};
501512
let end_col = if line_idx == end_line {
502-
self.selection_col_in_line(end, line_idx, line)
513+
self.selection_col_in_line(end, line_idx, &line)
503514
.saturating_add(1)
504515
.min(line_len)
505516
} else {
506517
line_len
507518
};
508519

509-
selected.push(slice_chars(line, start_col, end_col).to_string());
520+
selected.push(slice_chars(&line, start_col, end_col).to_string());
510521
}
511522

512523
Some(selected.join("\n"))
@@ -701,29 +712,34 @@ fn highlight_visible_range(line: Cow<str>, start: usize, end: usize) -> Cow<str>
701712
let mut highlighted = false;
702713

703714
while byte_idx < bytes.len() {
715+
if highlighted && visible_idx == end {
716+
out.push_str(RESET);
717+
highlighted = false;
718+
}
719+
if !highlighted && visible_idx == start {
720+
out.push_str(REVERSE);
721+
highlighted = true;
722+
}
723+
704724
if bytes[byte_idx] == b'\x1b' && bytes.get(byte_idx + 1) == Some(&b'[') {
705725
let esc_start = byte_idx;
706726
byte_idx += 2;
727+
let mut final_byte = 0;
707728
while byte_idx < bytes.len() {
708729
let byte = bytes[byte_idx];
709730
byte_idx += 1;
710731
if (0x40..=0x7e).contains(&byte) {
732+
final_byte = byte;
711733
break;
712734
}
713735
}
714736
out.push_str(&line[esc_start..byte_idx]);
737+
if highlighted && final_byte == b'm' {
738+
out.push_str(REVERSE);
739+
}
715740
continue;
716741
}
717742

718-
if !highlighted && visible_idx == start {
719-
out.push_str(REVERSE);
720-
highlighted = true;
721-
}
722-
if highlighted && visible_idx == end {
723-
out.push_str(RESET);
724-
highlighted = false;
725-
}
726-
727743
let ch = line[byte_idx..].chars().next().unwrap();
728744
out.push(ch);
729745
visible_idx += 1;
@@ -737,9 +753,66 @@ fn highlight_visible_range(line: Cow<str>, start: usize, end: usize) -> Cow<str>
737753
out.into()
738754
}
739755

756+
pub(crate) fn strip_ansi(s: &str) -> String {
757+
let mut out = String::with_capacity(s.len());
758+
let bytes = s.as_bytes();
759+
let mut i = 0;
760+
while i < bytes.len() {
761+
if bytes[i] == b'\x1b' {
762+
if i + 1 < bytes.len() {
763+
match bytes[i + 1] {
764+
b'[' => {
765+
i += 2;
766+
while i < bytes.len() {
767+
let b = bytes[i];
768+
i += 1;
769+
if (0x40..=0x7e).contains(&b) {
770+
break;
771+
}
772+
}
773+
}
774+
b']' => {
775+
i += 2;
776+
while i < bytes.len() {
777+
if bytes[i] == 0x07 {
778+
i += 1;
779+
break;
780+
}
781+
if bytes[i] == b'\x1b' && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
782+
i += 2;
783+
break;
784+
}
785+
i += 1;
786+
}
787+
}
788+
_ => {
789+
i += 2;
790+
}
791+
}
792+
} else {
793+
i += 1;
794+
}
795+
} else if bytes[i] == 0x9b {
796+
i += 1;
797+
while i < bytes.len() {
798+
let b = bytes[i];
799+
i += 1;
800+
if (0x40..=0x7e).contains(&b) {
801+
break;
802+
}
803+
}
804+
} else {
805+
let ch = s[i..].chars().next().unwrap();
806+
out.push(ch);
807+
i += ch.len_utf8();
808+
}
809+
}
810+
out
811+
}
812+
740813
#[cfg(test)]
741814
mod tests {
742-
use super::{PagerState, Selection};
815+
use super::{PagerState, Selection, highlight_visible_range, strip_ansi};
743816
use crate::LineNumbers;
744817

745818
#[test]
@@ -762,6 +835,49 @@ mod tests {
762835
);
763836
}
764837

838+
#[test]
839+
fn extract_selection_with_ansi_styles() {
840+
let mut ps = PagerState::new().unwrap();
841+
ps.line_numbers = LineNumbers::Disabled;
842+
ps.screen.line_wrapping = false;
843+
ps.screen.orig_text =
844+
"\x1b[31mhello\x1b[0m \x1b[1;32mworld\x1b[0m\n\x1b[34msecond\x1b[0m line\n".to_string();
845+
ps.reformat_display();
846+
847+
// Select "hello world" from line 0
848+
ps.selection_anchor = ps.selection_from_coordinates(0, 0);
849+
ps.selection = ps.selection_from_coordinates(10, 0);
850+
assert_eq!(ps.extract_selection().as_deref(), Some("hello world"));
851+
852+
// Select "world" from line 0
853+
ps.selection_anchor = ps.selection_from_coordinates(6, 0);
854+
ps.selection = ps.selection_from_coordinates(10, 0);
855+
assert_eq!(ps.extract_selection().as_deref(), Some("world"));
856+
}
857+
858+
#[test]
859+
fn test_strip_ansi() {
860+
assert_eq!(strip_ansi(""), "");
861+
assert_eq!(strip_ansi("plain text"), "plain text");
862+
assert_eq!(strip_ansi("\x1b[31mhello\x1b[0m"), "hello");
863+
assert_eq!(strip_ansi("\x1b[1;38;2;255;0;0mRGB\x1b[0m text"), "RGB text");
864+
assert_eq!(strip_ansi("\x1b]8;;https://example.com\x07link\x1b]8;;\x07"), "link");
865+
assert_eq!(strip_ansi("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\"), "link");
866+
}
867+
868+
#[test]
869+
fn test_highlight_visible_range_with_ansi_styles() {
870+
use std::borrow::Cow;
871+
// Selection across style reset and color change: "hello world"
872+
let line = Cow::Borrowed("\x1b[31mhello\x1b[0m \x1b[32mworld\x1b[0m");
873+
let highlighted = highlight_visible_range(line, 0, 11);
874+
// Highlight should be active for "hello", re-asserted after \x1b[0m and \x1b[32m, and reset after 11
875+
assert_eq!(
876+
highlighted.as_ref(),
877+
"\x1b[7m\x1b[31m\x1b[7mhello\x1b[0m\x1b[7m \x1b[32m\x1b[7mworld\x1b[27m\x1b[0m"
878+
);
879+
}
880+
765881
#[test]
766882
fn extract_selection_across_wrapped_rows() {
767883
let mut ps = PagerState::new().unwrap();

0 commit comments

Comments
 (0)