Skip to main content

editor_core/
layout.rs

1//! Phase 3: Layout and Soft Wrapping (Headless Layout Engine)
2//!
3//! Calculates the visual representation of text given a container width.
4//! Computes character widths based on UAX #11 and implements headless reflow algorithm.
5
6use unicode_width::UnicodeWidthChar;
7
8/// Default tab width (in cells) used when a caller does not specify a tab width.
9pub const DEFAULT_TAB_WIDTH: usize = 4;
10
11/// Soft wrapping mode.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum WrapMode {
14    /// No soft wrapping (each logical line is a single visual line).
15    None,
16    /// Wrap at character boundaries (current behavior).
17    #[default]
18    Char,
19    /// Prefer wrapping at word boundaries (whitespace), falling back to character wrap.
20    Word,
21}
22
23/// Wrapped-line indentation policy.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum WrapIndent {
26    /// No indentation for wrapped continuations.
27    #[default]
28    None,
29    /// Indent wrapped continuations by the width (in cells) of the logical line's leading
30    /// whitespace prefix (spaces + tabs).
31    SameAsLineIndent,
32    /// Indent wrapped continuations by a fixed number of cells.
33    FixedCells(usize),
34}
35
36/// Wrap point
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct WrapPoint {
39    /// Character index where wrapping occurs (within the logical line)
40    pub char_index: usize,
41    /// Byte offset where wrapping occurs (within the logical line)
42    pub byte_offset: usize,
43}
44
45/// Visual line information
46#[derive(Debug, Clone)]
47pub struct VisualLineInfo {
48    /// Number of visual lines corresponding to this logical line
49    pub visual_line_count: usize,
50    /// List of wrap points
51    pub wrap_points: Vec<WrapPoint>,
52}
53
54impl VisualLineInfo {
55    /// Create an empty layout (a single visual line, no wrap points).
56    pub fn new() -> Self {
57        Self {
58            visual_line_count: 1,
59            wrap_points: Vec::new(),
60        }
61    }
62
63    /// Calculate visual line information from text and width constraint
64    pub fn from_text(text: &str, viewport_width: usize) -> Self {
65        let wrap_points = calculate_wrap_points(text, viewport_width);
66        let visual_line_count = wrap_points.len() + 1;
67
68        Self {
69            visual_line_count,
70            wrap_points,
71        }
72    }
73
74    /// Calculate visual line information from text and width constraint, with explicit `tab_width`.
75    pub fn from_text_with_tab_width(text: &str, viewport_width: usize, tab_width: usize) -> Self {
76        let wrap_points = calculate_wrap_points_with_tab_width(text, viewport_width, tab_width);
77        let visual_line_count = wrap_points.len() + 1;
78
79        Self {
80            visual_line_count,
81            wrap_points,
82        }
83    }
84
85    /// Calculate visual line information with explicit options.
86    pub fn from_text_with_options(
87        text: &str,
88        viewport_width: usize,
89        tab_width: usize,
90        wrap_mode: WrapMode,
91    ) -> Self {
92        Self::from_text_with_layout_options(
93            text,
94            viewport_width,
95            tab_width,
96            wrap_mode,
97            WrapIndent::None,
98        )
99    }
100
101    /// Calculate visual line information with explicit layout options.
102    pub fn from_text_with_layout_options(
103        text: &str,
104        viewport_width: usize,
105        tab_width: usize,
106        wrap_mode: WrapMode,
107        wrap_indent: WrapIndent,
108    ) -> Self {
109        let wrap_points = calculate_wrap_points_with_tab_width_mode_and_indent(
110            text,
111            viewport_width,
112            tab_width,
113            wrap_mode,
114            wrap_indent,
115        );
116        let visual_line_count = wrap_points.len() + 1;
117
118        Self {
119            visual_line_count,
120            wrap_points,
121        }
122    }
123}
124
125impl Default for VisualLineInfo {
126    fn default() -> Self {
127        Self::new()
128    }
129}
130
131/// Calculate visual width of a character (based on UAX #11)
132///
133/// Return value:
134/// - 1: Narrow character (ASCII, etc.)
135/// - 2: Wide character (CJK, fullwidth, etc.)
136/// - 0: Zero-width character (combining characters, etc.)
137pub fn char_width(ch: char) -> usize {
138    // Use unicode-width crate to implement UAX #11
139    UnicodeWidthChar::width(ch).unwrap_or(1)
140}
141
142/// Calculate visual width (in cells) for a character at a specific cell offset within the line.
143///
144/// Notes:
145/// - For most characters, width follows UAX #11 via [`char_width`].
146/// - For `'\t'`, width advances to the next tab stop based on `tab_width`.
147pub fn cell_width_at(ch: char, cell_offset_in_line: usize, tab_width: usize) -> usize {
148    if ch == '\t' {
149        let tab_width = tab_width.max(1);
150        let rem = cell_offset_in_line % tab_width;
151        tab_width - rem
152    } else {
153        char_width(ch)
154    }
155}
156
157/// Calculate total visual width of a string
158pub fn str_width(s: &str) -> usize {
159    s.chars().map(char_width).sum()
160}
161
162/// Calculate total visual width of a string, interpreting `'\t'` using `tab_width`.
163pub fn str_width_with_tab_width(s: &str, tab_width: usize) -> usize {
164    let mut x = 0usize;
165    for ch in s.chars() {
166        x = x.saturating_add(cell_width_at(ch, x, tab_width));
167    }
168    x
169}
170
171/// Calculate the visual cell offset from the start of the line to the given character column.
172///
173/// - `column` is counted in `char` (not bytes).
174/// - `'\t'` is expanded using `tab_width` and the current cell offset.
175pub fn visual_x_for_column(line: &str, column: usize, tab_width: usize) -> usize {
176    let mut x = 0usize;
177    for ch in line.chars().take(column) {
178        x = x.saturating_add(cell_width_at(ch, x, tab_width));
179    }
180    x
181}
182
183fn leading_whitespace_prefix_slice(line: &str) -> &str {
184    let bytes = line.as_bytes();
185    let mut end = 0usize;
186    while end < bytes.len() {
187        match bytes[end] {
188            b' ' | b'\t' => end += 1,
189            _ => break,
190        }
191    }
192    &line[..end]
193}
194
195pub(crate) fn wrap_indent_cells_for_line_text(
196    line_text: &str,
197    wrap_indent: WrapIndent,
198    viewport_width: usize,
199    tab_width: usize,
200) -> usize {
201    if viewport_width <= 1 {
202        return 0;
203    }
204
205    let raw = match wrap_indent {
206        WrapIndent::None => 0,
207        WrapIndent::FixedCells(n) => n,
208        WrapIndent::SameAsLineIndent => {
209            let prefix = leading_whitespace_prefix_slice(line_text);
210            str_width_with_tab_width(prefix, tab_width)
211        }
212    };
213
214    raw.min(viewport_width.saturating_sub(1))
215}
216
217/// Calculate wrap points for text
218///
219/// Given a width constraint, calculates where the text needs to wrap
220pub fn calculate_wrap_points(text: &str, viewport_width: usize) -> Vec<WrapPoint> {
221    calculate_wrap_points_with_tab_width(text, viewport_width, DEFAULT_TAB_WIDTH)
222}
223
224/// Calculate wrap points for text, interpreting `'\t'` using `tab_width`.
225pub fn calculate_wrap_points_with_tab_width(
226    text: &str,
227    viewport_width: usize,
228    tab_width: usize,
229) -> Vec<WrapPoint> {
230    calculate_wrap_points_with_tab_width_and_mode(text, viewport_width, tab_width, WrapMode::Char)
231}
232
233/// Calculate wrap points for text using a configurable [`WrapMode`].
234pub fn calculate_wrap_points_with_tab_width_and_mode(
235    text: &str,
236    viewport_width: usize,
237    tab_width: usize,
238    wrap_mode: WrapMode,
239) -> Vec<WrapPoint> {
240    calculate_wrap_points_with_tab_width_mode_and_indent(
241        text,
242        viewport_width,
243        tab_width,
244        wrap_mode,
245        WrapIndent::None,
246    )
247}
248
249/// Calculate wrap points for text using a configurable [`WrapMode`] and [`WrapIndent`].
250pub fn calculate_wrap_points_with_tab_width_mode_and_indent(
251    text: &str,
252    viewport_width: usize,
253    tab_width: usize,
254    wrap_mode: WrapMode,
255    wrap_indent: WrapIndent,
256) -> Vec<WrapPoint> {
257    if viewport_width == 0 {
258        return Vec::new();
259    }
260
261    match wrap_mode {
262        WrapMode::None => Vec::new(),
263        WrapMode::Char => {
264            let indent =
265                wrap_indent_cells_for_line_text(text, wrap_indent, viewport_width, tab_width);
266            calculate_wrap_points_char_with_tab_width(text, viewport_width, tab_width, indent)
267        }
268        WrapMode::Word => {
269            let indent =
270                wrap_indent_cells_for_line_text(text, wrap_indent, viewport_width, tab_width);
271            calculate_wrap_points_word_with_tab_width(text, viewport_width, tab_width, indent)
272        }
273    }
274}
275
276fn calculate_wrap_points_char_with_tab_width(
277    text: &str,
278    viewport_width: usize,
279    tab_width: usize,
280    wrap_indent_cells: usize,
281) -> Vec<WrapPoint> {
282    let mut wrap_points = Vec::new();
283    let mut x_in_segment = 0usize;
284    let mut x_in_line = 0usize;
285
286    for (char_index, (byte_offset, ch)) in text.char_indices().enumerate() {
287        let ch_width = cell_width_at(ch, x_in_line, tab_width);
288
289        // If adding this character would exceed the width limit
290        if x_in_segment + ch_width > viewport_width {
291            // Double-width characters cannot be split
292            // If remaining width cannot accommodate the double-width character, it should wrap intact to the next line
293            wrap_points.push(WrapPoint {
294                char_index,
295                byte_offset,
296            });
297            x_in_segment = wrap_indent_cells;
298        } else {
299            // ok
300        }
301
302        x_in_segment = x_in_segment.saturating_add(ch_width);
303        x_in_line = x_in_line.saturating_add(ch_width);
304
305        // If current width equals viewport width exactly, the next character should wrap
306        if x_in_segment == viewport_width {
307            // Check if there are more characters
308            if byte_offset + ch.len_utf8() < text.len() {
309                wrap_points.push(WrapPoint {
310                    char_index: char_index + 1,
311                    byte_offset: byte_offset + ch.len_utf8(),
312                });
313                x_in_segment = wrap_indent_cells;
314            }
315        }
316    }
317
318    wrap_points
319}
320
321fn calculate_wrap_points_word_with_tab_width(
322    text: &str,
323    viewport_width: usize,
324    tab_width: usize,
325    wrap_indent_cells: usize,
326) -> Vec<WrapPoint> {
327    let mut wrap_points = Vec::new();
328
329    let mut segment_start_char = 0usize;
330    let mut segment_start_x_in_line = 0usize;
331    let mut last_break: Option<(usize, usize, usize)> = None; // (char_index, byte_offset, x_in_line)
332
333    let mut x_in_line = 0usize;
334
335    for (char_index, (byte_offset, ch)) in text.char_indices().enumerate() {
336        let ch_width = cell_width_at(ch, x_in_line, tab_width);
337
338        loop {
339            let segment_indent = if segment_start_char == 0 {
340                0
341            } else {
342                wrap_indent_cells
343            };
344            let x_in_segment = x_in_line
345                .saturating_sub(segment_start_x_in_line)
346                .saturating_add(segment_indent);
347            if x_in_segment.saturating_add(ch_width) <= viewport_width {
348                break;
349            }
350
351            if let Some((break_char, break_byte, break_x)) = last_break
352                && break_char > segment_start_char
353            {
354                wrap_points.push(WrapPoint {
355                    char_index: break_char,
356                    byte_offset: break_byte,
357                });
358                segment_start_char = break_char;
359                segment_start_x_in_line = break_x;
360                last_break = None;
361                continue;
362            }
363
364            // Fallback: wrap at the current character.
365            wrap_points.push(WrapPoint {
366                char_index,
367                byte_offset,
368            });
369            segment_start_char = char_index;
370            segment_start_x_in_line = x_in_line;
371            last_break = None;
372            break;
373        }
374
375        x_in_line = x_in_line.saturating_add(ch_width);
376
377        if ch.is_whitespace() {
378            last_break = Some((char_index + 1, byte_offset + ch.len_utf8(), x_in_line));
379        }
380    }
381
382    wrap_points
383}
384
385/// Layout engine - manages visual representation of all lines
386pub struct LayoutEngine {
387    /// Viewport width (in character cells)
388    viewport_width: usize,
389    /// Tab width (in cells) for expanding `'\t'`
390    tab_width: usize,
391    /// Soft wrapping mode.
392    wrap_mode: WrapMode,
393    /// Wrapped-line indentation policy.
394    wrap_indent: WrapIndent,
395    /// Visual information for each logical line
396    line_layouts: Vec<VisualLineInfo>,
397}
398
399impl LayoutEngine {
400    /// Create a new layout engine
401    pub fn new(viewport_width: usize) -> Self {
402        Self {
403            viewport_width,
404            tab_width: DEFAULT_TAB_WIDTH,
405            wrap_mode: WrapMode::Char,
406            wrap_indent: WrapIndent::None,
407            line_layouts: Vec::new(),
408        }
409    }
410
411    /// Set viewport width.
412    ///
413    /// Callers must reflow existing lines after changing this option.
414    pub fn set_viewport_width(&mut self, width: usize) {
415        if self.viewport_width != width {
416            self.viewport_width = width;
417        }
418    }
419
420    /// Get viewport width
421    pub fn viewport_width(&self) -> usize {
422        self.viewport_width
423    }
424
425    /// Get wrap mode.
426    pub fn wrap_mode(&self) -> WrapMode {
427        self.wrap_mode
428    }
429
430    /// Set wrap mode.
431    ///
432    /// Callers must reflow existing lines after changing this option.
433    pub fn set_wrap_mode(&mut self, wrap_mode: WrapMode) {
434        if self.wrap_mode != wrap_mode {
435            self.wrap_mode = wrap_mode;
436        }
437    }
438
439    /// Get wrapped-line indentation policy.
440    pub fn wrap_indent(&self) -> WrapIndent {
441        self.wrap_indent
442    }
443
444    /// Set wrapped-line indentation policy.
445    ///
446    /// Callers must reflow existing lines after changing this option.
447    pub fn set_wrap_indent(&mut self, wrap_indent: WrapIndent) {
448        if self.wrap_indent != wrap_indent {
449            self.wrap_indent = wrap_indent;
450        }
451    }
452
453    /// Get tab width (in cells).
454    pub fn tab_width(&self) -> usize {
455        self.tab_width
456    }
457
458    /// Set tab width (in cells) used for expanding `'\t'`.
459    ///
460    /// Callers must reflow existing lines after changing this option.
461    pub fn set_tab_width(&mut self, tab_width: usize) {
462        let tab_width = tab_width.max(1);
463        if self.tab_width != tab_width {
464            self.tab_width = tab_width;
465        }
466    }
467
468    /// Build layout from list of text lines
469    pub fn from_lines(&mut self, lines: &[&str]) {
470        self.recalculate_all_from_lines(lines.iter().copied());
471    }
472
473    /// Recalculate layout for all lines using caller-provided text.
474    pub fn recalculate_all_from_lines<I, S>(&mut self, lines: I)
475    where
476        I: IntoIterator<Item = S>,
477        S: AsRef<str>,
478    {
479        self.line_layouts.clear();
480        for line in lines {
481            let line = line.as_ref();
482            self.line_layouts
483                .push(VisualLineInfo::from_text_with_layout_options(
484                    line,
485                    self.viewport_width,
486                    self.tab_width,
487                    self.wrap_mode,
488                    self.wrap_indent,
489                ));
490        }
491    }
492
493    /// Add a line
494    pub fn add_line(&mut self, text: &str) {
495        self.line_layouts
496            .push(VisualLineInfo::from_text_with_layout_options(
497                text,
498                self.viewport_width,
499                self.tab_width,
500                self.wrap_mode,
501                self.wrap_indent,
502            ));
503    }
504
505    /// Update a specific line
506    pub fn update_line(&mut self, line_index: usize, text: &str) {
507        if line_index < self.line_layouts.len() {
508            self.line_layouts[line_index] = VisualLineInfo::from_text_with_layout_options(
509                text,
510                self.viewport_width,
511                self.tab_width,
512                self.wrap_mode,
513                self.wrap_indent,
514            );
515        }
516    }
517
518    /// Insert a line
519    pub fn insert_line(&mut self, line_index: usize, text: &str) {
520        let pos = line_index.min(self.line_layouts.len());
521        self.line_layouts.insert(
522            pos,
523            VisualLineInfo::from_text_with_layout_options(
524                text,
525                self.viewport_width,
526                self.tab_width,
527                self.wrap_mode,
528                self.wrap_indent,
529            ),
530        );
531    }
532
533    /// Delete a line
534    pub fn delete_line(&mut self, line_index: usize) {
535        if line_index < self.line_layouts.len() {
536            self.line_layouts.remove(line_index);
537        }
538    }
539
540    /// Get visual information for a specific logical line
541    pub fn get_line_layout(&self, line_index: usize) -> Option<&VisualLineInfo> {
542        self.line_layouts.get(line_index)
543    }
544
545    /// Get total number of logical lines
546    pub fn logical_line_count(&self) -> usize {
547        self.line_layouts.len()
548    }
549
550    /// Get total number of visual lines
551    pub fn visual_line_count(&self) -> usize {
552        self.line_layouts.iter().map(|l| l.visual_line_count).sum()
553    }
554
555    /// Convert logical line number to visual line number
556    ///
557    /// Returns the line number of the first visual line of this logical line
558    pub fn logical_to_visual_line(&self, logical_line: usize) -> usize {
559        self.line_layouts
560            .iter()
561            .take(logical_line)
562            .map(|l| l.visual_line_count)
563            .sum()
564    }
565
566    /// Convert visual line number to logical line number and offset within line
567    ///
568    /// Returns (logical_line, visual_line_in_logical)
569    pub fn visual_to_logical_line(&self, visual_line: usize) -> (usize, usize) {
570        let mut cumulative_visual = 0;
571
572        for (logical_idx, layout) in self.line_layouts.iter().enumerate() {
573            if cumulative_visual + layout.visual_line_count > visual_line {
574                let visual_offset = visual_line - cumulative_visual;
575                return (logical_idx, visual_offset);
576            }
577            cumulative_visual += layout.visual_line_count;
578        }
579
580        // If out of range, return the last line
581        let last_line = self.line_layouts.len().saturating_sub(1);
582        let last_visual_offset = self
583            .line_layouts
584            .last()
585            .map(|l| l.visual_line_count.saturating_sub(1))
586            .unwrap_or(0);
587        (last_line, last_visual_offset)
588    }
589
590    /// Clear all lines
591    pub fn clear(&mut self) {
592        self.line_layouts.clear();
593    }
594
595    /// Convert logical coordinates (line, column) to visual coordinates (visual row number, x cell offset within row).
596    ///
597    /// - `logical_line`: Logical line number (0-based)
598    /// - `column`: Character column within the logical line (0-based, counted by `char`)
599    /// - `line_text`: Text for `logical_line`, supplied by the caller's canonical text store.
600    ///
601    /// Return value:
602    /// - `Some((visual_row, x))`: `visual_row` is the global visual row number, `x` is the cell offset within that visual row
603    /// - `None`: Line number out of range
604    pub fn logical_position_to_visual(
605        &self,
606        logical_line: usize,
607        column: usize,
608        line_text: &str,
609    ) -> Option<(usize, usize)> {
610        let layout = self.get_line_layout(logical_line)?;
611
612        let line_char_len = line_text.chars().count();
613        let column = column.min(line_char_len);
614
615        // Calculate which visual line the cursor belongs to (within this logical line) and the starting character index of that visual line.
616        let mut wrapped_offset = 0usize;
617        let mut segment_start_col = 0usize;
618
619        // The char_index in wrap_points indicates "where the next segment starts".
620        for wrap_point in &layout.wrap_points {
621            if column >= wrap_point.char_index {
622                wrapped_offset += 1;
623                segment_start_col = wrap_point.char_index;
624            } else {
625                break;
626            }
627        }
628
629        // Calculate visual width from segment start to column, with tab expansion.
630        let seg_start_x_in_line = visual_x_for_column(line_text, segment_start_col, self.tab_width);
631        let mut x_in_line = seg_start_x_in_line;
632        let mut x_in_segment = 0usize;
633        for ch in line_text
634            .chars()
635            .skip(segment_start_col)
636            .take(column.saturating_sub(segment_start_col))
637        {
638            let w = cell_width_at(ch, x_in_line, self.tab_width);
639            x_in_line = x_in_line.saturating_add(w);
640            x_in_segment = x_in_segment.saturating_add(w);
641        }
642
643        let indent = if wrapped_offset == 0 {
644            0
645        } else {
646            wrap_indent_cells_for_line_text(
647                line_text,
648                self.wrap_indent,
649                self.viewport_width,
650                self.tab_width,
651            )
652        };
653
654        let visual_row = self.logical_to_visual_line(logical_line) + wrapped_offset;
655        Some((visual_row, indent.saturating_add(x_in_segment)))
656    }
657
658    /// Convert logical coordinates (line, column) to visual coordinates, allowing column to exceed line end (virtual spaces).
659    ///
660    /// Difference from [`logical_position_to_visual`](Self::logical_position_to_visual):
661    /// - `column` is not clamped to `line_char_len`
662    /// - Excess portion is treated as virtual spaces of `' '` (width=1)
663    /// - `line_text` comes from the caller's canonical text store.
664    pub fn logical_position_to_visual_allow_virtual(
665        &self,
666        logical_line: usize,
667        column: usize,
668        line_text: &str,
669    ) -> Option<(usize, usize)> {
670        let layout = self.get_line_layout(logical_line)?;
671
672        let line_char_len = line_text.chars().count();
673        let clamped_column = column.min(line_char_len);
674
675        let mut wrapped_offset = 0usize;
676        let mut segment_start_col = 0usize;
677        for wrap_point in &layout.wrap_points {
678            if clamped_column >= wrap_point.char_index {
679                wrapped_offset += 1;
680                segment_start_col = wrap_point.char_index;
681            } else {
682                break;
683            }
684        }
685
686        let seg_start_x_in_line = visual_x_for_column(line_text, segment_start_col, self.tab_width);
687        let mut x_in_line = seg_start_x_in_line;
688        let mut x_in_segment = 0usize;
689        for ch in line_text
690            .chars()
691            .skip(segment_start_col)
692            .take(clamped_column.saturating_sub(segment_start_col))
693        {
694            let w = cell_width_at(ch, x_in_line, self.tab_width);
695            x_in_line = x_in_line.saturating_add(w);
696            x_in_segment = x_in_segment.saturating_add(w);
697        }
698
699        let indent = if wrapped_offset == 0 {
700            0
701        } else {
702            wrap_indent_cells_for_line_text(
703                line_text,
704                self.wrap_indent,
705                self.viewport_width,
706                self.tab_width,
707            )
708        };
709
710        let x_in_segment = x_in_segment + column.saturating_sub(line_char_len);
711        let visual_row = self.logical_to_visual_line(logical_line) + wrapped_offset;
712        Some((visual_row, indent.saturating_add(x_in_segment)))
713    }
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719
720    #[test]
721    fn test_char_width() {
722        // ASCII characters should have width 1
723        assert_eq!(char_width('a'), 1);
724        assert_eq!(char_width('A'), 1);
725        assert_eq!(char_width(' '), 1);
726
727        // CJK characters should have width 2
728        assert_eq!(char_width('你'), 2);
729        assert_eq!(char_width('好'), 2);
730        assert_eq!(char_width('世'), 2);
731        assert_eq!(char_width('界'), 2);
732
733        // Most emojis have width 2
734        assert_eq!(char_width('👋'), 2);
735        assert_eq!(char_width('🌍'), 2);
736        assert_eq!(char_width('🦀'), 2);
737    }
738
739    #[test]
740    fn test_str_width() {
741        assert_eq!(str_width("hello"), 5);
742        assert_eq!(str_width("你好"), 4); // 2 CJK characters = 4 cells
743        assert_eq!(str_width("hello你好"), 9); // 5 + 4
744        assert_eq!(str_width("👋🌍"), 4); // 2 emojis = 4 cells
745    }
746
747    #[test]
748    fn test_tab_width_expansion() {
749        // tab stops every 4 cells.
750        assert_eq!(cell_width_at('\t', 0, 4), 4);
751        assert_eq!(cell_width_at('\t', 1, 4), 3);
752        assert_eq!(cell_width_at('\t', 2, 4), 2);
753        assert_eq!(cell_width_at('\t', 3, 4), 1);
754        assert_eq!(cell_width_at('\t', 4, 4), 4);
755
756        assert_eq!(str_width_with_tab_width("\t", 4), 4);
757        assert_eq!(str_width_with_tab_width("a\t", 4), 4); // "a" (1) then tab to 4
758        assert_eq!(str_width_with_tab_width("ab\t", 4), 4); // 2 + 2
759        assert_eq!(str_width_with_tab_width("abc\t", 4), 4); // 3 + 1
760        assert_eq!(str_width_with_tab_width("abcd\t", 4), 8); // 4 + 4
761    }
762
763    #[test]
764    fn test_calculate_wrap_points_simple() {
765        // Viewport width of 10
766        let text = "hello world";
767        let wraps = calculate_wrap_points(text, 10);
768
769        // "hello world" = 11 characters, should wrap between "hello" and "world"
770        // But actually wraps after the 10th character
771        assert!(!wraps.is_empty());
772    }
773
774    #[test]
775    fn test_calculate_wrap_points_exact_fit() {
776        // Exactly 10 characters wide
777        let text = "1234567890";
778        let wraps = calculate_wrap_points(text, 10);
779
780        // Exactly fills, no wrapping needed
781        assert_eq!(wraps.len(), 0);
782    }
783
784    #[test]
785    fn test_calculate_wrap_points_one_over() {
786        // 11 characters, width of 10
787        let text = "12345678901";
788        let wraps = calculate_wrap_points(text, 10);
789
790        // Should wrap after the 10th character
791        assert_eq!(wraps.len(), 1);
792        assert_eq!(wraps[0].char_index, 10);
793    }
794
795    #[test]
796    fn test_calculate_wrap_points_cjk() {
797        // 5 CJK characters = 10 cells wide
798        let text = "你好世界测";
799        let wraps = calculate_wrap_points(text, 10);
800
801        // Exactly fills, no wrapping needed
802        assert_eq!(wraps.len(), 0);
803    }
804
805    #[test]
806    fn test_calculate_wrap_points_cjk_overflow() {
807        // 6 CJK characters = 12 cells, viewport width of 10
808        let text = "你好世界测试";
809        let wraps = calculate_wrap_points(text, 10);
810
811        // Should wrap after the 5th character (first 5 characters = 10 cells)
812        assert_eq!(wraps.len(), 1);
813        assert_eq!(wraps[0].char_index, 5);
814    }
815
816    #[test]
817    fn test_wrap_mode_none_disables_wrapping() {
818        let mut engine = LayoutEngine::new(5);
819        engine.set_wrap_mode(WrapMode::None);
820        engine.from_lines(&["abcdefghij"]);
821
822        assert_eq!(engine.visual_line_count(), 1);
823        let layout = engine.get_line_layout(0).expect("layout");
824        assert_eq!(layout.visual_line_count, 1);
825        assert!(layout.wrap_points.is_empty());
826    }
827
828    #[test]
829    fn test_word_wrap_prefers_whitespace_when_possible() {
830        // With width=7, char-wrap would wrap as "hello w" + "orld".
831        // Word-wrap should prefer wrapping at the whitespace boundary ("hello " + "world").
832        let text = "hello world";
833
834        let wraps = calculate_wrap_points_with_tab_width_and_mode(
835            text,
836            7,
837            DEFAULT_TAB_WIDTH,
838            WrapMode::Word,
839        );
840
841        assert_eq!(wraps.len(), 1);
842        assert_eq!(wraps[0].char_index, 6);
843    }
844
845    #[test]
846    fn test_wrap_indent_same_as_line_indent_reduces_continuation_width() {
847        let text = "    abcdefgh";
848        let wraps = calculate_wrap_points_with_tab_width_mode_and_indent(
849            text,
850            6,
851            DEFAULT_TAB_WIDTH,
852            WrapMode::Char,
853            WrapIndent::SameAsLineIndent,
854        );
855
856        let indices: Vec<usize> = wraps.iter().map(|wp| wp.char_index).collect();
857        assert_eq!(indices, vec![6, 8, 10]);
858    }
859
860    #[test]
861    fn test_wrap_double_width_char() {
862        // Viewport has 1 cell remaining, next is a double-width character
863        // "Hello" = 5 cells, "你" = 2 cells, viewport width = 6
864        let text = "Hello你";
865        let wraps = calculate_wrap_points(text, 6);
866
867        // "Hello" takes 5 cells, "你" needs 2 cells but only 1 remains
868        // So "你" should wrap intact to the next line
869        assert_eq!(wraps.len(), 1);
870        assert_eq!(wraps[0].char_index, 5); // Wrap before "你"
871    }
872
873    #[test]
874    fn test_visual_line_info() {
875        let info = VisualLineInfo::from_text("1234567890abc", 10);
876        assert_eq!(info.visual_line_count, 2); // Needs 2 visual lines
877        assert_eq!(info.wrap_points.len(), 1);
878    }
879
880    #[test]
881    fn test_layout_engine_basic() {
882        let mut engine = LayoutEngine::new(10);
883        engine.add_line("hello");
884        engine.add_line("1234567890abc");
885
886        assert_eq!(engine.logical_line_count(), 2);
887        assert_eq!(engine.visual_line_count(), 3); // 1 + 2
888    }
889
890    #[test]
891    fn test_layout_engine_viewport_change() {
892        let mut engine = LayoutEngine::new(20);
893        engine.from_lines(&["hello world", "rust programming"]);
894
895        let initial_visual = engine.visual_line_count();
896        assert_eq!(initial_visual, 2); // Both lines don't need wrapping
897
898        // Reduce viewport width
899        engine.set_viewport_width(5);
900        // Note: Due to our implementation, need to reset lines
901        engine.from_lines(&["hello world", "rust programming"]);
902
903        let new_visual = engine.visual_line_count();
904        assert!(new_visual > initial_visual); // Should have more visual lines
905    }
906
907    #[test]
908    fn test_logical_to_visual() {
909        let mut engine = LayoutEngine::new(10);
910        engine.from_lines(&["12345", "1234567890abc", "hello"]);
911
912        // Line 0 ("12345") doesn't wrap, starts at visual line 0
913        assert_eq!(engine.logical_to_visual_line(0), 0);
914
915        // Line 1 ("1234567890abc") needs wrapping, starts at visual line 1
916        assert_eq!(engine.logical_to_visual_line(1), 1);
917
918        // Line 2 ("hello") starts at visual line 3 (0 + 1 + 2)
919        assert_eq!(engine.logical_to_visual_line(2), 3);
920    }
921
922    #[test]
923    fn test_visual_to_logical() {
924        let mut engine = LayoutEngine::new(10);
925        engine.from_lines(&["12345", "1234567890abc", "hello"]);
926
927        // Visual line 0 -> logical line 0
928        assert_eq!(engine.visual_to_logical_line(0), (0, 0));
929
930        // Visual line 1 -> logical line 1's 0th visual line
931        assert_eq!(engine.visual_to_logical_line(1), (1, 0));
932
933        // Visual line 2 -> logical line 1's 1st visual line
934        assert_eq!(engine.visual_to_logical_line(2), (1, 1));
935
936        // Visual line 3 -> logical line 2
937        assert_eq!(engine.visual_to_logical_line(3), (2, 0));
938    }
939}