Skip to main content

neco_editor_viewport/
lib.rs

1//! Viewport geometry calculations for editor rendering.
2//!
3//! Pure geometry: converts between byte offsets, logical/visual lines,
4//! and pixel coordinates. No DOM or Canvas dependency.
5
6use neco_textview::{LineIndex, Selection, TextViewError};
7use neco_wrap::{
8    LayoutMode, LineLayoutPolicy, VisualLayoutSpace, WidthPolicy, WrapMap, WrapPolicy,
9};
10use std::fmt;
11
12/// Host-injected font metrics. Plain struct (parameter bag).
13#[derive(Debug, Clone, Copy)]
14pub struct ViewportMetrics {
15    pub line_height: f64,
16    pub char_width: f64,
17    pub cjk_char_width: f64,
18    pub tab_width: u32,
19}
20
21/// Computed layout measurements.
22#[derive(Debug, Clone, Copy)]
23pub struct ViewportLayout {
24    pub gutter_width: f64,
25    pub content_left: f64,
26}
27
28/// Axis-aligned rectangle in pixel coordinates.
29#[derive(Debug, Clone, Copy)]
30pub struct Rect {
31    pub x: f64,
32    pub y: f64,
33    pub width: f64,
34    pub height: f64,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct VisualLineFrame {
39    layout: VisualLayoutSpace,
40}
41
42impl VisualLineFrame {
43    pub const fn logical_line(&self) -> u32 {
44        self.layout.logical_line()
45    }
46
47    pub const fn visual_line(&self) -> u32 {
48        self.layout.visual_line()
49    }
50
51    pub const fn inline_advance(&self) -> u32 {
52        self.layout.inline_advance()
53    }
54
55    pub const fn block_advance(&self) -> u32 {
56        self.layout.block_advance()
57    }
58
59    pub const fn layout_mode(&self) -> LayoutMode {
60        self.layout.layout_mode()
61    }
62}
63
64/// Errors returned by viewport operations.
65#[non_exhaustive]
66#[derive(Debug)]
67pub enum ViewportError {
68    TextView(TextViewError),
69}
70
71impl fmt::Display for ViewportError {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            Self::TextView(e) => write!(f, "text view error: {e}"),
75        }
76    }
77}
78
79impl std::error::Error for ViewportError {
80    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
81        match self {
82            Self::TextView(e) => Some(e),
83        }
84    }
85}
86
87impl From<TextViewError> for ViewportError {
88    fn from(e: TextViewError) -> Self {
89        Self::TextView(e)
90    }
91}
92
93// ---------------------------------------------------------------------------
94// Helper
95// ---------------------------------------------------------------------------
96
97/// Compute the visual width in pixels of `text[start_byte..end_byte]` considering tabs.
98fn text_width(
99    text: &str,
100    start_byte: usize,
101    end_byte: usize,
102    metrics: &ViewportMetrics,
103    width_policy: &WidthPolicy,
104) -> f64 {
105    let slice = &text[start_byte..end_byte];
106    slice
107        .chars()
108        .map(|ch| char_pixel_width(ch, metrics, width_policy))
109        .sum()
110}
111
112fn char_pixel_width(ch: char, metrics: &ViewportMetrics, width_policy: &WidthPolicy) -> f64 {
113    let advance = width_policy.advance_of(ch);
114    if ch == '\t' {
115        f64::from(advance) * metrics.char_width
116    } else if advance >= 2 {
117        metrics.cjk_char_width
118    } else {
119        metrics.char_width
120    }
121}
122
123fn u32_to_usize(v: u32) -> usize {
124    usize::try_from(v).expect("u32 exceeds usize::MAX")
125}
126
127// ---------------------------------------------------------------------------
128// Public functions
129// ---------------------------------------------------------------------------
130
131/// Returns `(first_visual_line, last_visual_line)` visible in the viewport.
132///
133/// Clamps to `0..total_visual_lines - 1`.
134pub fn visible_line_range(
135    scroll_top: f64,
136    container_height: f64,
137    wrap_map: &WrapMap,
138    metrics: &ViewportMetrics,
139) -> (u32, u32) {
140    let total = wrap_map.total_visual_lines();
141    if total == 0 {
142        return (0, 0);
143    }
144    let max_line = total - 1;
145
146    let first_f = scroll_top / metrics.line_height;
147    let first = if first_f < 0.0 {
148        0u32
149    } else {
150        let v = first_f as u64;
151        u32::try_from(v.min(u64::from(max_line))).expect("clamped value fits u32")
152    };
153
154    let last_f = (scroll_top + container_height) / metrics.line_height;
155    let last_raw = if last_f < 0.0 {
156        0u64
157    } else {
158        // Subtract 1 because the range is inclusive; if exactly on a boundary
159        // the pixel row above is the last visible line. Use ceil-1 approach:
160        // any fractional part means we can see part of that line.
161        let ceil = last_f.ceil() as u64;
162        if ceil == 0 {
163            0
164        } else {
165            ceil - 1
166        }
167    };
168    let last = u32::try_from(last_raw.min(u64::from(max_line))).expect("clamped value fits u32");
169
170    (first, last)
171}
172
173/// Compute the pixel rectangle for the caret at `offset`.
174pub fn caret_rect(
175    text: &str,
176    offset: usize,
177    line_index: &LineIndex,
178    wrap_map: &WrapMap,
179    metrics: &ViewportMetrics,
180    layout: &ViewportLayout,
181) -> Result<Rect, ViewportError> {
182    caret_rect_with_width_policy(
183        text,
184        offset,
185        line_index,
186        wrap_map,
187        metrics,
188        layout,
189        &WidthPolicy::cjk_grid(metrics.tab_width),
190    )
191}
192
193pub fn caret_rect_with_width_policy(
194    text: &str,
195    offset: usize,
196    line_index: &LineIndex,
197    wrap_map: &WrapMap,
198    metrics: &ViewportMetrics,
199    layout: &ViewportLayout,
200    width_policy: &WidthPolicy,
201) -> Result<Rect, ViewportError> {
202    let line = line_index.line_of_offset(offset)?;
203    let line_range = line_index.line_range(line)?;
204    let byte_in_line = offset - line_range.start();
205    let byte_in_line_u32 =
206        u32::try_from(byte_in_line).expect("byte offset in line exceeds u32::MAX");
207
208    let visual_line = wrap_map.to_visual_line(line, byte_in_line_u32);
209
210    // Determine the start byte (within the line) of this visual sub-line.
211    let (_, vl_start_in_line) = wrap_map.from_visual_line(visual_line);
212    let vl_start_abs = line_range.start() + u32_to_usize(vl_start_in_line);
213
214    let x = layout.content_left + text_width(text, vl_start_abs, offset, metrics, width_policy);
215    let y = f64::from(visual_line) * metrics.line_height;
216
217    Ok(Rect {
218        x,
219        y,
220        width: 2.0,
221        height: metrics.line_height,
222    })
223}
224
225/// Compute the pixel rectangles that cover `selection`.
226pub fn selection_rects(
227    text: &str,
228    selection: &Selection,
229    line_index: &LineIndex,
230    wrap_map: &WrapMap,
231    metrics: &ViewportMetrics,
232    layout: &ViewportLayout,
233) -> Result<Vec<Rect>, ViewportError> {
234    selection_rects_with_width_policy(
235        text,
236        selection,
237        line_index,
238        wrap_map,
239        metrics,
240        layout,
241        &WidthPolicy::cjk_grid(metrics.tab_width),
242    )
243}
244
245pub fn selection_rects_with_width_policy(
246    text: &str,
247    selection: &Selection,
248    line_index: &LineIndex,
249    wrap_map: &WrapMap,
250    metrics: &ViewportMetrics,
251    layout: &ViewportLayout,
252    width_policy: &WidthPolicy,
253) -> Result<Vec<Rect>, ViewportError> {
254    let range = selection.range();
255    if range.is_empty() {
256        return Ok(Vec::new());
257    }
258
259    let start_offset = range.start();
260    let end_offset = range.end();
261
262    let start_line = line_index.line_of_offset(start_offset)?;
263    let start_line_range = line_index.line_range(start_line)?;
264    let start_byte_in_line = start_offset - start_line_range.start();
265    let start_byte_u32 =
266        u32::try_from(start_byte_in_line).expect("byte offset in line exceeds u32::MAX");
267    let first_vl = wrap_map.to_visual_line(start_line, start_byte_u32);
268
269    let end_line = line_index.line_of_offset(end_offset)?;
270    let end_line_range = line_index.line_range(end_line)?;
271    let end_byte_in_line = end_offset - end_line_range.start();
272    let end_byte_u32 =
273        u32::try_from(end_byte_in_line).expect("byte offset in line exceeds u32::MAX");
274    let last_vl = wrap_map.to_visual_line(end_line, end_byte_u32);
275
276    let mut rects = Vec::new();
277
278    for vl in first_vl..=last_vl {
279        let (log_line, vl_start_in_line) = wrap_map.from_visual_line(vl);
280        let lr = line_index.line_range(log_line)?;
281        let vl_start_abs = lr.start() + u32_to_usize(vl_start_in_line);
282
283        // Determine end of this visual line.
284        let total_vl = wrap_map.total_visual_lines();
285        let vl_end_abs = if vl + 1 < total_vl {
286            let (next_log, next_start_in_line) = wrap_map.from_visual_line(vl + 1);
287            if next_log == log_line {
288                lr.start() + u32_to_usize(next_start_in_line)
289            } else {
290                lr.end()
291            }
292        } else {
293            lr.end()
294        };
295
296        // Clamp selection to this visual line.
297        let sel_start = start_offset.max(vl_start_abs);
298        let sel_end = end_offset.min(vl_end_abs);
299
300        if sel_start >= sel_end {
301            continue;
302        }
303
304        let x =
305            layout.content_left + text_width(text, vl_start_abs, sel_start, metrics, width_policy);
306        let w = text_width(text, sel_start, sel_end, metrics, width_policy);
307        let y = f64::from(vl) * metrics.line_height;
308
309        rects.push(Rect {
310            x,
311            y,
312            width: w,
313            height: metrics.line_height,
314        });
315    }
316
317    Ok(rects)
318}
319
320/// Convert a click at pixel `(x, y)` to a byte offset in `text`.
321#[allow(clippy::too_many_arguments)]
322pub fn hit_test(
323    x: f64,
324    y: f64,
325    scroll_top: f64,
326    text: &str,
327    line_index: &LineIndex,
328    wrap_map: &WrapMap,
329    metrics: &ViewportMetrics,
330    layout: &ViewportLayout,
331) -> usize {
332    hit_test_with_width_policy(
333        x,
334        y,
335        scroll_top,
336        text,
337        line_index,
338        wrap_map,
339        metrics,
340        layout,
341        &WidthPolicy::cjk_grid(metrics.tab_width),
342    )
343}
344
345#[allow(clippy::too_many_arguments)]
346pub fn hit_test_with_width_policy(
347    x: f64,
348    y: f64,
349    scroll_top: f64,
350    text: &str,
351    line_index: &LineIndex,
352    wrap_map: &WrapMap,
353    metrics: &ViewportMetrics,
354    layout: &ViewportLayout,
355    width_policy: &WidthPolicy,
356) -> usize {
357    let total_vl = wrap_map.total_visual_lines();
358    if total_vl == 0 {
359        return 0;
360    }
361
362    // Determine visual line from y coordinate.
363    let vl_f = (y + scroll_top) / metrics.line_height;
364    let vl_raw = if vl_f < 0.0 { 0u64 } else { vl_f as u64 };
365    let vl = u32::try_from(vl_raw.min(u64::from(total_vl - 1))).expect("clamped value fits u32");
366
367    let (log_line, vl_start_in_line) = wrap_map.from_visual_line(vl);
368    let lr = match line_index.line_range(log_line) {
369        Ok(r) => r,
370        Err(_) => return text.len(),
371    };
372    let vl_start_abs = lr.start() + u32_to_usize(vl_start_in_line);
373
374    // Determine end of this visual line.
375    let vl_end_abs = if vl + 1 < total_vl {
376        let (next_log, next_start_in_line) = wrap_map.from_visual_line(vl + 1);
377        if next_log == log_line {
378            lr.start() + u32_to_usize(next_start_in_line)
379        } else {
380            lr.end()
381        }
382    } else {
383        lr.end()
384    };
385
386    // x relative to content area.
387    let rel_x = (x - layout.content_left).max(0.0);
388
389    // Walk characters to find the offset.
390    let slice = &text[vl_start_abs..vl_end_abs];
391    let mut accum = 0.0;
392    for (i, ch) in slice.char_indices() {
393        let cw = char_pixel_width(ch, metrics, width_policy);
394        // If click is within the first half of the character, place caret before it.
395        if rel_x < accum + cw * 0.5 {
396            return vl_start_abs + i;
397        }
398        accum += cw;
399    }
400
401    // Past end of visual line: clamp to end.
402    vl_end_abs
403}
404
405/// Compute the line-number gutter width for `total_lines` lines.
406pub fn gutter_width(total_lines: u32, metrics: &ViewportMetrics) -> f64 {
407    let digit_count = total_lines.max(1).ilog10() + 1;
408    f64::from(digit_count) * metrics.char_width + metrics.char_width
409}
410
411/// Return the Y pixel coordinate for the top of `visual_line`.
412pub fn line_top(visual_line: u32, metrics: &ViewportMetrics) -> f64 {
413    f64::from(visual_line) * metrics.line_height
414}
415
416#[allow(clippy::too_many_arguments)]
417pub fn visual_line_frame(
418    text: &str,
419    visual_line: u32,
420    line_index: &LineIndex,
421    wrap_map: &WrapMap,
422    _metrics: &ViewportMetrics,
423    _layout: &ViewportLayout,
424    width_policy: &WidthPolicy,
425    line_layout_policy: &LineLayoutPolicy,
426) -> Result<VisualLineFrame, ViewportError> {
427    let (logical_line, vl_start_in_line) = wrap_map.from_visual_line(visual_line);
428    let line_range = line_index.line_range(logical_line)?;
429    let total_vl = wrap_map.total_visual_lines();
430    let vl_end_in_line = if visual_line + 1 < total_vl {
431        let (next_logical_line, next_start_in_line) = wrap_map.from_visual_line(visual_line + 1);
432        if next_logical_line == logical_line {
433            next_start_in_line
434        } else {
435            u32::try_from(line_range.end() - line_range.start()).expect("line length fits u32")
436        }
437    } else {
438        u32::try_from(line_range.end() - line_range.start()).expect("line length fits u32")
439    };
440    let line_text = &text[line_range.start()..line_range.end()];
441    let local_visual_line = visual_line - wrap_map.to_visual_line(logical_line, 0);
442    let wrap_policy = WrapPolicy::code_with_width_policy(*width_policy);
443    let visual_layout = wrap_map.visual_layout_space(
444        logical_line,
445        local_visual_line,
446        line_text,
447        &wrap_policy,
448        line_layout_policy,
449    );
450    debug_assert_eq!(
451        visual_layout.inline_advance(),
452        line_layout_policy.redistributed_inline_width(
453            width_policy.text_width(
454                &line_text[u32_to_usize(vl_start_in_line)..u32_to_usize(vl_end_in_line)]
455            ),
456            wrap_map.max_width(),
457        )
458    );
459    Ok(VisualLineFrame {
460        layout: visual_layout,
461    })
462}
463
464/// Compute a new `scroll_top` that reveals the caret at `offset`, or `None` if already visible.
465pub fn scroll_to_reveal(
466    _text: &str,
467    offset: usize,
468    scroll_top: f64,
469    container_height: f64,
470    line_index: &LineIndex,
471    wrap_map: &WrapMap,
472    metrics: &ViewportMetrics,
473) -> Result<Option<f64>, ViewportError> {
474    let line = line_index.line_of_offset(offset)?;
475    let line_range = line_index.line_range(line)?;
476    let byte_in_line = offset - line_range.start();
477    let byte_in_line_u32 =
478        u32::try_from(byte_in_line).expect("byte offset in line exceeds u32::MAX");
479    let vl = wrap_map.to_visual_line(line, byte_in_line_u32);
480
481    let top = f64::from(vl) * metrics.line_height;
482    let bottom = top + metrics.line_height;
483
484    if top < scroll_top {
485        // Caret is above the viewport.
486        Ok(Some(top))
487    } else if bottom > scroll_top + container_height {
488        // Caret is below the viewport.
489        Ok(Some(bottom - container_height))
490    } else {
491        Ok(None)
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use neco_wrap::{LineLayoutPolicy, WidthPolicy, WrapMap, WrapPolicy};
499
500    fn default_metrics() -> ViewportMetrics {
501        ViewportMetrics {
502            line_height: 20.0,
503            char_width: 8.0,
504            cjk_char_width: 14.0,
505            tab_width: 4,
506        }
507    }
508
509    fn default_width_policy() -> WidthPolicy {
510        WidthPolicy::cjk_grid(4)
511    }
512
513    fn default_line_layout_policy() -> LineLayoutPolicy {
514        LineLayoutPolicy::horizontal_ltr()
515    }
516
517    fn default_layout() -> ViewportLayout {
518        ViewportLayout {
519            gutter_width: 40.0,
520            content_left: 48.0,
521        }
522    }
523
524    fn make_wrap_map(text: &str, max_width: u32) -> WrapMap {
525        let lines: Vec<&str> = text.split('\n').collect();
526        WrapMap::new(lines.iter().copied(), max_width, &WrapPolicy::code())
527    }
528
529    // -----------------------------------------------------------------------
530    // visible_line_range
531    // -----------------------------------------------------------------------
532
533    #[test]
534    fn visible_line_range_single_line() {
535        let text = "hello";
536        let wm = make_wrap_map(text, 80);
537        let metrics = default_metrics();
538        let (first, last) = visible_line_range(0.0, 100.0, &wm, &metrics);
539        assert_eq!(first, 0);
540        assert_eq!(last, 0);
541    }
542
543    #[test]
544    fn visible_line_range_multi_line() {
545        let text = "aaa\nbbb\nccc\nddd\neee";
546        let wm = make_wrap_map(text, 80);
547        let metrics = default_metrics();
548        // Container shows 3 lines (60px / 20px = 3 lines).
549        let (first, last) = visible_line_range(0.0, 60.0, &wm, &metrics);
550        assert_eq!(first, 0);
551        assert_eq!(last, 2);
552    }
553
554    #[test]
555    fn visible_line_range_scrolled() {
556        let text = "aaa\nbbb\nccc\nddd\neee";
557        let wm = make_wrap_map(text, 80);
558        let metrics = default_metrics();
559        // scroll_top=40 means first visible is line 2.
560        let (first, last) = visible_line_range(40.0, 40.0, &wm, &metrics);
561        assert_eq!(first, 2);
562        assert_eq!(last, 3);
563    }
564
565    #[test]
566    fn visible_line_range_with_wrapping() {
567        // "ab cd ef" with max_width=4 wraps into 3 visual lines.
568        let text = "ab cd ef";
569        let wm = make_wrap_map(text, 4);
570        let metrics = default_metrics();
571        let total = wm.total_visual_lines();
572        assert_eq!(total, 3);
573        let (first, last) = visible_line_range(0.0, 60.0, &wm, &metrics);
574        assert_eq!(first, 0);
575        assert_eq!(last, 2);
576    }
577
578    #[test]
579    fn visible_line_range_clamps_past_end() {
580        let text = "aaa\nbbb";
581        let wm = make_wrap_map(text, 80);
582        let metrics = default_metrics();
583        let (first, last) = visible_line_range(0.0, 1000.0, &wm, &metrics);
584        assert_eq!(first, 0);
585        assert_eq!(last, 1);
586    }
587
588    // -----------------------------------------------------------------------
589    // caret_rect
590    // -----------------------------------------------------------------------
591
592    #[test]
593    fn caret_rect_line_start() {
594        let text = "hello\nworld";
595        let li = LineIndex::new(text);
596        let wm = make_wrap_map(text, 80);
597        let metrics = default_metrics();
598        let layout = default_layout();
599        let width_policy = default_width_policy();
600
601        let r = caret_rect_with_width_policy(text, 0, &li, &wm, &metrics, &layout, &width_policy)
602            .unwrap();
603        assert!((r.x - layout.content_left).abs() < f64::EPSILON);
604        assert!((r.y - 0.0).abs() < f64::EPSILON);
605        assert!((r.height - 20.0).abs() < f64::EPSILON);
606    }
607
608    #[test]
609    fn caret_rect_line_middle() {
610        let text = "hello\nworld";
611        let li = LineIndex::new(text);
612        let wm = make_wrap_map(text, 80);
613        let metrics = default_metrics();
614        let layout = default_layout();
615        let width_policy = default_width_policy();
616
617        // offset 3 = 'l' in "hello", x = content_left + 3 * char_width
618        let r = caret_rect_with_width_policy(text, 3, &li, &wm, &metrics, &layout, &width_policy)
619            .unwrap();
620        let expected_x = layout.content_left + 3.0 * metrics.char_width;
621        assert!((r.x - expected_x).abs() < f64::EPSILON);
622        assert!((r.y - 0.0).abs() < f64::EPSILON);
623    }
624
625    #[test]
626    fn caret_rect_second_line() {
627        let text = "hello\nworld";
628        let li = LineIndex::new(text);
629        let wm = make_wrap_map(text, 80);
630        let metrics = default_metrics();
631        let layout = default_layout();
632        let width_policy = default_width_policy();
633
634        // offset 6 = start of "world", visual line 1.
635        let r = caret_rect_with_width_policy(text, 6, &li, &wm, &metrics, &layout, &width_policy)
636            .unwrap();
637        assert!((r.x - layout.content_left).abs() < f64::EPSILON);
638        assert!((r.y - 20.0).abs() < f64::EPSILON);
639    }
640
641    #[test]
642    fn caret_rect_wrapped_line() {
643        // "ab cd ef" wraps at width 4 into 3 visual lines:
644        // vl0: "ab " (bytes 0..3), vl1: "cd " (bytes 3..6), vl2: "ef" (bytes 6..8)
645        let text = "ab cd ef";
646        let li = LineIndex::new(text);
647        let wm = make_wrap_map(text, 4);
648        let metrics = default_metrics();
649        let layout = default_layout();
650        let width_policy = default_width_policy();
651
652        // offset 4 = 'd' in "cd ", which is on visual line 1, column 1.
653        let r = caret_rect_with_width_policy(text, 4, &li, &wm, &metrics, &layout, &width_policy)
654            .unwrap();
655        let expected_x = layout.content_left + 1.0 * metrics.char_width;
656        assert!((r.x - expected_x).abs() < f64::EPSILON);
657        assert!((r.y - 20.0).abs() < f64::EPSILON);
658    }
659
660    // -----------------------------------------------------------------------
661    // hit_test
662    // -----------------------------------------------------------------------
663
664    #[test]
665    fn hit_test_basic() {
666        let text = "hello\nworld";
667        let li = LineIndex::new(text);
668        let wm = make_wrap_map(text, 80);
669        let metrics = default_metrics();
670        let layout = default_layout();
671        let width_policy = default_width_policy();
672
673        // Click at start of first line.
674        let offset = hit_test_with_width_policy(
675            layout.content_left,
676            0.0,
677            0.0,
678            text,
679            &li,
680            &wm,
681            &metrics,
682            &layout,
683            &width_policy,
684        );
685        assert_eq!(offset, 0);
686    }
687
688    #[test]
689    fn hit_test_middle_of_line() {
690        let text = "hello";
691        let li = LineIndex::new(text);
692        let wm = make_wrap_map(text, 80);
693        let metrics = default_metrics();
694        let layout = default_layout();
695        let width_policy = default_width_policy();
696
697        // Click at x = content_left + 2.5 * char_width -> offset 3 (past midpoint of char 2).
698        let x = layout.content_left + 2.5 * metrics.char_width;
699        let offset = hit_test_with_width_policy(
700            x,
701            0.0,
702            0.0,
703            text,
704            &li,
705            &wm,
706            &metrics,
707            &layout,
708            &width_policy,
709        );
710        assert_eq!(offset, 3);
711    }
712
713    #[test]
714    fn hit_test_gutter_area_clamps_to_line_start() {
715        let text = "hello\nworld";
716        let li = LineIndex::new(text);
717        let wm = make_wrap_map(text, 80);
718        let metrics = default_metrics();
719        let layout = default_layout();
720        let width_policy = default_width_policy();
721
722        // Click in gutter (x=0), second line (y=25).
723        let offset = hit_test_with_width_policy(
724            0.0,
725            25.0,
726            0.0,
727            text,
728            &li,
729            &wm,
730            &metrics,
731            &layout,
732            &width_policy,
733        );
734        assert_eq!(offset, 6); // start of "world"
735    }
736
737    #[test]
738    fn hit_test_wrapped_line() {
739        let text = "ab cd ef";
740        let li = LineIndex::new(text);
741        let wm = make_wrap_map(text, 4);
742        let metrics = default_metrics();
743        let layout = default_layout();
744        let width_policy = default_width_policy();
745
746        // Click on visual line 2 (y=40..60), at content_left -> offset 6 ("ef").
747        let offset = hit_test_with_width_policy(
748            layout.content_left,
749            45.0,
750            0.0,
751            text,
752            &li,
753            &wm,
754            &metrics,
755            &layout,
756            &width_policy,
757        );
758        assert_eq!(offset, 6);
759    }
760
761    #[test]
762    fn hit_test_past_end_of_line() {
763        let text = "hi";
764        let li = LineIndex::new(text);
765        let wm = make_wrap_map(text, 80);
766        let metrics = default_metrics();
767        let layout = default_layout();
768        let width_policy = default_width_policy();
769
770        // Click far to the right.
771        let offset = hit_test_with_width_policy(
772            layout.content_left + 500.0,
773            0.0,
774            0.0,
775            text,
776            &li,
777            &wm,
778            &metrics,
779            &layout,
780            &width_policy,
781        );
782        assert_eq!(offset, 2);
783    }
784
785    // -----------------------------------------------------------------------
786    // gutter_width
787    // -----------------------------------------------------------------------
788
789    #[test]
790    fn gutter_width_1_line() {
791        let metrics = default_metrics();
792        let w = gutter_width(1, &metrics);
793        // 1 digit + 1 padding = 2 * 8 = 16
794        assert!((w - 16.0).abs() < f64::EPSILON);
795    }
796
797    #[test]
798    fn gutter_width_10_lines() {
799        let metrics = default_metrics();
800        let w = gutter_width(10, &metrics);
801        // 2 digits + 1 padding = 3 * 8 = 24
802        assert!((w - 24.0).abs() < f64::EPSILON);
803    }
804
805    #[test]
806    fn gutter_width_100_lines() {
807        let metrics = default_metrics();
808        let w = gutter_width(100, &metrics);
809        // 3 digits + 1 padding = 4 * 8 = 32
810        assert!((w - 32.0).abs() < f64::EPSILON);
811    }
812
813    #[test]
814    fn gutter_width_1000_lines() {
815        let metrics = default_metrics();
816        let w = gutter_width(1000, &metrics);
817        // 4 digits + 1 padding = 5 * 8 = 40
818        assert!((w - 40.0).abs() < f64::EPSILON);
819    }
820
821    // -----------------------------------------------------------------------
822    // line_top
823    // -----------------------------------------------------------------------
824
825    #[test]
826    fn line_top_zero() {
827        let metrics = default_metrics();
828        assert!((line_top(0, &metrics) - 0.0).abs() < f64::EPSILON);
829    }
830
831    #[test]
832    fn line_top_five() {
833        let metrics = default_metrics();
834        assert!((line_top(5, &metrics) - 100.0).abs() < f64::EPSILON);
835    }
836
837    #[test]
838    fn line_top_ten() {
839        let metrics = default_metrics();
840        assert!((line_top(10, &metrics) - 200.0).abs() < f64::EPSILON);
841    }
842
843    // -----------------------------------------------------------------------
844    // scroll_to_reveal
845    // -----------------------------------------------------------------------
846
847    #[test]
848    fn scroll_to_reveal_already_visible() {
849        let text = "aaa\nbbb\nccc\nddd\neee";
850        let li = LineIndex::new(text);
851        let wm = make_wrap_map(text, 80);
852        let metrics = default_metrics();
853
854        // Offset 4 is line 1, visual line 1. scroll_top=0, container=100.
855        let result = scroll_to_reveal(text, 4, 0.0, 100.0, &li, &wm, &metrics).unwrap();
856        assert!(result.is_none());
857    }
858
859    #[test]
860    fn scroll_to_reveal_above_viewport() {
861        let text = "aaa\nbbb\nccc\nddd\neee";
862        let li = LineIndex::new(text);
863        let wm = make_wrap_map(text, 80);
864        let metrics = default_metrics();
865
866        // Offset 0 is visual line 0 (top=0). scroll_top=40 means it is above.
867        let result = scroll_to_reveal(text, 0, 40.0, 40.0, &li, &wm, &metrics).unwrap();
868        assert_eq!(result, Some(0.0));
869    }
870
871    #[test]
872    fn scroll_to_reveal_below_viewport() {
873        let text = "aaa\nbbb\nccc\nddd\neee";
874        let li = LineIndex::new(text);
875        let wm = make_wrap_map(text, 80);
876        let metrics = default_metrics();
877
878        // Offset 16 is line 4, visual line 4 (top=80, bottom=100).
879        // scroll_top=0, container=40 -> viewport covers 0..40. Line 4 is below.
880        let result = scroll_to_reveal(text, 16, 0.0, 40.0, &li, &wm, &metrics).unwrap();
881        // new scroll_top = bottom - container_height = 100 - 40 = 60
882        assert_eq!(result, Some(60.0));
883    }
884
885    // -----------------------------------------------------------------------
886    // selection_rects
887    // -----------------------------------------------------------------------
888
889    #[test]
890    fn selection_rects_empty_selection() {
891        let text = "hello";
892        let li = LineIndex::new(text);
893        let wm = make_wrap_map(text, 80);
894        let metrics = default_metrics();
895        let layout = default_layout();
896        let width_policy = default_width_policy();
897
898        let sel = Selection::cursor(2);
899        let rects = selection_rects_with_width_policy(
900            text,
901            &sel,
902            &li,
903            &wm,
904            &metrics,
905            &layout,
906            &width_policy,
907        )
908        .unwrap();
909        assert!(rects.is_empty());
910    }
911
912    #[test]
913    fn selection_rects_single_line() {
914        let text = "hello";
915        let li = LineIndex::new(text);
916        let wm = make_wrap_map(text, 80);
917        let metrics = default_metrics();
918        let layout = default_layout();
919        let width_policy = default_width_policy();
920
921        let sel = Selection::new(1, 4); // "ell"
922        let rects = selection_rects_with_width_policy(
923            text,
924            &sel,
925            &li,
926            &wm,
927            &metrics,
928            &layout,
929            &width_policy,
930        )
931        .unwrap();
932        assert_eq!(rects.len(), 1);
933        let r = &rects[0];
934        let expected_x = layout.content_left + 1.0 * metrics.char_width;
935        assert!((r.x - expected_x).abs() < f64::EPSILON);
936        assert!((r.width - 3.0 * metrics.char_width).abs() < f64::EPSILON);
937    }
938
939    #[test]
940    fn selection_rects_multi_line() {
941        let text = "aaa\nbbb\nccc";
942        let li = LineIndex::new(text);
943        let wm = make_wrap_map(text, 80);
944        let metrics = default_metrics();
945        let layout = default_layout();
946        let width_policy = default_width_policy();
947
948        // Select from middle of line 0 to middle of line 2.
949        let sel = Selection::new(1, 9); // "aa\nbbb\nc"
950        let rects = selection_rects_with_width_policy(
951            text,
952            &sel,
953            &li,
954            &wm,
955            &metrics,
956            &layout,
957            &width_policy,
958        )
959        .unwrap();
960        assert_eq!(rects.len(), 3);
961    }
962
963    // -----------------------------------------------------------------------
964    // roundtrip: caret_rect -> hit_test
965    // -----------------------------------------------------------------------
966
967    #[test]
968    fn roundtrip_caret_hit_test() {
969        let text = "hello\nworld";
970        let li = LineIndex::new(text);
971        let wm = make_wrap_map(text, 80);
972        let metrics = default_metrics();
973        let layout = default_layout();
974        let width_policy = default_width_policy();
975
976        for offset in [0, 3, 5, 6, 9, 11] {
977            let r = caret_rect_with_width_policy(
978                text,
979                offset,
980                &li,
981                &wm,
982                &metrics,
983                &layout,
984                &width_policy,
985            )
986            .unwrap();
987            // Hit test at the caret position should return the same offset.
988            let got = hit_test_with_width_policy(
989                r.x,
990                r.y,
991                0.0,
992                text,
993                &li,
994                &wm,
995                &metrics,
996                &layout,
997                &width_policy,
998            );
999            assert_eq!(got, offset, "roundtrip failed at offset {offset}");
1000        }
1001    }
1002
1003    #[test]
1004    fn roundtrip_caret_hit_test_wrapped() {
1005        let text = "ab cd ef";
1006        let li = LineIndex::new(text);
1007        let wm = make_wrap_map(text, 4);
1008        let metrics = default_metrics();
1009        let layout = default_layout();
1010        let width_policy = default_width_policy();
1011
1012        for offset in [0, 3, 6] {
1013            let r = caret_rect_with_width_policy(
1014                text,
1015                offset,
1016                &li,
1017                &wm,
1018                &metrics,
1019                &layout,
1020                &width_policy,
1021            )
1022            .unwrap();
1023            let got = hit_test_with_width_policy(
1024                r.x,
1025                r.y,
1026                0.0,
1027                text,
1028                &li,
1029                &wm,
1030                &metrics,
1031                &layout,
1032                &width_policy,
1033            );
1034            assert_eq!(got, offset, "roundtrip failed at offset {offset}");
1035        }
1036    }
1037
1038    // -----------------------------------------------------------------------
1039    // text_width helper
1040    // -----------------------------------------------------------------------
1041
1042    #[test]
1043    fn text_width_with_tabs() {
1044        let metrics = default_metrics();
1045        let width_policy = default_width_policy();
1046        let text = "a\tb";
1047        // 'a' = 8, '\t' = 4*8=32, 'b' = 8 -> total 48
1048        let w = text_width(text, 0, text.len(), &metrics, &width_policy);
1049        assert!((w - 48.0).abs() < f64::EPSILON);
1050    }
1051
1052    #[test]
1053    fn text_width_empty() {
1054        let metrics = default_metrics();
1055        let width_policy = default_width_policy();
1056        let w = text_width("hello", 2, 2, &metrics, &width_policy);
1057        assert!((w - 0.0).abs() < f64::EPSILON);
1058    }
1059
1060    #[test]
1061    fn text_width_uses_measured_cjk_width_without_changing_tabs() {
1062        let metrics = default_metrics();
1063        let width_policy = default_width_policy();
1064        let text = "aあ\tb";
1065
1066        let w = text_width(text, 0, text.len(), &metrics, &width_policy);
1067
1068        assert!((w - 62.0).abs() < f64::EPSILON);
1069    }
1070
1071    #[test]
1072    fn caret_rect_uses_measured_cjk_width() {
1073        let text = "aあ";
1074        let li = LineIndex::new(text);
1075        let wm = make_wrap_map(text, 80);
1076        let metrics = default_metrics();
1077        let layout = default_layout();
1078        let width_policy = WidthPolicy::cjk_grid(4);
1079
1080        let r = caret_rect_with_width_policy(
1081            text,
1082            text.len(),
1083            &li,
1084            &wm,
1085            &metrics,
1086            &layout,
1087            &width_policy,
1088        )
1089        .unwrap();
1090
1091        let expected_x = layout.content_left + 22.0;
1092        assert!((r.x - expected_x).abs() < f64::EPSILON);
1093    }
1094
1095    #[test]
1096    fn hit_test_uses_measured_cjk_width() {
1097        let text = "aあb";
1098        let li = LineIndex::new(text);
1099        let wm = make_wrap_map(text, 80);
1100        let metrics = default_metrics();
1101        let layout = default_layout();
1102        let width_policy = WidthPolicy::cjk_grid(4);
1103
1104        let got = hit_test_with_width_policy(
1105            layout.content_left + 23.0,
1106            0.0,
1107            0.0,
1108            text,
1109            &li,
1110            &wm,
1111            &metrics,
1112            &layout,
1113            &width_policy,
1114        );
1115
1116        assert_eq!(got, "aあ".len());
1117    }
1118
1119    #[test]
1120    fn caret_rect_uses_measured_width_for_cjk_grid() {
1121        let text = "aあ";
1122        let li = LineIndex::new(text);
1123        let wm = make_wrap_map(text, 80);
1124        let metrics = default_metrics();
1125        let layout = default_layout();
1126        let width_policy = WidthPolicy::cjk_grid(4);
1127
1128        let r = caret_rect_with_width_policy(
1129            text,
1130            "aあ".len(),
1131            &li,
1132            &wm,
1133            &metrics,
1134            &layout,
1135            &width_policy,
1136        )
1137        .unwrap();
1138
1139        let expected_x = layout.content_left + metrics.char_width + metrics.cjk_char_width;
1140        assert!((r.x - expected_x).abs() < f64::EPSILON);
1141    }
1142
1143    #[test]
1144    fn visual_line_frame_exposes_visual_layout_space() {
1145        let text = "ab cd";
1146        let li = LineIndex::new(text);
1147        let wm = make_wrap_map(text, 3);
1148        let metrics = default_metrics();
1149        let layout = default_layout();
1150        let width_policy = default_width_policy();
1151
1152        let frame = visual_line_frame(
1153            text,
1154            1,
1155            &li,
1156            &wm,
1157            &metrics,
1158            &layout,
1159            &width_policy,
1160            &default_line_layout_policy(),
1161        )
1162        .unwrap();
1163
1164        assert_eq!(frame.logical_line(), 0);
1165        assert_eq!(frame.visual_line(), 1);
1166        assert_eq!(frame.inline_advance(), 2);
1167        assert_eq!(frame.block_advance(), 1);
1168        assert_eq!(frame.layout_mode(), LayoutMode::HorizontalLtr);
1169    }
1170
1171    #[test]
1172    fn visual_line_frame_uses_line_layout_policy_width_redistribution() {
1173        fn justify_to_max(_line_width: u32, max_width: u32) -> u32 {
1174            max_width
1175        }
1176
1177        let text = "ab";
1178        let li = LineIndex::new(text);
1179        let wm = make_wrap_map(text, 6);
1180        let metrics = default_metrics();
1181        let layout = default_layout();
1182        let width_policy = default_width_policy();
1183        let line_layout_policy = LineLayoutPolicy::new(LayoutMode::HorizontalLtr, justify_to_max);
1184
1185        let frame = visual_line_frame(
1186            text,
1187            0,
1188            &li,
1189            &wm,
1190            &metrics,
1191            &layout,
1192            &width_policy,
1193            &line_layout_policy,
1194        )
1195        .unwrap();
1196
1197        assert_eq!(frame.inline_advance(), 6);
1198        assert_eq!(frame.layout_mode(), LayoutMode::HorizontalLtr);
1199    }
1200}