//! Selectable text widget with proper wrap-aware selection. //! //! Uses Unicode Private Use Area markers to track logical line boundaries: //! - Lines starting with CONT are continuations (wrapped from previous) //! - Lines between SEL_ON and SEL_OFF are selectable //! //! The caller pre-wraps text and marks continuations. This widget handles //! selection, clipboard copy, and rendering with highlights. use ratatui::prelude::*; use ratatui::widgets::{Block, Scrollbar, ScrollbarOrientation, ScrollbarState}; // ── Markers (Unicode Private Use Area) ───────────────────────────── /// This line continues the previous logical line (was wrapped). pub const CONT: char = '\u{E000}'; /// Start of a selectable region. pub const SEL_ON: char = '\u{E001}'; /// End of a selectable region. pub const SEL_OFF: char = '\u{E002}'; // ── Helper: wrap text with continuation markers ──────────────────── /// Wrap a single logical line into visual lines, marking continuations. /// Returns lines ready to push into a SelectableText. pub fn wrap_line(text: &str, width: usize) -> Vec { if width == 0 || text.is_empty() { return vec![text.to_string()]; } let wrapped = textwrap::wrap(text, width); wrapped .into_iter() .enumerate() .map(|(i, cow)| { if i == 0 { cow.into_owned() } else { format!("{}{}", CONT, cow) } }) .collect() } /// Wrap text and mark as selectable. pub fn wrap_line_selectable(text: &str, width: usize) -> Vec { let mut lines = wrap_line(text, width); if let Some(first) = lines.first_mut() { *first = format!("{}{}", SEL_ON, first); } if let Some(last) = lines.last_mut() { last.push(SEL_OFF); } lines } // ── Selection state ──────────────────────────────────────────────── /// A position in logical coordinates (line index, char offset). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct LogicalPos { pub line: usize, pub col: usize, } /// Selection anchor and cursor. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct Selection { pub anchor: LogicalPos, pub cursor: LogicalPos, } impl Selection { pub fn new(pos: LogicalPos) -> Self { Self { anchor: pos, cursor: pos } } pub fn extend(&mut self, pos: LogicalPos) { self.cursor = pos; } /// Returns (start, end) in normalized order. pub fn range(&self) -> (LogicalPos, LogicalPos) { if (self.anchor.line, self.anchor.col) <= (self.cursor.line, self.cursor.col) { (self.anchor, self.cursor) } else { (self.cursor, self.anchor) } } pub fn is_empty(&self) -> bool { self.anchor == self.cursor } } // ── Main widget state ────────────────────────────────────────────── pub struct SelectableTextState { /// Visual lines (may contain markers). lines: Vec, /// Scroll offset in visual lines. pub scroll_offset: usize, /// Viewport height (set during render). pub viewport_height: usize, /// Current selection, if any. pub selection: Option, /// Cached logical line index for each visual line. /// logical_line_idx[visual] = which logical line this visual line belongs to. logical_line_idx: Vec, /// Cached char offset: start char of each visual line within its logical line. char_offsets: Vec, } impl Default for SelectableTextState { fn default() -> Self { Self::new() } } impl SelectableTextState { pub fn new() -> Self { Self { lines: Vec::new(), scroll_offset: 0, viewport_height: 0, selection: None, logical_line_idx: Vec::new(), char_offsets: Vec::new(), } } /// Clear all content. pub fn clear(&mut self) { self.lines.clear(); self.logical_line_idx.clear(); self.char_offsets.clear(); self.selection = None; } /// Push a visual line. Call rebuild_index() after batch pushes. pub fn push_line(&mut self, line: String) { self.lines.push(line); } /// Push multiple visual lines. pub fn push_lines(&mut self, lines: impl IntoIterator) { self.lines.extend(lines); } /// Rebuild the logical line index. Call after modifying lines. pub fn rebuild_index(&mut self) { self.logical_line_idx.clear(); self.char_offsets.clear(); let mut logical_idx = 0usize; let mut char_offset = 0usize; for line in &self.lines { let is_continuation = line.starts_with(CONT); if !is_continuation && !self.logical_line_idx.is_empty() { // New logical line logical_idx += 1; char_offset = 0; } self.logical_line_idx.push(logical_idx); self.char_offsets.push(char_offset); // Advance char offset by the display length of this line char_offset += display_len(line); } } /// Number of visual lines. pub fn len(&self) -> usize { self.lines.len() } pub fn is_empty(&self) -> bool { self.lines.is_empty() } /// Scroll up by n visual lines. pub fn scroll_up(&mut self, n: usize) { self.scroll_offset = self.scroll_offset.saturating_sub(n); } /// Scroll down by n visual lines. pub fn scroll_down(&mut self, n: usize) { let max = self.len().saturating_sub(self.viewport_height); self.scroll_offset = (self.scroll_offset + n).min(max); } /// Convert screen position to logical position. pub fn screen_to_logical(&self, x: u16, y: u16) -> Option { let visual_row = self.scroll_offset + y as usize; if visual_row >= self.lines.len() { return None; } let logical_line = *self.logical_line_idx.get(visual_row)?; let char_base = *self.char_offsets.get(visual_row)?; // Check if this position is within a selectable region if !self.is_visual_line_selectable(visual_row) { return None; } let line = &self.lines[visual_row]; let display = strip_markers(line); let col = char_base + (x as usize).min(display.len()); Some(LogicalPos { line: logical_line, col }) } /// Check if a visual line is within a selectable region. fn is_visual_line_selectable(&self, visual_row: usize) -> bool { // Walk backwards to find if we're in a selectable region let mut in_selectable = false; for i in 0..=visual_row { let line = &self.lines[i]; if line.contains(SEL_ON) { in_selectable = true; } if line.contains(SEL_OFF) && i < visual_row { in_selectable = false; } } in_selectable || self.lines[visual_row].contains(SEL_ON) } /// Start a new selection at screen position. pub fn start_selection(&mut self, x: u16, y: u16) { if let Some(pos) = self.screen_to_logical(x, y) { self.selection = Some(Selection::new(pos)); } else { self.selection = None; } } /// Extend selection to screen position. pub fn extend_selection(&mut self, x: u16, y: u16) { if let Some(pos) = self.screen_to_logical(x, y) { if let Some(ref mut sel) = self.selection { sel.extend(pos); } } } /// Get selected text, joining logical lines with newlines. pub fn get_selected_text(&self) -> Option { let sel = self.selection.as_ref()?; if sel.is_empty() { return None; } let (start, end) = sel.range(); // Reconstruct logical lines let logical_lines = self.reconstruct_logical_lines(); let mut result = String::new(); for (i, line) in logical_lines.iter().enumerate() { if i < start.line || i > end.line { continue; } let line_start = if i == start.line { start.col } else { 0 }; let line_end = if i == end.line { end.col } else { line.len() }; if line_start < line.len() { if !result.is_empty() { result.push('\n'); } let end_clamped = line_end.min(line.len()); if let Some(slice) = line.get(line_start..end_clamped) { result.push_str(slice); } } } if result.is_empty() { None } else { Some(result) } } /// Reconstruct logical lines from visual lines (stripping markers, joining continuations). fn reconstruct_logical_lines(&self) -> Vec { let mut logical: Vec = Vec::new(); for line in &self.lines { let is_cont = line.starts_with(CONT); let clean = strip_markers(line); if is_cont && !logical.is_empty() { // Append to previous logical line logical.last_mut().unwrap().push_str(&clean); } else { logical.push(clean); } } logical } /// Copy selection to clipboard via OSC 52. pub fn copy_to_clipboard(&self) { if let Some(text) = self.get_selected_text() { if text.is_empty() { return; } use base64::Engine; use std::io::Write; let encoded = base64::engine::general_purpose::STANDARD.encode(&text); let mut stdout = std::io::stdout().lock(); let _ = write!(stdout, "\x1b]52;c;{}\x07", encoded); let _ = stdout.flush(); } } /// Get the visual lines for rendering (with markers stripped). pub fn display_lines(&self) -> impl Iterator> + '_ { self.lines.iter().map(|s| Line::raw(strip_markers(s))) } /// Check if a logical position is within the current selection. #[allow(dead_code)] // Reserved for future per-character highlight rendering fn is_selected(&self, logical_line: usize, col: usize) -> bool { let Some(ref sel) = self.selection else { return false }; let (start, end) = sel.range(); if logical_line < start.line || logical_line > end.line { return false; } if logical_line == start.line && col < start.col { return false; } if logical_line == end.line && col >= end.col { return false; } true } /// Get the selection highlight range for a visual line (in display columns). pub fn highlight_range(&self, visual_row: usize) -> Option<(usize, usize)> { let sel = self.selection.as_ref()?; if sel.is_empty() { return None; } let logical_line = *self.logical_line_idx.get(visual_row)?; let char_base = *self.char_offsets.get(visual_row)?; let display = strip_markers(&self.lines[visual_row]); let line_len = display.len(); let (start, end) = sel.range(); // Check if this visual line overlaps with selection if logical_line < start.line || logical_line > end.line { return None; } let sel_start_in_line = if logical_line == start.line { start.col } else { 0 }; let sel_end_in_line = if logical_line == end.line { end.col } else { usize::MAX }; // Convert to visual line's local coordinates let vis_start = sel_start_in_line.saturating_sub(char_base); let vis_end = sel_end_in_line.saturating_sub(char_base).min(line_len); if vis_start >= line_len || vis_end == 0 || vis_start >= vis_end { return None; } Some((vis_start, vis_end)) } } // ── Widget ───────────────────────────────────────────────────────── pub struct SelectableText<'a> { block: Option>, highlight_style: Style, } impl<'a> SelectableText<'a> { pub fn new() -> Self { Self { block: None, highlight_style: Style::default().bg(Color::DarkGray), } } pub fn block(mut self, block: Block<'a>) -> Self { self.block = Some(block); self } pub fn highlight_style(mut self, style: Style) -> Self { self.highlight_style = style; self } } impl Default for SelectableText<'_> { fn default() -> Self { Self::new() } } impl StatefulWidget for SelectableText<'_> { type State = SelectableTextState; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { let inner = if let Some(block) = self.block { let inner = block.inner(area); block.render(area, buf); inner } else { area }; if inner.width < 2 || inner.height == 0 { return; } state.viewport_height = inner.height as usize; // Render visible lines let start = state.scroll_offset; let end = (start + inner.height as usize).min(state.lines.len()); for (i, visual_row) in (start..end).enumerate() { let y = inner.y + i as u16; let line = &state.lines[visual_row]; let display = strip_markers(line); // Render with selection highlighting if let Some((hl_start, hl_end)) = state.highlight_range(visual_row) { // Before highlight let before = &display[..hl_start.min(display.len())]; buf.set_string(inner.x, y, before, Style::default()); // Highlighted portion let hl_text = &display[hl_start..hl_end.min(display.len())]; buf.set_string(inner.x + hl_start as u16, y, hl_text, self.highlight_style); // After highlight if hl_end < display.len() { let after = &display[hl_end..]; buf.set_string(inner.x + hl_end as u16, y, after, Style::default()); } } else { buf.set_string(inner.x, y, &display, Style::default()); } } // Scrollbar let content_len = state.lines.len(); let visible = inner.height as usize; if content_len > visible { let mut sb_state = ScrollbarState::new(content_len).position(state.scroll_offset); Scrollbar::new(ScrollbarOrientation::VerticalRight).render(inner, buf, &mut sb_state); } } } // ── Helpers ──────────────────────────────────────────────────────── /// Strip all markers from a line for display. fn strip_markers(s: &str) -> String { s.chars() .filter(|&c| c != CONT && c != SEL_ON && c != SEL_OFF) .collect() } /// Display length of a line (excluding markers). fn display_len(s: &str) -> usize { s.chars() .filter(|&c| c != CONT && c != SEL_ON && c != SEL_OFF) .count() } #[cfg(test)] mod tests { use super::*; #[test] fn test_wrap_line() { // "hello world, this is a test" at width 10: // "hello" / "world," / "this is a" / "test" let lines = wrap_line("hello world, this is a test", 10); assert_eq!(lines.len(), 4); assert!(!lines[0].starts_with(CONT)); // "hello" assert!(lines[1].starts_with(CONT)); // " world," assert!(lines[2].starts_with(CONT)); // " this is a" assert!(lines[3].starts_with(CONT)); // " test" } #[test] fn test_strip_markers() { let s = format!("{}hello{}world{}", SEL_ON, CONT, SEL_OFF); assert_eq!(strip_markers(&s), "helloworld"); } #[test] fn test_logical_index() { let mut state = SelectableTextState::new(); state.push_line("first line".to_string()); state.push_line(format!("{}continued", CONT)); state.push_line("second line".to_string()); state.rebuild_index(); assert_eq!(state.logical_line_idx, vec![0, 0, 1]); assert_eq!(state.char_offsets, vec![0, 10, 0]); } #[test] fn test_reconstruct() { let mut state = SelectableTextState::new(); state.push_line("hello ".to_string()); state.push_line(format!("{}world", CONT)); state.push_line("next".to_string()); state.rebuild_index(); let logical = state.reconstruct_logical_lines(); assert_eq!(logical, vec!["hello world", "next"]); } }