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:
parent
ab0f16a3b5
commit
dcd647764c
5 changed files with 632 additions and 1 deletions
77
Cargo.lock
generated
77
Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
530
src/user/selectable.rs
Normal 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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue