From dcd647764ce297099fb63d8407eb3e45d3fe67e5 Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Sun, 12 Apr 2026 15:49:57 -0400 Subject: [PATCH] user: fix text selection on wrapped lines scroll_pane: screen_to_item() now properly accounts for wrapped lines using textwrap to compute actual character positions instead of just using mouse_x directly. selectable: new module with PUA markers for wrap-aware selection. Not yet integrated into chat.rs but ready for future use. Uses continuation markers to track logical vs visual lines. Co-Authored-By: Proof of Concept --- Cargo.lock | 77 ++++++ Cargo.toml | 3 + src/user/mod.rs | 1 + src/user/scroll_pane.rs | 22 +- src/user/selectable.rs | 530 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 632 insertions(+), 1 deletion(-) create mode 100644 src/user/selectable.rs diff --git a/Cargo.lock b/Cargo.lock index d8b0221..f7b934e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -550,11 +550,13 @@ dependencies = [ "redb", "regex", "rkyv", + "rusqlite", "rustls", "rustls-native-certs", "serde", "serde_json", "serde_urlencoded", + "textwrap", "tokenizers", "tokio", "tokio-rustls", @@ -1033,6 +1035,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fancy-regex" version = "0.11.0" @@ -1331,6 +1345,15 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -1640,6 +1663,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "line-clipping" version = "0.3.7" @@ -2537,6 +2571,20 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags 2.11.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -2813,6 +2861,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "socket2" version = "0.6.3" @@ -2992,6 +3046,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3512,6 +3577,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-normalization-alignments" version = "0.1.12" @@ -3580,6 +3651,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 2c5246f..a39c60f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,9 @@ tokio-rustls = "0.26" rustls-native-certs = "0.8" serde_urlencoded = "0.7" +rusqlite = { version = "0.37", features = ["bundled"] } +textwrap = "0.16" + [build-dependencies] capnpc = "0.25" diff --git a/src/user/mod.rs b/src/user/mod.rs index 0648eb9..f588a16 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -6,6 +6,7 @@ pub(crate) mod chat; mod context; pub(crate) mod scroll_pane; +pub mod selectable; mod subconscious; mod unconscious; mod thalamus; diff --git a/src/user/scroll_pane.rs b/src/user/scroll_pane.rs index 55ba593..09e9559 100644 --- a/src/user/scroll_pane.rs +++ b/src/user/scroll_pane.rs @@ -106,7 +106,27 @@ impl ScrollPaneState { let h = self.heights.get(line_idx).copied().unwrap_or(1) as i32; if (mouse_y as i32) < row + h { let line_text: String = lines[line_idx].spans.iter().map(|s| s.content.as_ref()).collect(); - let col = (mouse_x as usize).min(line_text.len()); + + // Which visual row within this wrapped line? + let visual_row_in_item = ((mouse_y as i32) - row).max(0) as usize; + + // Use textwrap to find actual break positions + let wrap_width = self.cached_width as usize; + let wrapped = textwrap::wrap(&line_text, wrap_width); + + // Sum lengths of previous wrapped rows to get char offset base + let char_base: usize = wrapped.iter() + .take(visual_row_in_item) + .map(|s| s.len()) + .sum(); + + // Add mouse x position within current row + let current_row_len = wrapped.get(visual_row_in_item) + .map(|s| s.len()) + .unwrap_or(0); + let col = char_base + (mouse_x as usize).min(current_row_len); + let col = col.min(line_text.len()); + return Some((line_idx, col)); } row += h; diff --git a/src/user/selectable.rs b/src/user/selectable.rs new file mode 100644 index 0000000..cb44d42 --- /dev/null +++ b/src/user/selectable.rs @@ -0,0 +1,530 @@ +//! 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. + 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"]); + } +}