use crate::util::text::{char_width, display_width};
#[derive(Debug, Clone, PartialEq)]
pub struct VisualLine {
pub logical_line: usize,
pub start_col: usize,
pub end_col: usize,
pub text: String,
pub display_width: usize,
}
impl VisualLine {
pub fn from_line(line: &str, line_num: usize) -> Self {
Self {
logical_line: line_num,
start_col: 0,
end_col: line.chars().count(),
text: line.to_string(),
display_width: display_width(line),
}
}
}
#[derive(Debug, Clone)]
pub struct WrapEngine {
enabled: bool,
width: usize,
line_cache: std::collections::HashMap<usize, Vec<VisualLine>>,
line_visual_counts: Vec<usize>,
prefix_sums: Vec<usize>,
dirty: bool,
}
impl Default for WrapEngine {
fn default() -> Self {
Self::new()
}
}
impl WrapEngine {
pub fn new() -> Self {
Self {
enabled: true,
width: 80,
line_cache: std::collections::HashMap::new(),
line_visual_counts: Vec::new(),
prefix_sums: vec![0],
dirty: true,
}
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn set_enabled(&mut self, enabled: bool) {
if self.enabled != enabled {
self.enabled = enabled;
self.dirty = true;
}
}
pub fn set_width(&mut self, width: usize) {
let width = width.max(10);
if self.width != width {
self.width = width;
self.dirty = true;
}
}
pub fn is_dirty(&self) -> bool {
self.dirty
}
pub fn visual_line_count(&self) -> usize {
self.prefix_sums.last().copied().unwrap_or(0)
}
pub fn rebuild_cache(&mut self, lines: &[String]) {
self.line_cache.clear();
self.line_visual_counts.clear();
self.prefix_sums.clear();
self.line_visual_counts.reserve(lines.len());
self.prefix_sums.reserve(lines.len() + 1);
self.prefix_sums.push(0);
let mut sum: usize = 0;
for line in lines {
let count = self.compute_visual_line_count(line);
self.line_visual_counts.push(count);
sum += count;
self.prefix_sums.push(sum);
}
self.dirty = false;
}
fn compute_visual_line_count(&self, line: &str) -> usize {
if !self.enabled {
return 1;
}
let chars: Vec<char> = line.chars().collect();
if chars.is_empty() {
return 1;
}
let mut count: usize = 1;
let mut current_width: usize = 0;
for ch in &chars {
let ch_width = char_width(*ch);
if current_width + ch_width > self.width && current_width > 0 {
count += 1;
current_width = 0;
}
current_width += ch_width;
}
count
}
pub fn build_range(&mut self, lines: &[String], start: usize, end: usize) {
let end = end.min(lines.len());
for (i, line) in lines.iter().enumerate().skip(start).take(end - start) {
if !self.line_cache.contains_key(&i) {
let vlines = self.wrap_line(line, i);
self.line_cache.insert(i, vlines);
}
}
}
pub fn wrap_line(&self, line: &str, line_num: usize) -> Vec<VisualLine> {
if !self.enabled {
return vec![VisualLine::from_line(line, line_num)];
}
let chars: Vec<char> = line.chars().collect();
if chars.is_empty() {
return vec![VisualLine {
logical_line: line_num,
start_col: 0,
end_col: 0,
text: String::new(),
display_width: 0,
}];
}
let mut result = Vec::new();
let mut current = String::new();
let mut current_width = 0;
let mut start_col = 0;
let mut col = 0;
for ch in chars {
let ch_width = char_width(ch);
if current_width + ch_width > self.width && !current.is_empty() {
result.push(VisualLine {
logical_line: line_num,
start_col,
end_col: col,
text: current.clone(),
display_width: current_width,
});
start_col = col;
current.clear();
current_width = 0;
}
current.push(ch);
current_width += ch_width;
col += 1;
}
if !current.is_empty() || result.is_empty() {
result.push(VisualLine {
logical_line: line_num,
start_col,
end_col: col,
text: current,
display_width: current_width,
});
}
result
}
fn visual_to_logical_line(&self, visual_row: usize) -> usize {
if self.prefix_sums.len() <= 1 {
return 0;
}
let max_logical = self.line_visual_counts.len().saturating_sub(1);
match self.prefix_sums.binary_search(&visual_row) {
Ok(i) => i.min(max_logical),
Err(i) => i.saturating_sub(1).min(max_logical),
}
}
pub fn logical_to_visual(&self, logical_line: usize, logical_col: usize) -> usize {
if logical_line >= self.line_visual_counts.len() {
return self.visual_line_count().saturating_sub(1);
}
let base = self.prefix_sums[logical_line];
if !self.enabled || self.width == 0 {
return base;
}
let count = self.line_visual_counts[logical_line];
if count <= 1 {
return base;
}
if let Some(vlines) = self.line_cache.get(&logical_line) {
for (i, vl) in vlines.iter().enumerate() {
if logical_col < vl.end_col || i == vlines.len() - 1 {
return base + i;
}
}
return base + vlines.len().saturating_sub(1);
}
let sub = logical_col / self.width.max(1);
base + sub.min(count.saturating_sub(1))
}
pub fn visual_to_logical(&self, visual_row: usize) -> (usize, usize) {
let logical = self.visual_to_logical_line(visual_row);
let base = self.prefix_sums.get(logical).copied().unwrap_or(0);
let sub = visual_row.saturating_sub(base);
if let Some(vlines) = self.line_cache.get(&logical)
&& let Some(vl) = vlines.get(sub)
{
return (logical, vl.start_col);
}
let start_col = sub * self.width;
(logical, start_col)
}
pub fn get_visual_line(&self, visual_row: usize) -> Option<&VisualLine> {
let logical = self.visual_to_logical_line(visual_row);
let base = self.prefix_sums.get(logical).copied().unwrap_or(0);
let sub = visual_row.saturating_sub(base);
self.line_cache.get(&logical)?.get(sub)
}
pub fn get_cached_lines(&self, logical_line: usize) -> &[VisualLine] {
self.line_cache
.get(&logical_line)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
pub fn visual_offset_of(&self, logical_line: usize) -> usize {
self.prefix_sums.get(logical_line).copied().unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_wrap_ascii() {
let mut engine = WrapEngine::new();
engine.set_width(10);
let lines = vec!["Hello, World!".to_string()];
engine.rebuild_cache(&lines);
assert_eq!(engine.visual_line_count(), 2);
}
#[test]
fn test_wrap_chinese() {
let mut engine = WrapEngine::new();
engine.set_width(10);
let lines = vec!["测试中文折行".to_string()];
engine.rebuild_cache(&lines);
assert_eq!(engine.visual_line_count(), 2);
}
#[test]
fn test_logical_to_visual() {
let mut engine = WrapEngine::new();
engine.set_width(10);
let lines = vec!["HelloWorldTest".to_string()];
engine.rebuild_cache(&lines);
engine.build_range(&lines, 0, lines.len());
let visual = engine.logical_to_visual(0, 3);
assert_eq!(visual, 0);
let visual = engine.logical_to_visual(0, 12);
assert!(visual >= 1, "Expected visual >= 1, got {}", visual); }
#[test]
fn test_visual_to_logical() {
let mut engine = WrapEngine::new();
engine.set_width(10);
let lines = vec!["HelloWorldTest".to_string()];
engine.rebuild_cache(&lines);
let (line, col) = engine.visual_to_logical(0);
assert_eq!(line, 0);
assert_eq!(col, 0);
}
#[test]
fn test_empty_line() {
let mut engine = WrapEngine::new();
engine.set_width(10);
let lines = vec!["".to_string(), "Hello".to_string()];
engine.rebuild_cache(&lines);
engine.build_range(&lines, 0, lines.len());
assert_eq!(engine.visual_line_count(), 2);
let vl = engine.get_visual_line(0).unwrap();
assert_eq!(vl.text, "");
assert_eq!(vl.logical_line, 0);
}
#[test]
fn test_visual_to_logical_binary_search() {
let mut engine = WrapEngine::new();
engine.set_width(10);
let lines = vec![
"Hello".to_string(), "HelloWorldTest".to_string(), "End".to_string(), ];
engine.rebuild_cache(&lines);
assert_eq!(engine.visual_to_logical(0).0, 0); assert_eq!(engine.visual_to_logical(1).0, 1); assert_eq!(engine.visual_to_logical(2).0, 1); assert_eq!(engine.visual_to_logical(3).0, 2); }
#[test]
fn test_sparse_cache() {
let mut engine = WrapEngine::new();
engine.set_width(10);
let lines: Vec<String> = (0..1000).map(|i| format!("Line {}", i)).collect();
engine.rebuild_cache(&lines);
engine.build_range(&lines, 500, 510);
let cached = engine.get_cached_lines(505);
assert!(!cached.is_empty());
let cached = engine.get_cached_lines(0);
assert!(cached.is_empty());
assert_eq!(engine.visual_line_count(), 1000);
}
#[test]
fn test_compute_count_matches_wrap_line() {
let mut engine = WrapEngine::new();
engine.set_width(10);
let line = "Hello, World!";
let lines = vec![line.to_string()];
engine.rebuild_cache(&lines);
engine.build_range(&lines, 0, 1);
let vlines = engine.get_cached_lines(0);
assert_eq!(vlines.len(), engine.line_visual_counts[0]);
assert_eq!(vlines.len(), 2);
let long_line = "Rust tests are currently inline unit tests under cfg blocks";
let lines2 = vec![long_line.to_string()];
engine.rebuild_cache(&lines2);
engine.build_range(&lines2, 0, 1);
let vlines2 = engine.get_cached_lines(0);
assert_eq!(vlines2.len(), engine.line_visual_counts[0]);
let reconstructed: String = vlines2.iter().map(|vl| vl.text.as_str()).collect();
assert_eq!(reconstructed, long_line);
}
}