Skip to main content

neco_wrap/
lib.rs

1//! Word wrap engine for splitting logical lines into visual lines.
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum BreakOpportunity {
5    Allowed,
6    Forbidden,
7    Mandatory,
8}
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum LayoutMode {
12    HorizontalLtr,
13    VerticalRl,
14    VerticalLr,
15}
16
17#[derive(Debug, Clone, Copy)]
18pub struct LineLayoutPolicy {
19    layout_mode: LayoutMode,
20    redistribute_inline_width: fn(u32, u32) -> u32,
21}
22
23impl LineLayoutPolicy {
24    pub fn new(layout_mode: LayoutMode, redistribute_inline_width: fn(u32, u32) -> u32) -> Self {
25        Self {
26            layout_mode,
27            redistribute_inline_width,
28        }
29    }
30
31    pub fn horizontal_ltr() -> Self {
32        Self::new(LayoutMode::HorizontalLtr, preserve_inline_width)
33    }
34
35    pub fn layout_mode(&self) -> LayoutMode {
36        self.layout_mode
37    }
38
39    pub fn redistributed_inline_width(&self, line_width: u32, max_width: u32) -> u32 {
40        (self.redistribute_inline_width)(line_width, max_width)
41    }
42}
43
44#[derive(Debug, Clone, Copy)]
45pub struct WidthPolicy {
46    char_width: fn(char) -> u32,
47    tab_width: Option<u32>,
48}
49
50impl WidthPolicy {
51    pub fn new(char_width: fn(char) -> u32) -> Self {
52        Self {
53            char_width,
54            tab_width: None,
55        }
56    }
57
58    pub fn monospace_ascii(tab_width: u32) -> Self {
59        Self::with_tab_width(monospace_ascii_width, tab_width)
60    }
61
62    pub fn cjk_grid(tab_width: u32) -> Self {
63        Self::with_tab_width(cjk_grid_width, tab_width)
64    }
65
66    pub fn with_tab_width(char_width: fn(char) -> u32, tab_width: u32) -> Self {
67        Self {
68            char_width,
69            tab_width: Some(tab_width),
70        }
71    }
72
73    pub fn tab_width(&self) -> Option<u32> {
74        self.tab_width
75    }
76
77    pub fn char_width(&self) -> fn(char) -> u32 {
78        self.char_width
79    }
80
81    pub fn advance_of(&self, ch: char) -> u32 {
82        if ch == '\t' {
83            self.tab_width.unwrap_or_else(|| (self.char_width)(ch))
84        } else {
85            (self.char_width)(ch)
86        }
87    }
88
89    pub fn text_width(&self, text: &str) -> u32 {
90        text.chars().map(|ch| self.advance_of(ch)).sum()
91    }
92}
93
94#[derive(Debug, Clone, Copy)]
95pub struct WrapPolicy {
96    width_policy: WidthPolicy,
97    break_opportunity: fn(&str, usize) -> BreakOpportunity,
98}
99
100impl WrapPolicy {
101    pub fn new(
102        char_width: fn(char) -> u32,
103        break_opportunity: fn(&str, usize) -> BreakOpportunity,
104    ) -> Self {
105        Self::with_width_policy(WidthPolicy::new(char_width), break_opportunity)
106    }
107
108    pub fn with_width_policy(
109        width_policy: WidthPolicy,
110        break_opportunity: fn(&str, usize) -> BreakOpportunity,
111    ) -> Self {
112        Self {
113            width_policy,
114            break_opportunity,
115        }
116    }
117
118    pub fn width_policy(&self) -> WidthPolicy {
119        self.width_policy
120    }
121
122    pub fn char_width(&self) -> fn(char) -> u32 {
123        self.width_policy.char_width()
124    }
125
126    pub fn break_opportunity(&self) -> fn(&str, usize) -> BreakOpportunity {
127        self.break_opportunity
128    }
129
130    pub fn code() -> Self {
131        Self::code_with_width_policy(WidthPolicy::cjk_grid(4))
132    }
133
134    pub fn code_with_width_policy(width_policy: WidthPolicy) -> Self {
135        Self::with_width_policy(width_policy, code_break_opportunity)
136    }
137
138    pub fn japanese_basic() -> Self {
139        Self::with_width_policy(WidthPolicy::cjk_grid(4), japanese_break_opportunity)
140    }
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub struct WrapPoint {
145    byte_offset: u32,
146    visual_width: u32,
147}
148
149impl WrapPoint {
150    pub const fn byte_offset(&self) -> u32 {
151        self.byte_offset
152    }
153
154    pub const fn visual_width(&self) -> u32 {
155        self.visual_width
156    }
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub struct VisualLine {
161    start: u32,
162    end: u32,
163}
164
165impl VisualLine {
166    pub const fn start(&self) -> u32 {
167        self.start
168    }
169
170    pub const fn end(&self) -> u32 {
171        self.end
172    }
173
174    pub const fn len(&self) -> u32 {
175        self.end - self.start
176    }
177
178    pub const fn is_empty(&self) -> bool {
179        self.start == self.end
180    }
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub struct VisualLayoutSpace {
185    logical_line: u32,
186    visual_line: u32,
187    inline_advance: u32,
188    block_advance: u32,
189    layout_mode: LayoutMode,
190}
191
192impl VisualLayoutSpace {
193    pub const fn logical_line(&self) -> u32 {
194        self.logical_line
195    }
196
197    pub const fn visual_line(&self) -> u32 {
198        self.visual_line
199    }
200
201    pub const fn inline_advance(&self) -> u32 {
202        self.inline_advance
203    }
204
205    pub const fn block_advance(&self) -> u32 {
206        self.block_advance
207    }
208
209    pub const fn layout_mode(&self) -> LayoutMode {
210        self.layout_mode
211    }
212}
213
214#[derive(Debug, Clone)]
215pub struct WrapMap {
216    line_wraps: Vec<Vec<WrapPoint>>,
217    max_width: u32,
218}
219
220impl WrapMap {
221    pub fn new<'a>(
222        lines: impl Iterator<Item = &'a str>,
223        max_width: u32,
224        policy: &WrapPolicy,
225    ) -> Self {
226        let line_wraps = lines
227            .map(|line| wrap_line(line, max_width, policy))
228            .collect::<Vec<_>>();
229        Self {
230            line_wraps,
231            max_width,
232        }
233    }
234
235    pub const fn max_width(&self) -> u32 {
236        self.max_width
237    }
238
239    pub fn line_count(&self) -> u32 {
240        usize_to_u32(self.line_wraps.len(), "line count")
241    }
242
243    pub fn visual_line_count(&self, line: u32) -> u32 {
244        let index = u32_to_usize(line, "line");
245        let wraps = &self.line_wraps[index];
246        usize_to_u32(wraps.len(), "visual line count") + 1
247    }
248
249    pub fn total_visual_lines(&self) -> u32 {
250        self.line_wraps.iter().fold(0u32, |acc, wraps| {
251            acc + usize_to_u32(wraps.len(), "visual line count") + 1
252        })
253    }
254
255    pub fn wrap_points(&self, line: u32) -> &[WrapPoint] {
256        let index = u32_to_usize(line, "line");
257        &self.line_wraps[index]
258    }
259
260    pub fn visual_lines(&self, line: u32, line_len: u32) -> Vec<VisualLine> {
261        let mut visual_lines = Vec::new();
262        let mut start = 0u32;
263        for wrap in self.wrap_points(line) {
264            visual_lines.push(VisualLine {
265                start,
266                end: wrap.byte_offset(),
267            });
268            start = wrap.byte_offset();
269        }
270        visual_lines.push(VisualLine {
271            start,
272            end: line_len,
273        });
274        visual_lines
275    }
276
277    pub fn visual_layout_space(
278        &self,
279        line: u32,
280        local_visual_line: u32,
281        line_text: &str,
282        policy: &WrapPolicy,
283        line_layout_policy: &LineLayoutPolicy,
284    ) -> VisualLayoutSpace {
285        let line_len = usize_to_u32(line_text.len(), "line len");
286        let visual_lines = self.visual_lines(line, line_len);
287        let index = u32_to_usize(local_visual_line, "local visual line");
288        let visual_line = visual_lines[index];
289        let inline_advance = line_layout_policy.redistributed_inline_width(
290            policy.width_policy().text_width(
291                &line_text[u32_to_usize(visual_line.start, "start")
292                    ..u32_to_usize(visual_line.end, "end")],
293            ),
294            self.max_width,
295        );
296
297        VisualLayoutSpace {
298            logical_line: line,
299            visual_line: local_visual_line,
300            inline_advance,
301            block_advance: local_visual_line,
302            layout_mode: line_layout_policy.layout_mode(),
303        }
304    }
305
306    pub fn to_visual_line(&self, line: u32, byte_offset_in_line: u32) -> u32 {
307        let prior = (0..line)
308            .map(|current| self.visual_line_count(current))
309            .sum::<u32>();
310        let local = self
311            .wrap_points(line)
312            .iter()
313            .take_while(|wrap| wrap.byte_offset() <= byte_offset_in_line)
314            .count();
315        prior + usize_to_u32(local, "visual line index")
316    }
317
318    pub fn from_visual_line(&self, visual_line: u32) -> (u32, u32) {
319        let mut remaining = visual_line;
320        for (line_index, wraps) in self.line_wraps.iter().enumerate() {
321            let count = usize_to_u32(wraps.len(), "visual line count") + 1;
322            if remaining < count {
323                let start = if remaining == 0 {
324                    0
325                } else {
326                    let wrap_index = u32_to_usize(remaining - 1, "wrap index");
327                    wraps[wrap_index].byte_offset()
328                };
329                return (usize_to_u32(line_index, "line"), start);
330            }
331            remaining -= count;
332        }
333        panic!("visual line {visual_line} out of bounds");
334    }
335
336    pub fn rewrap_line(&mut self, line: u32, line_text: &str, policy: &WrapPolicy) {
337        let index = u32_to_usize(line, "line");
338        self.line_wraps[index] = wrap_line(line_text, self.max_width, policy);
339    }
340
341    pub fn set_max_width<'a>(
342        &mut self,
343        max_width: u32,
344        lines: impl Iterator<Item = &'a str>,
345        policy: &WrapPolicy,
346    ) {
347        self.max_width = max_width;
348        self.line_wraps = lines
349            .map(|line| wrap_line(line, max_width, policy))
350            .collect::<Vec<_>>();
351    }
352
353    pub fn splice_lines<'a>(
354        &mut self,
355        start_line: u32,
356        removed_count: u32,
357        new_lines: impl Iterator<Item = &'a str>,
358        policy: &WrapPolicy,
359    ) {
360        let start = u32_to_usize(start_line, "start line");
361        let end = start + u32_to_usize(removed_count, "removed line count");
362        let replacement = new_lines
363            .map(|line| wrap_line(line, self.max_width, policy))
364            .collect::<Vec<_>>();
365        self.line_wraps.splice(start..end, replacement);
366    }
367}
368
369pub fn wrap_line(line_text: &str, max_width: u32, policy: &WrapPolicy) -> Vec<WrapPoint> {
370    if max_width == 0 {
371        return Vec::new();
372    }
373
374    let width_policy = policy.width_policy();
375    let break_opportunity = policy.break_opportunity();
376    let mut wraps = Vec::new();
377    let mut total_width = 0u32;
378    let mut segment_start_offset = 0u32;
379    let mut segment_start_width = 0u32;
380    let mut last_allowed = None::<WrapPoint>;
381
382    for (byte_offset, ch) in line_text.char_indices() {
383        total_width += width_policy.advance_of(ch);
384        let next_offset = byte_offset + ch.len_utf8();
385        let next_offset_u32 = usize_to_u32(next_offset, "byte offset");
386        let wrap_point = WrapPoint {
387            byte_offset: next_offset_u32,
388            visual_width: total_width,
389        };
390
391        match break_opportunity(line_text, next_offset) {
392            BreakOpportunity::Allowed => {
393                last_allowed = Some(wrap_point);
394            }
395            BreakOpportunity::Forbidden => {}
396            BreakOpportunity::Mandatory => {
397                if next_offset < line_text.len() && next_offset_u32 > segment_start_offset {
398                    wraps.push(wrap_point);
399                    segment_start_offset = next_offset_u32;
400                    segment_start_width = total_width;
401                }
402                last_allowed = None;
403                continue;
404            }
405        }
406
407        if total_width.saturating_sub(segment_start_width) > max_width {
408            if let Some(candidate) = last_allowed {
409                if candidate.byte_offset() > segment_start_offset {
410                    wraps.push(candidate);
411                    segment_start_offset = candidate.byte_offset();
412                    segment_start_width = candidate.visual_width();
413                }
414            }
415            last_allowed = None;
416        }
417    }
418
419    wraps
420}
421
422fn monospace_ascii_width(ch: char) -> u32 {
423    let _ = ch;
424    1
425}
426
427fn cjk_grid_width(ch: char) -> u32 {
428    if ch.is_ascii() {
429        1
430    } else {
431        east_asian_width(ch)
432    }
433}
434
435fn preserve_inline_width(line_width: u32, _max_width: u32) -> u32 {
436    line_width
437}
438
439fn code_break_opportunity(line_text: &str, byte_offset: usize) -> BreakOpportunity {
440    if byte_offset == 0 || byte_offset >= line_text.len() {
441        return BreakOpportunity::Forbidden;
442    }
443
444    match prev_char(line_text, byte_offset) {
445        Some(ch) if ch.is_whitespace() || is_code_operator(ch) => BreakOpportunity::Allowed,
446        _ => BreakOpportunity::Forbidden,
447    }
448}
449
450fn japanese_break_opportunity(line_text: &str, byte_offset: usize) -> BreakOpportunity {
451    if byte_offset == 0 || byte_offset >= line_text.len() {
452        return BreakOpportunity::Forbidden;
453    }
454
455    let prev = match prev_char(line_text, byte_offset) {
456        Some(ch) => ch,
457        None => return BreakOpportunity::Forbidden,
458    };
459    let next = match next_char(line_text, byte_offset) {
460        Some(ch) => ch,
461        None => return BreakOpportunity::Forbidden,
462    };
463
464    if is_line_start_kinsoku(next) || is_line_end_kinsoku(prev) {
465        return BreakOpportunity::Forbidden;
466    }
467
468    if is_japanese_wrap_char(prev) && is_japanese_wrap_char(next) {
469        BreakOpportunity::Allowed
470    } else {
471        BreakOpportunity::Forbidden
472    }
473}
474
475fn prev_char(line_text: &str, byte_offset: usize) -> Option<char> {
476    line_text[..byte_offset].chars().next_back()
477}
478
479fn next_char(line_text: &str, byte_offset: usize) -> Option<char> {
480    line_text[byte_offset..].chars().next()
481}
482
483fn is_code_operator(ch: char) -> bool {
484    matches!(
485        ch,
486        '+' | '-'
487            | '*'
488            | '/'
489            | '%'
490            | '='
491            | '!'
492            | '?'
493            | '&'
494            | '|'
495            | '^'
496            | '~'
497            | ':'
498            | ';'
499            | ','
500            | '.'
501    )
502}
503
504fn is_line_start_kinsoku(ch: char) -> bool {
505    "。、.,:;?!)」』】〉》〕}―…".contains(ch)
506}
507
508fn is_line_end_kinsoku(ch: char) -> bool {
509    "(「『【〈《〔{".contains(ch)
510}
511
512fn is_japanese_wrap_char(ch: char) -> bool {
513    east_asian_width(ch) == 2
514}
515
516fn east_asian_width(ch: char) -> u32 {
517    if matches!(
518        ch as u32,
519        0x1100..=0x115F
520            | 0x2329..=0x232A
521            | 0x2E80..=0x303E
522            | 0x3040..=0x30FF
523            | 0x3100..=0x312F
524            | 0x3130..=0x318F
525            | 0x3190..=0x31EF
526            | 0x31F0..=0x31FF
527            | 0x3200..=0xA4CF
528            | 0xAC00..=0xD7A3
529            | 0xF900..=0xFAFF
530            | 0xFE10..=0xFE19
531            | 0xFE30..=0xFE6F
532            | 0xFF01..=0xFF60
533            | 0xFFE0..=0xFFE6
534            | 0x1F300..=0x1FAFF
535            | 0x20000..=0x3FFFD
536    ) {
537        2
538    } else {
539        1
540    }
541}
542
543fn usize_to_u32(value: usize, what: &str) -> u32 {
544    u32::try_from(value).unwrap_or_else(|_| panic!("{what} exceeds u32::MAX"))
545}
546
547fn u32_to_usize(value: u32, what: &str) -> usize {
548    usize::try_from(value).unwrap_or_else(|_| panic!("{what} exceeds usize::MAX"))
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn wrap_line_basic_wrapping() {
557        let wraps = wrap_line("ab cd ef", 4, &WrapPolicy::code());
558        assert_eq!(
559            wraps,
560            vec![
561                WrapPoint {
562                    byte_offset: 3,
563                    visual_width: 3,
564                },
565                WrapPoint {
566                    byte_offset: 6,
567                    visual_width: 6,
568                },
569            ]
570        );
571    }
572
573    #[test]
574    fn wrap_line_no_wrap_needed() {
575        let wraps = wrap_line("abc", 10, &WrapPolicy::code());
576        assert!(wraps.is_empty());
577    }
578
579    #[test]
580    fn wrap_line_zero_width_disables_wrapping() {
581        let wraps = wrap_line("ab cd", 0, &WrapPolicy::code());
582        assert!(wraps.is_empty());
583    }
584
585    #[test]
586    fn wrap_line_cjk_width_is_two() {
587        let wraps = wrap_line("あい うえ", 4, &WrapPolicy::code());
588        assert_eq!(wraps.len(), 1);
589        assert_eq!(wraps[0].byte_offset(), usize_to_u32("あい ".len(), "len"));
590        assert_eq!(wraps[0].visual_width(), 5);
591    }
592
593    #[test]
594    fn width_policy_can_customize_tab_advance() {
595        let width_policy = WidthPolicy::cjk_grid(2);
596        assert_eq!(width_policy.text_width("a\tb"), 4);
597
598        let wraps = wrap_line(
599            "a\tb c",
600            4,
601            &WrapPolicy::code_with_width_policy(width_policy),
602        );
603        assert_eq!(wraps.len(), 1);
604        assert_eq!(wraps[0].byte_offset(), usize_to_u32("a\tb ".len(), "len"));
605    }
606
607    #[test]
608    fn legacy_wrap_policy_new_keeps_tab_width_in_char_width_callback() {
609        fn legacy_width(ch: char) -> u32 {
610            if ch == '\t' {
611                7
612            } else {
613                1
614            }
615        }
616
617        let policy = WrapPolicy::new(legacy_width, code_break_opportunity);
618        assert_eq!(policy.char_width()('\t'), 7);
619        assert_eq!(policy.width_policy().advance_of('\t'), 7);
620    }
621
622    #[test]
623    fn visual_layout_space_tracks_inline_and_block_advances() {
624        let text = "ab cd";
625        let map = WrapMap::new([text].iter().copied(), 3, &WrapPolicy::code());
626        let layout = map.visual_layout_space(
627            0,
628            1,
629            text,
630            &WrapPolicy::code(),
631            &LineLayoutPolicy::horizontal_ltr(),
632        );
633
634        assert_eq!(layout.logical_line(), 0);
635        assert_eq!(layout.visual_line(), 1);
636        assert_eq!(layout.inline_advance(), 2);
637        assert_eq!(layout.block_advance(), 1);
638        assert_eq!(layout.layout_mode(), LayoutMode::HorizontalLtr);
639    }
640
641    #[test]
642    fn line_layout_policy_can_redistribute_inline_width() {
643        fn justify_to_max(_line_width: u32, max_width: u32) -> u32 {
644            max_width
645        }
646
647        let text = "ab cd";
648        let map = WrapMap::new([text].iter().copied(), 6, &WrapPolicy::code());
649        let layout = map.visual_layout_space(
650            0,
651            0,
652            text,
653            &WrapPolicy::code(),
654            &LineLayoutPolicy::new(LayoutMode::HorizontalLtr, justify_to_max),
655        );
656
657        assert_eq!(layout.inline_advance(), 6);
658        assert_eq!(layout.layout_mode(), LayoutMode::HorizontalLtr);
659    }
660
661    #[test]
662    fn code_policy_breaks_after_space_and_operator() {
663        let policy = WrapPolicy::code();
664        let break_opportunity = policy.break_opportunity();
665
666        assert_eq!(break_opportunity("a + b", 2), BreakOpportunity::Allowed);
667        assert_eq!(break_opportunity("a+b", 2), BreakOpportunity::Allowed);
668        assert_eq!(break_opportunity("ab", 1), BreakOpportunity::Forbidden);
669        assert_eq!(break_opportunity("ab", 0), BreakOpportunity::Forbidden);
670    }
671
672    #[test]
673    fn japanese_basic_applies_kinsoku() {
674        let policy = WrapPolicy::japanese_basic();
675        let break_opportunity = policy.break_opportunity();
676
677        assert_eq!(
678            break_opportunity("あい", "あ".len()),
679            BreakOpportunity::Allowed
680        );
681        assert_eq!(
682            break_opportunity("あ。", "あ".len()),
683            BreakOpportunity::Forbidden
684        );
685        assert_eq!(
686            break_opportunity("(あ", "(".len()),
687            BreakOpportunity::Forbidden
688        );
689    }
690
691    #[test]
692    fn wrap_map_construction_and_round_trip() {
693        let lines = ["ab cd ef", "xyz"];
694        let map = WrapMap::new(lines.iter().copied(), 4, &WrapPolicy::code());
695
696        assert_eq!(map.max_width(), 4);
697        assert_eq!(map.line_count(), 2);
698        assert_eq!(map.visual_line_count(0), 3);
699        assert_eq!(map.visual_line_count(1), 1);
700        assert_eq!(map.total_visual_lines(), 4);
701        assert_eq!(map.to_visual_line(0, 0), 0);
702        assert_eq!(map.to_visual_line(0, 3), 1);
703        assert_eq!(map.to_visual_line(1, 0), 3);
704        assert_eq!(map.from_visual_line(0), (0, 0));
705        assert_eq!(map.from_visual_line(1), (0, 3));
706        assert_eq!(map.from_visual_line(3), (1, 0));
707        assert_eq!(
708            map.visual_lines(0, usize_to_u32(lines[0].len(), "line len")),
709            vec![
710                VisualLine { start: 0, end: 3 },
711                VisualLine { start: 3, end: 6 },
712                VisualLine {
713                    start: 6,
714                    end: usize_to_u32(lines[0].len(), "line len"),
715                },
716            ]
717        );
718    }
719
720    #[test]
721    fn wrap_map_rewrap_line_updates_points() {
722        let lines = ["ab cd", "xy z"];
723        let mut map = WrapMap::new(lines.iter().copied(), 3, &WrapPolicy::code());
724        assert_eq!(map.visual_line_count(0), 2);
725
726        map.rewrap_line(0, "abcd", &WrapPolicy::code());
727        assert_eq!(map.wrap_points(0), &[]);
728        assert_eq!(map.visual_line_count(0), 1);
729    }
730
731    #[test]
732    fn wrap_map_splice_lines_replaces_range() {
733        let lines = ["ab cd", "xy z"];
734        let mut map = WrapMap::new(lines.iter().copied(), 3, &WrapPolicy::code());
735
736        map.splice_lines(1, 1, ["12 34", "p q"].iter().copied(), &WrapPolicy::code());
737
738        assert_eq!(map.line_count(), 3);
739        assert_eq!(map.visual_line_count(1), 2);
740        assert_eq!(map.visual_line_count(2), 1);
741    }
742
743    #[test]
744    fn wrap_map_set_max_width_recomputes_all_lines() {
745        let lines = ["ab cd", "xy z"];
746        let mut map = WrapMap::new(lines.iter().copied(), 10, &WrapPolicy::code());
747        assert_eq!(map.total_visual_lines(), 2);
748
749        map.set_max_width(3, lines.iter().copied(), &WrapPolicy::code());
750        assert_eq!(map.max_width(), 3);
751        assert_eq!(map.total_visual_lines(), 4);
752    }
753}