From c6bafeb783257805b82d32db52c8c624f334e425 Mon Sep 17 00:00:00 2001 From: Ewart Nijburg Date: Fri, 15 May 2026 18:06:36 +0200 Subject: [PATCH] Implement Ctrl+drag block selection and rectangular copy --- Cargo.lock | 440 +++++++++++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 325 +++++++++++++++++++++++++++++++------- 2 files changed, 703 insertions(+), 62 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1d9e034..3948a77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "anstream" version = "1.0.0" @@ -38,7 +44,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -49,7 +55,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -58,12 +64,50 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "cfg-if" version = "1.0.4" @@ -110,6 +154,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -125,6 +178,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossterm" version = "0.29.0" @@ -152,6 +214,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "derive_more" version = "2.1.1" @@ -174,6 +242,16 @@ dependencies = [ "syn", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "document-features" version = "0.2.12" @@ -190,7 +268,59 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", ] [[package]] @@ -199,6 +329,20 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -210,6 +354,7 @@ name = "large_file_viewer" version = "0.1.0" dependencies = [ "anyhow", + "arboard", "clap", "crossterm", "memmap2", @@ -257,6 +402,16 @@ dependencies = [ "libc", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.0" @@ -266,7 +421,99 @@ dependencies = [ "libc", "log", "wasi", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", ] [[package]] @@ -298,6 +545,25 @@ dependencies = [ "windows-link", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -307,6 +573,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quote" version = "1.0.45" @@ -344,7 +622,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -390,6 +668,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "smallvec" version = "1.15.1" @@ -413,6 +697,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -437,6 +735,12 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -465,6 +769,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -473,3 +786,120 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/src/main.rs b/src/main.rs index 09ffc25..becb742 100644 --- a/src/main.rs +++ b/src/main.rs @@ -66,6 +66,8 @@ struct Viewer { cursor_offset: usize, selection_anchor: Option, selection_focus: Option, + block_selection_anchor: Option<(usize, usize)>, + block_selection_focus: Option<(usize, usize)>, status_message: Option, } @@ -204,6 +206,8 @@ impl Viewer { cursor_offset: 0, selection_anchor: None, selection_focus: None, + block_selection_anchor: None, + block_selection_focus: None, status_message: None, }) } @@ -360,6 +364,7 @@ impl Viewer { None } }); + let block_selection = self.block_selection_range(); let bytes = &view_bytes[line_start..line_end]; let content_start = skipped_prefix_len(line_idx, bytes); let mut segments: Vec<(bool, RenderClass, String)> = Vec::new(); @@ -376,28 +381,6 @@ impl Viewer { .then(Vec::new) .unwrap_or_else(|| classify_json_line(bytes)); - let mut push_char = |c: char, is_highlight: bool, render_class: RenderClass| { - if absolute_col < self.left_col { - absolute_col += 1; - return; - } - if visible_width >= max_width { - absolute_col += 1; - return; - } - if segments - .last() - .map(|(h, class, _)| *h != is_highlight || *class != render_class) - .unwrap_or(true) - { - segments.push((is_highlight, render_class, String::new())); - } - let (_, _, target) = segments.last_mut().expect("segment just pushed"); - target.push(c); - visible_width += 1; - absolute_col += 1; - }; - if let Some(column_widths) = &self.csv_column_widths { let separator = self.csv_separator.unwrap_or(b','); let mut column_idx = 0usize; @@ -412,7 +395,17 @@ impl Viewer { let is_selected = selection .map(|(start, end)| absolute_idx >= start && absolute_idx < end) .unwrap_or(false); + let current_col = absolute_col; + let is_block_selected = if let Some((top, bottom, left, right)) = block_selection { + line_idx >= top + && line_idx <= bottom + && current_col >= left + && current_col <= right + } else { + false + }; let is_highlight = is_selected + || is_block_selected || highlight .map(|(start, end)| absolute_idx >= start && absolute_idx < end) .unwrap_or(false); @@ -421,25 +414,79 @@ impl Viewer { b if b == separator => { let target_width = column_widths.get(column_idx).copied().unwrap_or(0); for _ in field_width..target_width { - push_char(' ', false, RenderClass::Text); + push_char( + ' ', + false, + RenderClass::Text, + self.left_col, + max_width, + &mut absolute_col, + &mut visible_width, + &mut segments, + ); } - push_char(separator as char, is_highlight, RenderClass::Text); - push_char(' ', false, RenderClass::Text); + push_char( + separator as char, + is_highlight, + RenderClass::Text, + self.left_col, + max_width, + &mut absolute_col, + &mut visible_width, + &mut segments, + ); + push_char( + ' ', + false, + RenderClass::Text, + self.left_col, + max_width, + &mut absolute_col, + &mut visible_width, + &mut segments, + ); column_idx += 1; field_width = 0; } b'\t' => { for _ in 0..self.tab_width { - push_char(' ', is_highlight, RenderClass::Text); + push_char( + ' ', + is_highlight, + RenderClass::Text, + self.left_col, + max_width, + &mut absolute_col, + &mut visible_width, + &mut segments, + ); field_width += 1; } } 0x20..=0x7e => { - push_char(b as char, is_highlight, RenderClass::Text); + push_char( + b as char, + is_highlight, + RenderClass::Text, + self.left_col, + max_width, + &mut absolute_col, + &mut visible_width, + &mut segments, + ); field_width += 1; } _ => { - push_char('·', is_highlight, RenderClass::Text); + push_char( + '·', + is_highlight, + RenderClass::Text, + self.left_col, + max_width, + &mut absolute_col, + &mut visible_width, + &mut segments, + ); field_width += 1; } } @@ -454,7 +501,17 @@ impl Viewer { let is_selected = selection .map(|(start, end)| absolute_idx >= start && absolute_idx < end) .unwrap_or(false); + let current_col = absolute_col; + let is_block_selected = if let Some((top, bottom, left, right)) = block_selection { + line_idx >= top + && line_idx <= bottom + && current_col >= left + && current_col <= right + } else { + false + }; let is_highlight = is_selected + || is_block_selected || highlight .map(|(start, end)| absolute_idx >= start && absolute_idx < end) .unwrap_or(false); @@ -485,11 +542,38 @@ impl Viewer { match b { b'\t' => { for _ in 0..self.tab_width { - push_char(' ', is_highlight, render_class); + push_char( + ' ', + is_highlight, + render_class, + self.left_col, + max_width, + &mut absolute_col, + &mut visible_width, + &mut segments, + ); } } - 0x20..=0x7e => push_char(b as char, is_highlight, render_class), - _ => push_char('·', is_highlight, render_class), + 0x20..=0x7e => push_char( + b as char, + is_highlight, + render_class, + self.left_col, + max_width, + &mut absolute_col, + &mut visible_width, + &mut segments, + ), + _ => push_char( + '·', + is_highlight, + render_class, + self.left_col, + max_width, + &mut absolute_col, + &mut visible_width, + &mut segments, + ), } } } @@ -688,6 +772,48 @@ impl Viewer { fn clear_selection(&mut self) { self.selection_anchor = None; self.selection_focus = None; + self.block_selection_anchor = None; + self.block_selection_focus = None; + } + + fn block_selection_range(&self) -> Option<(usize, usize, usize, usize)> { + let (a_line, a_col) = self.block_selection_anchor?; + let (b_line, b_col) = self.block_selection_focus?; + if a_line == b_line && a_col == b_col { + None + } else { + Some(( + a_line.min(b_line), + a_line.max(b_line), + a_col.min(b_col), + a_col.max(b_col), + )) + } + } + + fn line_display_chars(&self, line_idx: usize) -> Vec { + let line_start = self.line_offsets[line_idx]; + let view = self.view_bytes(); + let line_end = if line_idx + 1 < self.line_offsets.len() { + self.line_offsets[line_idx + 1] + } else { + view.len() + }; + let bytes = &view[line_start..line_end]; + let mut out = Vec::new(); + for &b in bytes.iter().skip(skipped_prefix_len(line_idx, bytes)) { + if b == b'\n' || b == b'\r' { + continue; + } + if b == b'\t' { + out.extend(std::iter::repeat_n(' ', self.tab_width)); + } else if (0x20..=0x7e).contains(&b) { + out.push(b as char); + } else { + out.push('·'); + } + } + out } fn offset_for_line_col(&self, line_idx: usize, target_col: usize) -> usize { @@ -721,6 +847,37 @@ fn clip_to_width(s: &str, max_width: usize) -> String { s.chars().take(max_width).collect() } +fn push_char( + c: char, + is_highlight: bool, + render_class: RenderClass, + left_col: usize, + max_width: usize, + absolute_col: &mut usize, + visible_width: &mut usize, + segments: &mut Vec<(bool, RenderClass, String)>, +) { + if *absolute_col < left_col { + *absolute_col += 1; + return; + } + if *visible_width >= max_width { + *absolute_col += 1; + return; + } + if segments + .last() + .map(|(h, class, _)| *h != is_highlight || *class != render_class) + .unwrap_or(true) + { + segments.push((is_highlight, render_class, String::new())); + } + let (_, _, target) = segments.last_mut().expect("segment just pushed"); + target.push(c); + *visible_width += 1; + *absolute_col += 1; +} + fn skipped_prefix_len(line_idx: usize, bytes: &[u8]) -> usize { if line_idx == 0 && bytes.starts_with(&[0xEF_u8, 0xBB_u8, 0xBF_u8]) { 3 @@ -1206,26 +1363,7 @@ fn run_event_loop(viewer: &mut Viewer, out: &mut impl Write) -> Result<()> { match key.code { KeyCode::Char('q') => break, KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - if let Some((start, end)) = viewer.selection_range() { - let copied = - String::from_utf8_lossy(&viewer.view_bytes()[start..end]) - .to_string(); - if let Some(cb) = clipboard.as_mut() { - if cb.set_text(copied).is_ok() { - viewer.status_message = Some(format!( - "Copied {} bytes to clipboard", - end - start - )); - } else { - viewer.status_message = Some( - "Failed to copy selection to clipboard".to_string(), - ); - } - } else { - viewer.status_message = Some( - "Clipboard unavailable in this environment".to_string(), - ); - } + if copy_selection_to_clipboard(viewer, &mut clipboard) { needs_redraw = true; } } @@ -1365,9 +1503,20 @@ fn run_event_loop(viewer: &mut Viewer, out: &mut impl Write) -> Result<()> { let col = viewer.left_col + mouse.column as usize; let offset = viewer.offset_for_line_col(line_idx, col); viewer.cursor_offset = offset; - viewer.selection_anchor = Some(offset); - viewer.selection_focus = Some(offset); - viewer.status_message = Some("Selecting text…".to_string()); + if mouse.modifiers.contains(KeyModifiers::CONTROL) { + viewer.selection_anchor = None; + viewer.selection_focus = None; + viewer.block_selection_anchor = Some((line_idx, col)); + viewer.block_selection_focus = Some((line_idx, col)); + viewer.status_message = + Some("Selecting block…".to_string()); + } else { + viewer.block_selection_anchor = None; + viewer.block_selection_focus = None; + viewer.selection_anchor = Some(offset); + viewer.selection_focus = Some(offset); + viewer.status_message = Some("Selecting text…".to_string()); + } needs_redraw = true; } } @@ -1384,7 +1533,11 @@ fn run_event_loop(viewer: &mut Viewer, out: &mut impl Write) -> Result<()> { let col = viewer.left_col + mouse.column as usize; let offset = viewer.offset_for_line_col(line_idx, col); viewer.cursor_offset = offset; - viewer.selection_focus = Some(offset); + if viewer.block_selection_anchor.is_some() { + viewer.block_selection_focus = Some((line_idx, col)); + } else { + viewer.selection_focus = Some(offset); + } needs_redraw = true; } } @@ -1393,13 +1546,25 @@ fn run_event_loop(viewer: &mut Viewer, out: &mut impl Write) -> Result<()> { scrollbar_drag = false; if selection_drag { selection_drag = false; - viewer.status_message = + viewer.status_message = if viewer.block_selection_range().is_some() + { + Some( + "Block selected. Right-click or Ctrl+C to copy." + .to_string(), + ) + } else { viewer.selection_range().map(|(start, end)| { format!( "Selected {} bytes. Press Ctrl+C to copy.", end - start ) - }); + }) + }; + needs_redraw = true; + } + } + MouseEventKind::Down(MouseButton::Right) => { + if copy_selection_to_clipboard(viewer, &mut clipboard) { needs_redraw = true; } } @@ -1422,6 +1587,52 @@ fn run_event_loop(viewer: &mut Viewer, out: &mut impl Write) -> Result<()> { Ok(()) } +fn copy_selection_to_clipboard(viewer: &mut Viewer, clipboard: &mut Option) -> bool { + if let Some((top, bottom, left, right)) = viewer.block_selection_range() { + let mut text = String::new(); + for line_idx in top..=bottom { + if line_idx >= viewer.line_count() { + break; + } + let chars = viewer.line_display_chars(line_idx); + let start = left.min(chars.len()); + let end = (right + 1).min(chars.len()); + if start < end { + text.extend(chars[start..end].iter()); + } + if line_idx < bottom { + text.push('\n'); + } + } + if let Some(cb) = clipboard.as_mut() { + if cb.set_text(text).is_ok() { + viewer.status_message = Some("Copied block selection to clipboard".to_string()); + } else { + viewer.status_message = Some("Failed to copy selection to clipboard".to_string()); + } + } else { + viewer.status_message = Some("Clipboard unavailable in this environment".to_string()); + } + return true; + } + + let Some((start, end)) = viewer.selection_range() else { + return false; + }; + + let copied = String::from_utf8_lossy(&viewer.view_bytes()[start..end]).to_string(); + if let Some(cb) = clipboard.as_mut() { + if cb.set_text(copied).is_ok() { + viewer.status_message = Some(format!("Copied {} bytes to clipboard", end - start)); + } else { + viewer.status_message = Some("Failed to copy selection to clipboard".to_string()); + } + } else { + viewer.status_message = Some("Clipboard unavailable in this environment".to_string()); + } + true +} + mod ui; #[cfg(test)]