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/// Wrap point
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub struct WrapPoint {
14    /// Character index where wrapping occurs (within the logical line)
15    pub char_index: usize,
16    /// Byte offset where wrapping occurs (within the logical line)
17    pub byte_offset: usize,
18}
19
20/// Visual line information
21#[derive(Debug, Clone)]
22pub struct VisualLineInfo {
23    /// Number of visual lines corresponding to this logical line
24    pub visual_line_count: usize,
25    /// List of wrap points
26    pub wrap_points: Vec<WrapPoint>,
27}
28
29impl VisualLineInfo {
30    /// Create an empty layout (a single visual line, no wrap points).
31    pub fn new() -> Self {
32        Self {
33            visual_line_count: 1,
34            wrap_points: Vec::new(),
35        }
36    }
37
38    /// Calculate visual line information from text and width constraint
39    pub fn from_text(text: &str, viewport_width: usize) -> Self {
40        let wrap_points = calculate_wrap_points(text, viewport_width);
41        let visual_line_count = wrap_points.len() + 1;
42
43        Self {
44            visual_line_count,
45            wrap_points,
46        }
47    }
48
49    /// Calculate visual line information from text and width constraint, with explicit `tab_width`.
50    pub fn from_text_with_tab_width(text: &str, viewport_width: usize, tab_width: usize) -> Self {
51        let wrap_points = calculate_wrap_points_with_tab_width(text, viewport_width, tab_width);
52        let visual_line_count = wrap_points.len() + 1;
53
54        Self {
55            visual_line_count,
56            wrap_points,
57        }
58    }
59}
60
61impl Default for VisualLineInfo {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67/// Calculate visual width of a character (based on UAX #11)
68///
69/// Return value:
70/// - 1: Narrow character (ASCII, etc.)
71/// - 2: Wide character (CJK, fullwidth, etc.)
72/// - 0: Zero-width character (combining characters, etc.)
73pub fn char_width(ch: char) -> usize {
74    // Use unicode-width crate to implement UAX #11
75    UnicodeWidthChar::width(ch).unwrap_or(1)
76}
77
78/// Calculate visual width (in cells) for a character at a specific cell offset within the line.
79///
80/// Notes:
81/// - For most characters, width follows UAX #11 via [`char_width`].
82/// - For `'\t'`, width advances to the next tab stop based on `tab_width`.
83pub fn cell_width_at(ch: char, cell_offset_in_line: usize, tab_width: usize) -> usize {
84    if ch == '\t' {
85        let tab_width = tab_width.max(1);
86        let rem = cell_offset_in_line % tab_width;
87        tab_width - rem
88    } else {
89        char_width(ch)
90    }
91}
92
93/// Calculate total visual width of a string
94pub fn str_width(s: &str) -> usize {
95    s.chars().map(char_width).sum()
96}
97
98/// Calculate total visual width of a string, interpreting `'\t'` using `tab_width`.
99pub fn str_width_with_tab_width(s: &str, tab_width: usize) -> usize {
100    let mut x = 0usize;
101    for ch in s.chars() {
102        x = x.saturating_add(cell_width_at(ch, x, tab_width));
103    }
104    x
105}
106
107/// Calculate the visual cell offset from the start of the line to the given character column.
108///
109/// - `column` is counted in `char` (not bytes).
110/// - `'\t'` is expanded using `tab_width` and the current cell offset.
111pub fn visual_x_for_column(line: &str, column: usize, tab_width: usize) -> usize {
112    let mut x = 0usize;
113    for ch in line.chars().take(column) {
114        x = x.saturating_add(cell_width_at(ch, x, tab_width));
115    }
116    x
117}
118
119/// Calculate wrap points for text
120///
121/// Given a width constraint, calculates where the text needs to wrap
122pub fn calculate_wrap_points(text: &str, viewport_width: usize) -> Vec<WrapPoint> {
123    calculate_wrap_points_with_tab_width(text, viewport_width, DEFAULT_TAB_WIDTH)
124}
125
126/// Calculate wrap points for text, interpreting `'\t'` using `tab_width`.
127pub fn calculate_wrap_points_with_tab_width(
128    text: &str,
129    viewport_width: usize,
130    tab_width: usize,
131) -> Vec<WrapPoint> {
132    if viewport_width == 0 {
133        return Vec::new();
134    }
135
136    let mut wrap_points = Vec::new();
137    let mut x_in_segment = 0usize;
138    let mut x_in_line = 0usize;
139
140    for (char_index, (byte_offset, ch)) in text.char_indices().enumerate() {
141        let ch_width = cell_width_at(ch, x_in_line, tab_width);
142
143        // If adding this character would exceed the width limit
144        if x_in_segment + ch_width > viewport_width {
145            // Double-width characters cannot be split
146            // If remaining width cannot accommodate the double-width character, it should wrap intact to the next line
147            wrap_points.push(WrapPoint {
148                char_index,
149                byte_offset,
150            });
151            x_in_segment = 0;
152        } else {
153            // ok
154        }
155
156        x_in_segment = x_in_segment.saturating_add(ch_width);
157        x_in_line = x_in_line.saturating_add(ch_width);
158
159        // If current width equals viewport width exactly, the next character should wrap
160        if x_in_segment == viewport_width {
161            // Check if there are more characters
162            if byte_offset + ch.len_utf8() < text.len() {
163                wrap_points.push(WrapPoint {
164                    char_index: char_index + 1,
165                    byte_offset: byte_offset + ch.len_utf8(),
166                });
167                x_in_segment = 0;
168            }
169        }
170    }
171
172    wrap_points
173}
174
175/// Layout engine - manages visual representation of all lines
176pub struct LayoutEngine {
177    /// Viewport width (in character cells)
178    viewport_width: usize,
179    /// Tab width (in cells) for expanding `'\t'`
180    tab_width: usize,
181    /// Visual information for each logical line
182    line_layouts: Vec<VisualLineInfo>,
183    /// Raw text for each logical line (excluding newline characters)
184    line_texts: Vec<String>,
185}
186
187impl LayoutEngine {
188    /// Create a new layout engine
189    pub fn new(viewport_width: usize) -> Self {
190        Self {
191            viewport_width,
192            tab_width: DEFAULT_TAB_WIDTH,
193            line_layouts: Vec::new(),
194            line_texts: Vec::new(),
195        }
196    }
197
198    /// Set viewport width
199    pub fn set_viewport_width(&mut self, width: usize) {
200        if self.viewport_width != width {
201            self.viewport_width = width;
202            self.recalculate_all();
203        }
204    }
205
206    /// Get viewport width
207    pub fn viewport_width(&self) -> usize {
208        self.viewport_width
209    }
210
211    /// Get tab width (in cells).
212    pub fn tab_width(&self) -> usize {
213        self.tab_width
214    }
215
216    /// Set tab width (in cells) used for expanding `'\t'`.
217    ///
218    /// If `tab_width` changes, all line layouts are recalculated.
219    pub fn set_tab_width(&mut self, tab_width: usize) {
220        let tab_width = tab_width.max(1);
221        if self.tab_width != tab_width {
222            self.tab_width = tab_width;
223            self.recalculate_all();
224        }
225    }
226
227    /// Build layout from list of text lines
228    pub fn from_lines(&mut self, lines: &[&str]) {
229        self.line_layouts.clear();
230        self.line_texts.clear();
231        for line in lines {
232            self.line_texts.push((*line).to_string());
233            self.line_layouts
234                .push(VisualLineInfo::from_text_with_tab_width(
235                    line,
236                    self.viewport_width,
237                    self.tab_width,
238                ));
239        }
240    }
241
242    /// Add a line
243    pub fn add_line(&mut self, text: &str) {
244        self.line_texts.push(text.to_string());
245        self.line_layouts
246            .push(VisualLineInfo::from_text_with_tab_width(
247                text,
248                self.viewport_width,
249                self.tab_width,
250            ));
251    }
252
253    /// Update a specific line
254    pub fn update_line(&mut self, line_index: usize, text: &str) {
255        if line_index < self.line_layouts.len() {
256            self.line_texts[line_index] = text.to_string();
257            self.line_layouts[line_index] =
258                VisualLineInfo::from_text_with_tab_width(text, self.viewport_width, self.tab_width);
259        }
260    }
261
262    /// Insert a line
263    pub fn insert_line(&mut self, line_index: usize, text: &str) {
264        let pos = line_index.min(self.line_layouts.len());
265        self.line_texts.insert(pos, text.to_string());
266        self.line_layouts.insert(
267            pos,
268            VisualLineInfo::from_text_with_tab_width(text, self.viewport_width, self.tab_width),
269        );
270    }
271
272    /// Delete a line
273    pub fn delete_line(&mut self, line_index: usize) {
274        if line_index < self.line_layouts.len() {
275            self.line_texts.remove(line_index);
276            self.line_layouts.remove(line_index);
277        }
278    }
279
280    /// Get visual information for a specific logical line
281    pub fn get_line_layout(&self, line_index: usize) -> Option<&VisualLineInfo> {
282        self.line_layouts.get(line_index)
283    }
284
285    /// Get total number of logical lines
286    pub fn logical_line_count(&self) -> usize {
287        self.line_layouts.len()
288    }
289
290    /// Get total number of visual lines
291    pub fn visual_line_count(&self) -> usize {
292        self.line_layouts.iter().map(|l| l.visual_line_count).sum()
293    }
294
295    /// Convert logical line number to visual line number
296    ///
297    /// Returns the line number of the first visual line of this logical line
298    pub fn logical_to_visual_line(&self, logical_line: usize) -> usize {
299        self.line_layouts
300            .iter()
301            .take(logical_line)
302            .map(|l| l.visual_line_count)
303            .sum()
304    }
305
306    /// Convert visual line number to logical line number and offset within line
307    ///
308    /// Returns (logical_line, visual_line_in_logical)
309    pub fn visual_to_logical_line(&self, visual_line: usize) -> (usize, usize) {
310        let mut cumulative_visual = 0;
311
312        for (logical_idx, layout) in self.line_layouts.iter().enumerate() {
313            if cumulative_visual + layout.visual_line_count > visual_line {
314                let visual_offset = visual_line - cumulative_visual;
315                return (logical_idx, visual_offset);
316            }
317            cumulative_visual += layout.visual_line_count;
318        }
319
320        // If out of range, return the last line
321        let last_line = self.line_layouts.len().saturating_sub(1);
322        let last_visual_offset = self
323            .line_layouts
324            .last()
325            .map(|l| l.visual_line_count.saturating_sub(1))
326            .unwrap_or(0);
327        (last_line, last_visual_offset)
328    }
329
330    /// Recalculate layout for all lines
331    fn recalculate_all(&mut self) {
332        if self.line_texts.len() != self.line_layouts.len() {
333            // Conservative handling: avoid out-of-bounds access. Normally these two should always be consistent.
334            self.line_layouts.clear();
335            for line in &self.line_texts {
336                self.line_layouts
337                    .push(VisualLineInfo::from_text_with_tab_width(
338                        line,
339                        self.viewport_width,
340                        self.tab_width,
341                    ));
342            }
343            return;
344        }
345
346        for (layout, line_text) in self.line_layouts.iter_mut().zip(self.line_texts.iter()) {
347            *layout = VisualLineInfo::from_text_with_tab_width(
348                line_text,
349                self.viewport_width,
350                self.tab_width,
351            );
352        }
353    }
354
355    /// Clear all lines
356    pub fn clear(&mut self) {
357        self.line_layouts.clear();
358        self.line_texts.clear();
359    }
360
361    /// Convert logical coordinates (line, column) to visual coordinates (visual row number, x cell offset within row).
362    ///
363    /// - `logical_line`: Logical line number (0-based)
364    /// - `column`: Character column within the logical line (0-based, counted by `char`)
365    ///
366    /// Return value:
367    /// - `Some((visual_row, x))`: `visual_row` is the global visual row number, `x` is the cell offset within that visual row
368    /// - `None`: Line number out of range
369    pub fn logical_position_to_visual(
370        &self,
371        logical_line: usize,
372        column: usize,
373    ) -> Option<(usize, usize)> {
374        let layout = self.get_line_layout(logical_line)?;
375        let line_text = self.line_texts.get(logical_line)?;
376
377        let line_char_len = line_text.chars().count();
378        let column = column.min(line_char_len);
379
380        // Calculate which visual line the cursor belongs to (within this logical line) and the starting character index of that visual line.
381        let mut wrapped_offset = 0usize;
382        let mut segment_start_col = 0usize;
383
384        // The char_index in wrap_points indicates "where the next segment starts".
385        for wrap_point in &layout.wrap_points {
386            if column >= wrap_point.char_index {
387                wrapped_offset += 1;
388                segment_start_col = wrap_point.char_index;
389            } else {
390                break;
391            }
392        }
393
394        // Calculate visual width from segment start to column, with tab expansion.
395        let seg_start_x_in_line = visual_x_for_column(line_text, segment_start_col, self.tab_width);
396        let mut x_in_line = seg_start_x_in_line;
397        let mut x_in_segment = 0usize;
398        for ch in line_text
399            .chars()
400            .skip(segment_start_col)
401            .take(column.saturating_sub(segment_start_col))
402        {
403            let w = cell_width_at(ch, x_in_line, self.tab_width);
404            x_in_line = x_in_line.saturating_add(w);
405            x_in_segment = x_in_segment.saturating_add(w);
406        }
407
408        let visual_row = self.logical_to_visual_line(logical_line) + wrapped_offset;
409        Some((visual_row, x_in_segment))
410    }
411
412    /// Convert logical coordinates (line, column) to visual coordinates, allowing column to exceed line end (virtual spaces).
413    ///
414    /// Difference from [`logical_position_to_visual`](Self::logical_position_to_visual):
415    /// - `column` is not clamped to `line_char_len`
416    /// - Excess portion is treated as virtual spaces of `' '` (width=1)
417    pub fn logical_position_to_visual_allow_virtual(
418        &self,
419        logical_line: usize,
420        column: usize,
421    ) -> Option<(usize, usize)> {
422        let layout = self.get_line_layout(logical_line)?;
423        let line_text = self.line_texts.get(logical_line)?;
424
425        let line_char_len = line_text.chars().count();
426        let clamped_column = column.min(line_char_len);
427
428        let mut wrapped_offset = 0usize;
429        let mut segment_start_col = 0usize;
430        for wrap_point in &layout.wrap_points {
431            if clamped_column >= wrap_point.char_index {
432                wrapped_offset += 1;
433                segment_start_col = wrap_point.char_index;
434            } else {
435                break;
436            }
437        }
438
439        let seg_start_x_in_line = visual_x_for_column(line_text, segment_start_col, self.tab_width);
440        let mut x_in_line = seg_start_x_in_line;
441        let mut x_in_segment = 0usize;
442        for ch in line_text
443            .chars()
444            .skip(segment_start_col)
445            .take(clamped_column.saturating_sub(segment_start_col))
446        {
447            let w = cell_width_at(ch, x_in_line, self.tab_width);
448            x_in_line = x_in_line.saturating_add(w);
449            x_in_segment = x_in_segment.saturating_add(w);
450        }
451
452        let x_in_segment = x_in_segment + column.saturating_sub(line_char_len);
453        let visual_row = self.logical_to_visual_line(logical_line) + wrapped_offset;
454        Some((visual_row, x_in_segment))
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    #[test]
463    fn test_char_width() {
464        // ASCII characters should have width 1
465        assert_eq!(char_width('a'), 1);
466        assert_eq!(char_width('A'), 1);
467        assert_eq!(char_width(' '), 1);
468
469        // CJK characters should have width 2
470        assert_eq!(char_width('你'), 2);
471        assert_eq!(char_width('好'), 2);
472        assert_eq!(char_width('世'), 2);
473        assert_eq!(char_width('界'), 2);
474
475        // Most emojis have width 2
476        assert_eq!(char_width('👋'), 2);
477        assert_eq!(char_width('🌍'), 2);
478        assert_eq!(char_width('🦀'), 2);
479    }
480
481    #[test]
482    fn test_str_width() {
483        assert_eq!(str_width("hello"), 5);
484        assert_eq!(str_width("你好"), 4); // 2 CJK characters = 4 cells
485        assert_eq!(str_width("hello你好"), 9); // 5 + 4
486        assert_eq!(str_width("👋🌍"), 4); // 2 emojis = 4 cells
487    }
488
489    #[test]
490    fn test_tab_width_expansion() {
491        // tab stops every 4 cells.
492        assert_eq!(cell_width_at('\t', 0, 4), 4);
493        assert_eq!(cell_width_at('\t', 1, 4), 3);
494        assert_eq!(cell_width_at('\t', 2, 4), 2);
495        assert_eq!(cell_width_at('\t', 3, 4), 1);
496        assert_eq!(cell_width_at('\t', 4, 4), 4);
497
498        assert_eq!(str_width_with_tab_width("\t", 4), 4);
499        assert_eq!(str_width_with_tab_width("a\t", 4), 4); // "a" (1) then tab to 4
500        assert_eq!(str_width_with_tab_width("ab\t", 4), 4); // 2 + 2
501        assert_eq!(str_width_with_tab_width("abc\t", 4), 4); // 3 + 1
502        assert_eq!(str_width_with_tab_width("abcd\t", 4), 8); // 4 + 4
503    }
504
505    #[test]
506    fn test_calculate_wrap_points_simple() {
507        // Viewport width of 10
508        let text = "hello world";
509        let wraps = calculate_wrap_points(text, 10);
510
511        // "hello world" = 11 characters, should wrap between "hello" and "world"
512        // But actually wraps after the 10th character
513        assert!(!wraps.is_empty());
514    }
515
516    #[test]
517    fn test_calculate_wrap_points_exact_fit() {
518        // Exactly 10 characters wide
519        let text = "1234567890";
520        let wraps = calculate_wrap_points(text, 10);
521
522        // Exactly fills, no wrapping needed
523        assert_eq!(wraps.len(), 0);
524    }
525
526    #[test]
527    fn test_calculate_wrap_points_one_over() {
528        // 11 characters, width of 10
529        let text = "12345678901";
530        let wraps = calculate_wrap_points(text, 10);
531
532        // Should wrap after the 10th character
533        assert_eq!(wraps.len(), 1);
534        assert_eq!(wraps[0].char_index, 10);
535    }
536
537    #[test]
538    fn test_calculate_wrap_points_cjk() {
539        // 5 CJK characters = 10 cells wide
540        let text = "你好世界测";
541        let wraps = calculate_wrap_points(text, 10);
542
543        // Exactly fills, no wrapping needed
544        assert_eq!(wraps.len(), 0);
545    }
546
547    #[test]
548    fn test_calculate_wrap_points_cjk_overflow() {
549        // 6 CJK characters = 12 cells, viewport width of 10
550        let text = "你好世界测试";
551        let wraps = calculate_wrap_points(text, 10);
552
553        // Should wrap after the 5th character (first 5 characters = 10 cells)
554        assert_eq!(wraps.len(), 1);
555        assert_eq!(wraps[0].char_index, 5);
556    }
557
558    #[test]
559    fn test_wrap_double_width_char() {
560        // Viewport has 1 cell remaining, next is a double-width character
561        // "Hello" = 5 cells, "你" = 2 cells, viewport width = 6
562        let text = "Hello你";
563        let wraps = calculate_wrap_points(text, 6);
564
565        // "Hello" takes 5 cells, "你" needs 2 cells but only 1 remains
566        // So "你" should wrap intact to the next line
567        assert_eq!(wraps.len(), 1);
568        assert_eq!(wraps[0].char_index, 5); // Wrap before "你"
569    }
570
571    #[test]
572    fn test_visual_line_info() {
573        let info = VisualLineInfo::from_text("1234567890abc", 10);
574        assert_eq!(info.visual_line_count, 2); // Needs 2 visual lines
575        assert_eq!(info.wrap_points.len(), 1);
576    }
577
578    #[test]
579    fn test_layout_engine_basic() {
580        let mut engine = LayoutEngine::new(10);
581        engine.add_line("hello");
582        engine.add_line("1234567890abc");
583
584        assert_eq!(engine.logical_line_count(), 2);
585        assert_eq!(engine.visual_line_count(), 3); // 1 + 2
586    }
587
588    #[test]
589    fn test_layout_engine_viewport_change() {
590        let mut engine = LayoutEngine::new(20);
591        engine.from_lines(&["hello world", "rust programming"]);
592
593        let initial_visual = engine.visual_line_count();
594        assert_eq!(initial_visual, 2); // Both lines don't need wrapping
595
596        // Reduce viewport width
597        engine.set_viewport_width(5);
598        // Note: Due to our implementation, need to reset lines
599        engine.from_lines(&["hello world", "rust programming"]);
600
601        let new_visual = engine.visual_line_count();
602        assert!(new_visual > initial_visual); // Should have more visual lines
603    }
604
605    #[test]
606    fn test_logical_to_visual() {
607        let mut engine = LayoutEngine::new(10);
608        engine.from_lines(&["12345", "1234567890abc", "hello"]);
609
610        // Line 0 ("12345") doesn't wrap, starts at visual line 0
611        assert_eq!(engine.logical_to_visual_line(0), 0);
612
613        // Line 1 ("1234567890abc") needs wrapping, starts at visual line 1
614        assert_eq!(engine.logical_to_visual_line(1), 1);
615
616        // Line 2 ("hello") starts at visual line 3 (0 + 1 + 2)
617        assert_eq!(engine.logical_to_visual_line(2), 3);
618    }
619
620    #[test]
621    fn test_visual_to_logical() {
622        let mut engine = LayoutEngine::new(10);
623        engine.from_lines(&["12345", "1234567890abc", "hello"]);
624
625        // Visual line 0 -> logical line 0
626        assert_eq!(engine.visual_to_logical_line(0), (0, 0));
627
628        // Visual line 1 -> logical line 1's 0th visual line
629        assert_eq!(engine.visual_to_logical_line(1), (1, 0));
630
631        // Visual line 2 -> logical line 1's 1st visual line
632        assert_eq!(engine.visual_to_logical_line(2), (1, 1));
633
634        // Visual line 3 -> logical line 2
635        assert_eq!(engine.visual_to_logical_line(3), (2, 0));
636    }
637}