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