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    /// Raw text for each logical line (excluding newline characters)
398    line_texts: Vec<String>,
399}
400
401impl LayoutEngine {
402    /// Create a new layout engine
403    pub fn new(viewport_width: usize) -> Self {
404        Self {
405            viewport_width,
406            tab_width: DEFAULT_TAB_WIDTH,
407            wrap_mode: WrapMode::Char,
408            wrap_indent: WrapIndent::None,
409            line_layouts: Vec::new(),
410            line_texts: Vec::new(),
411        }
412    }
413
414    /// Set viewport width
415    pub fn set_viewport_width(&mut self, width: usize) {
416        if self.viewport_width != width {
417            self.viewport_width = width;
418            self.recalculate_all();
419        }
420    }
421
422    /// Get viewport width
423    pub fn viewport_width(&self) -> usize {
424        self.viewport_width
425    }
426
427    /// Get wrap mode.
428    pub fn wrap_mode(&self) -> WrapMode {
429        self.wrap_mode
430    }
431
432    /// Set wrap mode.
433    ///
434    /// If `wrap_mode` changes, all line layouts are recalculated.
435    pub fn set_wrap_mode(&mut self, wrap_mode: WrapMode) {
436        if self.wrap_mode != wrap_mode {
437            self.wrap_mode = wrap_mode;
438            self.recalculate_all();
439        }
440    }
441
442    /// Get wrapped-line indentation policy.
443    pub fn wrap_indent(&self) -> WrapIndent {
444        self.wrap_indent
445    }
446
447    /// Set wrapped-line indentation policy.
448    ///
449    /// If `wrap_indent` changes, all line layouts are recalculated.
450    pub fn set_wrap_indent(&mut self, wrap_indent: WrapIndent) {
451        if self.wrap_indent != wrap_indent {
452            self.wrap_indent = wrap_indent;
453            self.recalculate_all();
454        }
455    }
456
457    /// Get tab width (in cells).
458    pub fn tab_width(&self) -> usize {
459        self.tab_width
460    }
461
462    /// Set tab width (in cells) used for expanding `'\t'`.
463    ///
464    /// If `tab_width` changes, all line layouts are recalculated.
465    pub fn set_tab_width(&mut self, tab_width: usize) {
466        let tab_width = tab_width.max(1);
467        if self.tab_width != tab_width {
468            self.tab_width = tab_width;
469            self.recalculate_all();
470        }
471    }
472
473    /// Build layout from list of text lines
474    pub fn from_lines(&mut self, lines: &[&str]) {
475        self.line_layouts.clear();
476        self.line_texts.clear();
477        for line in lines {
478            self.line_texts.push((*line).to_string());
479            self.line_layouts
480                .push(VisualLineInfo::from_text_with_layout_options(
481                    line,
482                    self.viewport_width,
483                    self.tab_width,
484                    self.wrap_mode,
485                    self.wrap_indent,
486                ));
487        }
488    }
489
490    /// Add a line
491    pub fn add_line(&mut self, text: &str) {
492        self.line_texts.push(text.to_string());
493        self.line_layouts
494            .push(VisualLineInfo::from_text_with_layout_options(
495                text,
496                self.viewport_width,
497                self.tab_width,
498                self.wrap_mode,
499                self.wrap_indent,
500            ));
501    }
502
503    /// Update a specific line
504    pub fn update_line(&mut self, line_index: usize, text: &str) {
505        if line_index < self.line_layouts.len() {
506            self.line_texts[line_index] = text.to_string();
507            self.line_layouts[line_index] = VisualLineInfo::from_text_with_layout_options(
508                text,
509                self.viewport_width,
510                self.tab_width,
511                self.wrap_mode,
512                self.wrap_indent,
513            );
514        }
515    }
516
517    /// Insert a line
518    pub fn insert_line(&mut self, line_index: usize, text: &str) {
519        let pos = line_index.min(self.line_layouts.len());
520        self.line_texts.insert(pos, text.to_string());
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_texts.remove(line_index);
537            self.line_layouts.remove(line_index);
538        }
539    }
540
541    /// Get visual information for a specific logical line
542    pub fn get_line_layout(&self, line_index: usize) -> Option<&VisualLineInfo> {
543        self.line_layouts.get(line_index)
544    }
545
546    /// Get total number of logical lines
547    pub fn logical_line_count(&self) -> usize {
548        self.line_layouts.len()
549    }
550
551    /// Get total number of visual lines
552    pub fn visual_line_count(&self) -> usize {
553        self.line_layouts.iter().map(|l| l.visual_line_count).sum()
554    }
555
556    /// Convert logical line number to visual line number
557    ///
558    /// Returns the line number of the first visual line of this logical line
559    pub fn logical_to_visual_line(&self, logical_line: usize) -> usize {
560        self.line_layouts
561            .iter()
562            .take(logical_line)
563            .map(|l| l.visual_line_count)
564            .sum()
565    }
566
567    /// Convert visual line number to logical line number and offset within line
568    ///
569    /// Returns (logical_line, visual_line_in_logical)
570    pub fn visual_to_logical_line(&self, visual_line: usize) -> (usize, usize) {
571        let mut cumulative_visual = 0;
572
573        for (logical_idx, layout) in self.line_layouts.iter().enumerate() {
574            if cumulative_visual + layout.visual_line_count > visual_line {
575                let visual_offset = visual_line - cumulative_visual;
576                return (logical_idx, visual_offset);
577            }
578            cumulative_visual += layout.visual_line_count;
579        }
580
581        // If out of range, return the last line
582        let last_line = self.line_layouts.len().saturating_sub(1);
583        let last_visual_offset = self
584            .line_layouts
585            .last()
586            .map(|l| l.visual_line_count.saturating_sub(1))
587            .unwrap_or(0);
588        (last_line, last_visual_offset)
589    }
590
591    /// Recalculate layout for all lines
592    fn recalculate_all(&mut self) {
593        if self.line_texts.len() != self.line_layouts.len() {
594            // Conservative handling: avoid out-of-bounds access. Normally these two should always be consistent.
595            self.line_layouts.clear();
596            for line in &self.line_texts {
597                self.line_layouts
598                    .push(VisualLineInfo::from_text_with_layout_options(
599                        line,
600                        self.viewport_width,
601                        self.tab_width,
602                        self.wrap_mode,
603                        self.wrap_indent,
604                    ));
605            }
606            return;
607        }
608
609        for (layout, line_text) in self.line_layouts.iter_mut().zip(self.line_texts.iter()) {
610            *layout = VisualLineInfo::from_text_with_layout_options(
611                line_text,
612                self.viewport_width,
613                self.tab_width,
614                self.wrap_mode,
615                self.wrap_indent,
616            );
617        }
618    }
619
620    /// Clear all lines
621    pub fn clear(&mut self) {
622        self.line_layouts.clear();
623        self.line_texts.clear();
624    }
625
626    /// Convert logical coordinates (line, column) to visual coordinates (visual row number, x cell offset within row).
627    ///
628    /// - `logical_line`: Logical line number (0-based)
629    /// - `column`: Character column within the logical line (0-based, counted by `char`)
630    ///
631    /// Return value:
632    /// - `Some((visual_row, x))`: `visual_row` is the global visual row number, `x` is the cell offset within that visual row
633    /// - `None`: Line number out of range
634    pub fn logical_position_to_visual(
635        &self,
636        logical_line: usize,
637        column: usize,
638    ) -> Option<(usize, usize)> {
639        let layout = self.get_line_layout(logical_line)?;
640        let line_text = self.line_texts.get(logical_line)?;
641
642        let line_char_len = line_text.chars().count();
643        let column = column.min(line_char_len);
644
645        // Calculate which visual line the cursor belongs to (within this logical line) and the starting character index of that visual line.
646        let mut wrapped_offset = 0usize;
647        let mut segment_start_col = 0usize;
648
649        // The char_index in wrap_points indicates "where the next segment starts".
650        for wrap_point in &layout.wrap_points {
651            if column >= wrap_point.char_index {
652                wrapped_offset += 1;
653                segment_start_col = wrap_point.char_index;
654            } else {
655                break;
656            }
657        }
658
659        // Calculate visual width from segment start to column, with tab expansion.
660        let seg_start_x_in_line = visual_x_for_column(line_text, segment_start_col, self.tab_width);
661        let mut x_in_line = seg_start_x_in_line;
662        let mut x_in_segment = 0usize;
663        for ch in line_text
664            .chars()
665            .skip(segment_start_col)
666            .take(column.saturating_sub(segment_start_col))
667        {
668            let w = cell_width_at(ch, x_in_line, self.tab_width);
669            x_in_line = x_in_line.saturating_add(w);
670            x_in_segment = x_in_segment.saturating_add(w);
671        }
672
673        let indent = if wrapped_offset == 0 {
674            0
675        } else {
676            wrap_indent_cells_for_line_text(
677                line_text,
678                self.wrap_indent,
679                self.viewport_width,
680                self.tab_width,
681            )
682        };
683
684        let visual_row = self.logical_to_visual_line(logical_line) + wrapped_offset;
685        Some((visual_row, indent.saturating_add(x_in_segment)))
686    }
687
688    /// Convert logical coordinates (line, column) to visual coordinates, allowing column to exceed line end (virtual spaces).
689    ///
690    /// Difference from [`logical_position_to_visual`](Self::logical_position_to_visual):
691    /// - `column` is not clamped to `line_char_len`
692    /// - Excess portion is treated as virtual spaces of `' '` (width=1)
693    pub fn logical_position_to_visual_allow_virtual(
694        &self,
695        logical_line: usize,
696        column: usize,
697    ) -> Option<(usize, usize)> {
698        let layout = self.get_line_layout(logical_line)?;
699        let line_text = self.line_texts.get(logical_line)?;
700
701        let line_char_len = line_text.chars().count();
702        let clamped_column = column.min(line_char_len);
703
704        let mut wrapped_offset = 0usize;
705        let mut segment_start_col = 0usize;
706        for wrap_point in &layout.wrap_points {
707            if clamped_column >= wrap_point.char_index {
708                wrapped_offset += 1;
709                segment_start_col = wrap_point.char_index;
710            } else {
711                break;
712            }
713        }
714
715        let seg_start_x_in_line = visual_x_for_column(line_text, segment_start_col, self.tab_width);
716        let mut x_in_line = seg_start_x_in_line;
717        let mut x_in_segment = 0usize;
718        for ch in line_text
719            .chars()
720            .skip(segment_start_col)
721            .take(clamped_column.saturating_sub(segment_start_col))
722        {
723            let w = cell_width_at(ch, x_in_line, self.tab_width);
724            x_in_line = x_in_line.saturating_add(w);
725            x_in_segment = x_in_segment.saturating_add(w);
726        }
727
728        let indent = if wrapped_offset == 0 {
729            0
730        } else {
731            wrap_indent_cells_for_line_text(
732                line_text,
733                self.wrap_indent,
734                self.viewport_width,
735                self.tab_width,
736            )
737        };
738
739        let x_in_segment = x_in_segment + column.saturating_sub(line_char_len);
740        let visual_row = self.logical_to_visual_line(logical_line) + wrapped_offset;
741        Some((visual_row, indent.saturating_add(x_in_segment)))
742    }
743}
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748
749    #[test]
750    fn test_char_width() {
751        // ASCII characters should have width 1
752        assert_eq!(char_width('a'), 1);
753        assert_eq!(char_width('A'), 1);
754        assert_eq!(char_width(' '), 1);
755
756        // CJK characters should have width 2
757        assert_eq!(char_width('你'), 2);
758        assert_eq!(char_width('好'), 2);
759        assert_eq!(char_width('世'), 2);
760        assert_eq!(char_width('界'), 2);
761
762        // Most emojis have width 2
763        assert_eq!(char_width('👋'), 2);
764        assert_eq!(char_width('🌍'), 2);
765        assert_eq!(char_width('🦀'), 2);
766    }
767
768    #[test]
769    fn test_str_width() {
770        assert_eq!(str_width("hello"), 5);
771        assert_eq!(str_width("你好"), 4); // 2 CJK characters = 4 cells
772        assert_eq!(str_width("hello你好"), 9); // 5 + 4
773        assert_eq!(str_width("👋🌍"), 4); // 2 emojis = 4 cells
774    }
775
776    #[test]
777    fn test_tab_width_expansion() {
778        // tab stops every 4 cells.
779        assert_eq!(cell_width_at('\t', 0, 4), 4);
780        assert_eq!(cell_width_at('\t', 1, 4), 3);
781        assert_eq!(cell_width_at('\t', 2, 4), 2);
782        assert_eq!(cell_width_at('\t', 3, 4), 1);
783        assert_eq!(cell_width_at('\t', 4, 4), 4);
784
785        assert_eq!(str_width_with_tab_width("\t", 4), 4);
786        assert_eq!(str_width_with_tab_width("a\t", 4), 4); // "a" (1) then tab to 4
787        assert_eq!(str_width_with_tab_width("ab\t", 4), 4); // 2 + 2
788        assert_eq!(str_width_with_tab_width("abc\t", 4), 4); // 3 + 1
789        assert_eq!(str_width_with_tab_width("abcd\t", 4), 8); // 4 + 4
790    }
791
792    #[test]
793    fn test_calculate_wrap_points_simple() {
794        // Viewport width of 10
795        let text = "hello world";
796        let wraps = calculate_wrap_points(text, 10);
797
798        // "hello world" = 11 characters, should wrap between "hello" and "world"
799        // But actually wraps after the 10th character
800        assert!(!wraps.is_empty());
801    }
802
803    #[test]
804    fn test_calculate_wrap_points_exact_fit() {
805        // Exactly 10 characters wide
806        let text = "1234567890";
807        let wraps = calculate_wrap_points(text, 10);
808
809        // Exactly fills, no wrapping needed
810        assert_eq!(wraps.len(), 0);
811    }
812
813    #[test]
814    fn test_calculate_wrap_points_one_over() {
815        // 11 characters, width of 10
816        let text = "12345678901";
817        let wraps = calculate_wrap_points(text, 10);
818
819        // Should wrap after the 10th character
820        assert_eq!(wraps.len(), 1);
821        assert_eq!(wraps[0].char_index, 10);
822    }
823
824    #[test]
825    fn test_calculate_wrap_points_cjk() {
826        // 5 CJK characters = 10 cells wide
827        let text = "你好世界测";
828        let wraps = calculate_wrap_points(text, 10);
829
830        // Exactly fills, no wrapping needed
831        assert_eq!(wraps.len(), 0);
832    }
833
834    #[test]
835    fn test_calculate_wrap_points_cjk_overflow() {
836        // 6 CJK characters = 12 cells, viewport width of 10
837        let text = "你好世界测试";
838        let wraps = calculate_wrap_points(text, 10);
839
840        // Should wrap after the 5th character (first 5 characters = 10 cells)
841        assert_eq!(wraps.len(), 1);
842        assert_eq!(wraps[0].char_index, 5);
843    }
844
845    #[test]
846    fn test_wrap_mode_none_disables_wrapping() {
847        let mut engine = LayoutEngine::new(5);
848        engine.set_wrap_mode(WrapMode::None);
849        engine.from_lines(&["abcdefghij"]);
850
851        assert_eq!(engine.visual_line_count(), 1);
852        let layout = engine.get_line_layout(0).expect("layout");
853        assert_eq!(layout.visual_line_count, 1);
854        assert!(layout.wrap_points.is_empty());
855    }
856
857    #[test]
858    fn test_word_wrap_prefers_whitespace_when_possible() {
859        // With width=7, char-wrap would wrap as "hello w" + "orld".
860        // Word-wrap should prefer wrapping at the whitespace boundary ("hello " + "world").
861        let text = "hello world";
862
863        let wraps = calculate_wrap_points_with_tab_width_and_mode(
864            text,
865            7,
866            DEFAULT_TAB_WIDTH,
867            WrapMode::Word,
868        );
869
870        assert_eq!(wraps.len(), 1);
871        assert_eq!(wraps[0].char_index, 6);
872    }
873
874    #[test]
875    fn test_wrap_indent_same_as_line_indent_reduces_continuation_width() {
876        let text = "    abcdefgh";
877        let wraps = calculate_wrap_points_with_tab_width_mode_and_indent(
878            text,
879            6,
880            DEFAULT_TAB_WIDTH,
881            WrapMode::Char,
882            WrapIndent::SameAsLineIndent,
883        );
884
885        let indices: Vec<usize> = wraps.iter().map(|wp| wp.char_index).collect();
886        assert_eq!(indices, vec![6, 8, 10]);
887    }
888
889    #[test]
890    fn test_wrap_double_width_char() {
891        // Viewport has 1 cell remaining, next is a double-width character
892        // "Hello" = 5 cells, "你" = 2 cells, viewport width = 6
893        let text = "Hello你";
894        let wraps = calculate_wrap_points(text, 6);
895
896        // "Hello" takes 5 cells, "你" needs 2 cells but only 1 remains
897        // So "你" should wrap intact to the next line
898        assert_eq!(wraps.len(), 1);
899        assert_eq!(wraps[0].char_index, 5); // Wrap before "你"
900    }
901
902    #[test]
903    fn test_visual_line_info() {
904        let info = VisualLineInfo::from_text("1234567890abc", 10);
905        assert_eq!(info.visual_line_count, 2); // Needs 2 visual lines
906        assert_eq!(info.wrap_points.len(), 1);
907    }
908
909    #[test]
910    fn test_layout_engine_basic() {
911        let mut engine = LayoutEngine::new(10);
912        engine.add_line("hello");
913        engine.add_line("1234567890abc");
914
915        assert_eq!(engine.logical_line_count(), 2);
916        assert_eq!(engine.visual_line_count(), 3); // 1 + 2
917    }
918
919    #[test]
920    fn test_layout_engine_viewport_change() {
921        let mut engine = LayoutEngine::new(20);
922        engine.from_lines(&["hello world", "rust programming"]);
923
924        let initial_visual = engine.visual_line_count();
925        assert_eq!(initial_visual, 2); // Both lines don't need wrapping
926
927        // Reduce viewport width
928        engine.set_viewport_width(5);
929        // Note: Due to our implementation, need to reset lines
930        engine.from_lines(&["hello world", "rust programming"]);
931
932        let new_visual = engine.visual_line_count();
933        assert!(new_visual > initial_visual); // Should have more visual lines
934    }
935
936    #[test]
937    fn test_logical_to_visual() {
938        let mut engine = LayoutEngine::new(10);
939        engine.from_lines(&["12345", "1234567890abc", "hello"]);
940
941        // Line 0 ("12345") doesn't wrap, starts at visual line 0
942        assert_eq!(engine.logical_to_visual_line(0), 0);
943
944        // Line 1 ("1234567890abc") needs wrapping, starts at visual line 1
945        assert_eq!(engine.logical_to_visual_line(1), 1);
946
947        // Line 2 ("hello") starts at visual line 3 (0 + 1 + 2)
948        assert_eq!(engine.logical_to_visual_line(2), 3);
949    }
950
951    #[test]
952    fn test_visual_to_logical() {
953        let mut engine = LayoutEngine::new(10);
954        engine.from_lines(&["12345", "1234567890abc", "hello"]);
955
956        // Visual line 0 -> logical line 0
957        assert_eq!(engine.visual_to_logical_line(0), (0, 0));
958
959        // Visual line 1 -> logical line 1's 0th visual line
960        assert_eq!(engine.visual_to_logical_line(1), (1, 0));
961
962        // Visual line 2 -> logical line 1's 1st visual line
963        assert_eq!(engine.visual_to_logical_line(2), (1, 1));
964
965        // Visual line 3 -> logical line 2
966        assert_eq!(engine.visual_to_logical_line(3), (2, 0));
967    }
968}