consciousness/src/user/selectable.rs

531 lines
17 KiB
Rust
Raw Normal View History

//! 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"]);
}
}