//! 多行文本选择和光标导航。选择端点映射到合法字符边界,避免把 UTF-8 字节中间当成一个字符。 //! 工具只保留当前文本行和选择范围;复制操作取得的是所选内容,不是整份运行时状态。 //! //! Read-only, grapheme-aware source selection, independent of console editing. use crate::{ geometry::Point, text::{self, TextStyle}, }; use unicode_segmentation::UnicodeSegmentation; #[derive(Debug, Default)] /// 行文本、锚点与选择端点,既供鼠标拖选也供键盘导航使用。 pub(super) struct SourceSelection { lines: Vec, // 端点是 (行号, UTF-8 字节偏移),字节位置始终由完整字素边界产生,不用字符数直接切片。 anchor: (usize, usize), end: (usize, usize), pub dragging: bool, } impl SourceSelection { /// 同步展示文本;内容变化时重置选择和拖动,内容完全相同时保留原选区。 pub fn set_lines(&mut self, lines: &[String]) { // 文本变化时重置两端与拖选状态;当前实现不是把旧选区钳到新文本,避免对应内容已经改变。 if self.lines != lines { self.lines = lines.to_vec(); self.anchor = (0, 0); self.end = (0, 0); self.dragging = false; } } /// 把工具指针位置换算成合法行号与文本字符边界。 fn locate(p: Point, origin: Point, lines: &[String]) -> (usize, usize) { // 按固定行高求行号,向上下界钳制;空文本通过 saturating_sub 与后续 get 安全返回原点。 let row = (((p.y - origin.y) / super::ROW).floor().max(0.0) as usize) .min(lines.len().saturating_sub(1)); let Some(line) = lines.get(row) else { return (0, 0); }; let x = (p.x - origin.x).max(0.0); let mut byte = line.len(); // 逐个字素测量左右边界,以中点决定插入位置,点击超过行尾则保留 byte=line.len()。 for (i, g) in line.grapheme_indices(true) { let left = text::measure(&line[..i], TextStyle::code()).width; let right = text::measure(&line[..i + g.len()], TextStyle::code()).width; if x < (left + right) * 0.5 { byte = i; break; } } (row, byte) } /// 在指针位置建立文本选择锚点,开始一次拖动选择。 pub fn begin(&mut self, p: Point, origin: Point, lines: &[String]) { // 调用方提供的 lines 与当前展示一致;开始拖动先折叠旧选择,后续 extend 只移动活动端。 self.anchor = Self::locate(p, origin, lines); self.end = self.anchor; self.dragging = true; } /// 保持原锚点,随指针更新选择另一端。 pub fn extend(&mut self, p: Point, origin: Point, lines: &[String]) { self.end = Self::locate(p, origin, lines); } /// 用键盘移动文本选择端点,按扩展模式决定是否保留锚点。 pub fn navigate( &mut self, key: &winit::keyboard::NamedKey, shift: bool, command: bool, ) -> Option { use winit::keyboard::NamedKey::*; let (mut row, mut byte) = self.end; let line = self.lines.get(row)?; match key { // 水平移动按字素跨越,组合音标和 emoji 视作整体;到行边界后跨到相邻行的末尾/开头。 ArrowLeft => { if byte > 0 { byte = line[..byte] .grapheme_indices(true) .next_back() .map_or(0, |(i, _)| i); } else if row > 0 { row -= 1; byte = self.lines[row].len(); } } ArrowRight => { if byte < line.len() { byte += line[byte..].graphemes(true).next().map_or(0, str::len); } else if row + 1 < self.lines.len() { row += 1; byte = 0; } } // 垂直移动保持的是字素列数,不是像素 x;目标行较短时取尽其字素,落到行尾。 ArrowUp | ArrowDown => { let col = line[..byte].graphemes(true).count(); row = if *key == ArrowUp { row.saturating_sub(1) } else { (row + 1).min(self.lines.len() - 1) }; byte = self.lines[row] .graphemes(true) .take(col) .map(str::len) .sum(); } // 普通 Home/End 选本行边界,command 修饰时转到整个文本首/末行。 Home => { if command { row = 0; } byte = 0; } End => { if command { row = self.lines.len() - 1; } byte = self.lines[row].len(); } _ => return None, } self.end = (row, byte); // 未按 Shift 时同步锚点以折叠选区;按 Shift 则仅移动 end,保持原锚点扩展选择。 if !shift { self.anchor = self.end; } Some(row) } /// 返回排序后的有效选择范围,隐藏拖动方向差异。 fn range(&self, all: bool) -> ((usize, usize), (usize, usize)) { if all { return ( (0, 0), ( self.lines.len().saturating_sub(1), self.lines.last().map_or(0, String::len), ), ); } // 元组字典序先比行号再比字节位置,统一处理从后向前拖选与从前向后拖选。 (self.anchor.min(self.end), self.anchor.max(self.end)) } /// 拼接当前选择范围内的实际文本,保持多行换行与 UTF-8 边界。 pub fn text(&self, all: bool) -> String { let (a, b) = self.range(all); self.lines .iter() .enumerate() .filter(|(i, _)| *i >= a.0 && *i <= b.0) .map(|(i, line)| { // 首尾行只取选中片段,中间行取完整文本;用安全 get 防止不一致范围导致切片 panic。 let start = if i == a.0 { a.1 } else { 0 }; let end = if i == b.0 { b.1 } else { line.len() }; line.get(start..end).unwrap_or("").to_owned() }) .collect::>() .join("\n") } /// 取得某行的可见选择片段,供高亮绘制复用。 pub fn span(&self, row: usize, all: bool, lines: &[String]) -> Option<(f32, f32)> { let (a, b) = self.range(all); // 未选中的行或折叠选择不画高亮;有选择时返回两个真实字体测量的横向位置,不是宽度值。 if row < a.0 || row > b.0 || a == b { return None; } let line = lines.get(row)?; let start = if row == a.0 { a.1 } else { 0 }; let end = if row == b.0 { b.1 } else { line.len() }; Some(( text::measure(line.get(..start)?, TextStyle::code()).width, text::measure(line.get(..end)?, TextStyle::code()).width, )) } }