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 <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-12 15:49:57 -04:00
parent ab0f16a3b5
commit dcd647764c
5 changed files with 632 additions and 1 deletions

77
Cargo.lock generated
View file

@ -550,11 +550,13 @@ dependencies = [
"redb", "redb",
"regex", "regex",
"rkyv", "rkyv",
"rusqlite",
"rustls", "rustls",
"rustls-native-certs", "rustls-native-certs",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
"textwrap",
"tokenizers", "tokenizers",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
@ -1033,6 +1035,18 @@ dependencies = [
"num-traits", "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]] [[package]]
name = "fancy-regex" name = "fancy-regex"
version = "0.11.0" version = "0.11.0"
@ -1331,6 +1345,15 @@ version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" 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]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@ -1640,6 +1663,17 @@ dependencies = [
"libc", "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]] [[package]]
name = "line-clipping" name = "line-clipping"
version = "0.3.7" version = "0.3.7"
@ -2537,6 +2571,20 @@ dependencies = [
"syn 1.0.109", "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]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@ -2813,6 +2861,12 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "smawk"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.3" version = "0.6.3"
@ -2992,6 +3046,17 @@ dependencies = [
"winapi", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@ -3512,6 +3577,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-linebreak"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]] [[package]]
name = "unicode-normalization-alignments" name = "unicode-normalization-alignments"
version = "0.1.12" version = "0.1.12"
@ -3580,6 +3651,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"

View file

@ -75,6 +75,9 @@ tokio-rustls = "0.26"
rustls-native-certs = "0.8" rustls-native-certs = "0.8"
serde_urlencoded = "0.7" serde_urlencoded = "0.7"
rusqlite = { version = "0.37", features = ["bundled"] }
textwrap = "0.16"
[build-dependencies] [build-dependencies]
capnpc = "0.25" capnpc = "0.25"

View file

@ -6,6 +6,7 @@
pub(crate) mod chat; pub(crate) mod chat;
mod context; mod context;
pub(crate) mod scroll_pane; pub(crate) mod scroll_pane;
pub mod selectable;
mod subconscious; mod subconscious;
mod unconscious; mod unconscious;
mod thalamus; mod thalamus;

View file

@ -106,7 +106,27 @@ impl ScrollPaneState {
let h = self.heights.get(line_idx).copied().unwrap_or(1) as i32; let h = self.heights.get(line_idx).copied().unwrap_or(1) as i32;
if (mouse_y as i32) < row + h { if (mouse_y as i32) < row + h {
let line_text: String = lines[line_idx].spans.iter().map(|s| s.content.as_ref()).collect(); 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)); return Some((line_idx, col));
} }
row += h; row += h;

530
src/user/selectable.rs Normal file
View file

@ -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<String> {
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<String> {
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<String>,
/// 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<Selection>,
/// Cached logical line index for each visual line.
/// logical_line_idx[visual] = which logical line this visual line belongs to.
logical_line_idx: Vec<usize>,
/// Cached char offset: start char of each visual line within its logical line.
char_offsets: Vec<usize>,
}
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<Item = String>) {
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<LogicalPos> {
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<String> {
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<String> {
let mut logical: Vec<String> = 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<Item = Line<'_>> + '_ {
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<Block<'a>>,
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"]);
}
}