From 6f9585f65f4b4e88c79bbefcf242c03ef67a414d Mon Sep 17 00:00:00 2001 From: cartermp Date: Wed, 25 Mar 2026 12:32:00 -0700 Subject: [PATCH] gutter --- src/main.rs | 35 ++++++++++++++++++++++++++++++++--- src/renderer.rs | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index 825dc25..c71ae02 100644 --- a/src/main.rs +++ b/src/main.rs @@ -388,9 +388,9 @@ impl App { "c" => { // Copy selection if active; otherwise do nothing. if self.selection.is_some() { - let text = self.selection_text(); - if !text.is_empty() { - copy_to_clipboard(&text); + let raw = self.selection_text(); + if !raw.is_empty() { + copy_to_clipboard(&strip_tcat_gutter(&raw)); } self.selection = None; if let Some(w) = &self.window { @@ -887,6 +887,35 @@ fn copy_to_clipboard(text: &str) { } } +/// Strip tcat's line-number gutter from copied text. +/// tcat renders each line as " {N} │ {content}". We find the U+2502 +/// separator flanked by spaces, verify the prefix is digits+spaces, and +/// return only the content. +fn strip_tcat_gutter(text: &str) -> String { + const SEP: char = '\u{2502}'; // │ + text.lines() + .map(|line| { + // Find │ with a space on each side + let chars: Vec = line.chars().collect(); + for i in 1..chars.len().saturating_sub(1) { + if chars[i] != SEP || chars[i - 1] != ' ' || chars[i + 1] != ' ' { + continue; + } + // Prefix (0..i-1) must be spaces/digits with at least one digit + let prefix = &chars[..i - 1]; + if prefix.iter().all(|&c| c == ' ' || c == '\0' || c.is_ascii_digit()) + && prefix.iter().any(|c| c.is_ascii_digit()) + { + // Content starts at i+2 (after │ and trailing space) + return chars[i + 2..].iter().collect::(); + } + } + line.to_string() + }) + .collect::>() + .join("\n") +} + fn paste_from_clipboard() -> String { use std::process::Command; Command::new("pbpaste") diff --git a/src/renderer.rs b/src/renderer.rs index 116834b..2ae9f83 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -22,6 +22,35 @@ pub struct Renderer { font_size: f32, } +/// Find the first content column of a tcat gutter line, or 0 if this row is +/// not tcat output. tcat renders: ` {digits} │ content` — we detect the +/// BOX-DRAWINGS LIGHT VERTICAL (U+2502) with a space on each side, preceded +/// only by spaces and digits. +fn tcat_gutter_end(state: &TerminalState, row: usize, vis_cols: usize) -> usize { + // │ can't appear at col 0 (needs a space before it) or at the last col + for c in 1..vis_cols.saturating_sub(1) { + if state.visual_cell(row, c).c != '\u{2502}' { + continue; + } + if state.visual_cell(row, c - 1).c != ' ' { + continue; + } + if state.visual_cell(row, c + 1).c != ' ' { + continue; + } + // Everything in 0..c-1 must be spaces/null/digits, with at least one digit + let mut prefix = 0..c - 1; + let has_digit = prefix.clone().any(|i| state.visual_cell(row, i).c.is_ascii_digit()); + let all_ok = prefix.all(|i| { + matches!(state.visual_cell(row, i).c, ' ' | '\0' | '0'..='9') + }); + if has_digit && all_ok { + return c + 2; // column after "│ " + } + } + 0 +} + impl Renderer { pub fn new(scale_factor: f64) -> Self { let font_size = (FONT_SIZE_PT * scale_factor as f32).round(); @@ -390,6 +419,13 @@ impl Renderer { let sel_bg = Color::new(0x26, 0x4a, 0x7a).to_u32(); // muted blue selection for row in 0..vis_rows { + // Per-row: detect tcat gutter so it's excluded from selection highlight. + let gutter = if selection.is_some() { + tcat_gutter_end(state, row, vis_cols) + } else { + 0 + }; + for col in 0..vis_cols { let cell = state.visual_cell(row, col); let mut fg = cell.attrs.fg; @@ -403,6 +439,7 @@ impl Renderer { && row <= r1 && !(row == r0 && col < c0) && !(row == r1 && col > c1) + && col >= gutter } else { false }; -- 2.51.2