1use crate::font::CellWidths;
14use crate::lang::text_width;
15use crate::prelude::*;
16
17pub const NO_BREAK_SPACE: char = '\u{a0}';
21
22const NO_START: &str = "、。,.・:;?!)》〉」』】〕ーぁぃぅぇぉっゃゅょァィゥェォッャュョ\
25 ,.:;?!)]}";
26
27const NO_END: &str = "(《〈「『【〔([{";
29
30const NO_START_WIDE: &str = "\u{201D}\u{2019}\u{2026}\u{2014}";
34
35const NO_END_WIDE: &str = "\u{201C}\u{2018}";
37
38fn no_start(c: char, widths: &CellWidths) -> bool {
40 NO_START.contains(c) || (NO_START_WIDE.contains(c) && widths.is_wide(c))
41}
42
43fn no_end(c: char, widths: &CellWidths) -> bool {
45 NO_END.contains(c) || (NO_END_WIDE.contains(c) && widths.is_wide(c))
46}
47
48fn breaks_at(c: char) -> bool {
51 c.is_whitespace() && c != NO_BREAK_SPACE
52}
53
54fn breaks_around(c: char, widths: &CellWidths) -> bool {
59 widths.is_wide(c)
60 || matches!(c,
61 '\u{3000}'..='\u{30ff}' | '\u{3400}'..='\u{4dbf}' | '\u{4e00}'..='\u{9fff}' | '\u{f900}'..='\u{faff}' | '\u{ff00}'..='\u{ffef}' )
67}
68
69fn may_break(a: char, b: char, widths: &CellWidths) -> bool {
72 (breaks_around(a, widths) || breaks_around(b, widths))
73 && a != NO_BREAK_SPACE
74 && b != NO_BREAK_SPACE
75 && !no_start(b, widths)
76 && !no_end(a, widths)
77}
78
79fn pieces<'a>(word: &'a str, widths: &CellWidths) -> Vec<&'a str> {
82 let mut out = Vec::new();
83 let mut start = 0;
84 let mut chars = word.char_indices().peekable();
85 while let Some((_, a)) = chars.next() {
86 if let Some(&(at, b)) = chars.peek()
87 && may_break(a, b, widths)
88 {
89 out.push(&word[start..at]);
90 start = at;
91 }
92 }
93 out.push(&word[start..]);
94 out
95}
96
97struct Lines<'w> {
100 width: usize,
101 widths: &'w CellWidths,
102 done: Vec<String>,
103 line: String,
104 cells: usize,
105}
106
107impl Lines<'_> {
108 fn end_line(&mut self) {
110 if self.cells > 0 {
111 self.done.push(core::mem::take(&mut self.line));
112 self.cells = 0;
113 }
114 }
115
116 fn add(&mut self, piece: &str, spaced: bool) {
120 let cells = text_width(piece, self.widths);
121 let gap = usize::from(spaced && self.cells > 0);
122 if self.cells + gap + cells <= self.width {
123 if gap > 0 {
124 self.line.push(' ');
125 }
126 self.line.push_str(piece);
127 self.cells += gap + cells;
128 return;
129 }
130 self.end_line();
131 for c in piece.chars() {
132 let c_cells = self.widths.of_char(c);
133 if self.cells + c_cells > self.width {
134 self.end_line();
135 }
136 self.line.push(c);
137 self.cells += c_cells;
138 }
139 }
140}
141
142pub fn word_wrap(text: &str, width: usize, widths: &CellWidths) -> Vec<String> {
161 let mut lines = Lines {
162 width: width.max(1),
163 widths,
164 done: Vec::new(),
165 line: String::new(),
166 cells: 0,
167 };
168 for word in text.split(breaks_at).filter(|w| !w.is_empty()) {
169 for (i, piece) in pieces(word, widths).into_iter().enumerate() {
170 lines.add(piece, i == 0);
171 }
172 }
173 lines.end_line();
174 lines.done
175}
176
177#[cfg(test)]
178mod tests {
179 use proptest::prelude::*;
180
181 use super::*;
182
183 fn wrap(text: &str, width: usize) -> Vec<String> {
184 word_wrap(text, width, &CellWidths::NARROW)
185 }
186
187 fn japanese() -> CellWidths {
189 CellWidths::wide(
190 "今日はいい天気ですね。散歩に行きましょうか、それとも家で本を読みますか?\
191 彼女は「明日また来ます」と言った。ちょっと待ってくださいコーヒーを一杯\
192 私の名前ですゲームが好きで毎日遊んでいる遊ぶ(はい)"
193 .chars(),
194 )
195 }
196
197 #[test]
198 fn breaks_at_spaces() {
199 assert_eq!(wrap("the quick brown fox", 10), ["the quick", "brown fox"]);
200 assert_eq!(wrap("abc def", 7), ["abc def"]);
201 assert_eq!(wrap("abc def", 6), ["abc", "def"]);
202 }
203
204 #[test]
205 fn hard_splits_only_over_long_words() {
206 assert_eq!(wrap("ab abcdefgh c", 3), ["ab", "abc", "def", "gh", "c"]);
207 assert_eq!(wrap("abcdef", 3), ["abc", "def"]);
208 }
209
210 #[test]
211 fn empty_and_extra_spaces() {
212 let found = wrap("", 5);
213 assert!(found.is_empty(), "{found:?}");
214 let found = wrap(" ", 5);
215 assert!(found.is_empty(), "{found:?}");
216 assert_eq!(wrap(" a b ", 5), ["a b"]);
217 assert_eq!(wrap("ab", 0), ["a", "b"]);
218 }
219
220 #[test]
221 fn counts_characters_not_bytes() {
222 assert_eq!(wrap("ééé ééé", 3), ["ééé", "ééé"]);
223 }
224
225 #[test]
226 fn a_wide_character_takes_two_cells() {
227 let widths = japanese();
228 assert_eq!(word_wrap("今日はいい天", 6, &widths), ["今日は", "いい天"]);
230 assert_eq!(
232 word_wrap("今日はいい天", 6, &CellWidths::NARROW),
233 ["今日はいい天"]
234 );
235 assert_eq!(word_wrap("今日は", 5, &widths), ["今日", "は"]);
237 }
238
239 #[test]
240 fn japanese_breaks_between_characters_without_an_atlas_and_a_wide_one_anywhere() {
241 assert_eq!(
244 word_wrap("a 今日はいい", 4, &CellWidths::NARROW),
245 ["a 今日", "はいい"]
246 );
247 let hearts = CellWidths::wide(['♥']);
249 assert_eq!(word_wrap("a ♥♥", 4, &hearts), ["a ♥", "♥"]);
250 }
251
252 #[test]
253 fn a_japanese_paragraph_breaks_anywhere_within_the_width() {
254 let widths = japanese();
255 let text = "今日はいい天気ですね。散歩に行きましょうか、それとも家で本を読みますか?";
256 let lines = word_wrap(text, 16, &widths);
257 assert_eq!(
258 lines,
259 [
260 "今日はいい天気で",
261 "すね。散歩に行き",
262 "ましょうか、それ",
263 "とも家で本を読み",
264 "ますか?",
265 ]
266 );
267 assert_eq!(lines.concat(), text);
268 }
269
270 #[test]
271 fn no_line_starts_with_closing_punctuation_or_a_small_kana() {
272 let widths = japanese();
273 assert_eq!(
275 word_wrap("天気ですね。散歩", 10, &widths),
276 ["天気です", "ね。散歩"]
277 );
278 assert_eq!(
280 word_wrap("行きましょうか、それ", 14, &widths),
281 ["行きましょう", "か、それ"]
282 );
283 assert_eq!(word_wrap("行きましょう", 8, &widths), ["行きま", "しょう"]);
286 assert_eq!(word_wrap("コーヒー", 6, &widths), ["コー", "ヒー"]);
288 assert_eq!(word_wrap("(はい)", 6, &widths), ["(は", "い)"]);
289 }
290
291 #[test]
292 fn no_line_ends_with_an_opening_bracket() {
293 let widths = japanese();
294 let text = "彼女は「明日また来ます」と言った。";
295 let lines = word_wrap(text, 8, &widths);
298 assert_eq!(
299 lines,
300 ["彼女は", "「明日ま", "た来ま", "す」と", "言った。"]
301 );
302 for line in &lines {
303 assert!(!line.ends_with('「'), "{lines:?}");
304 assert!(!line.starts_with(['。', '、', '」']), "{lines:?}");
305 }
306 }
307
308 #[test]
309 fn breaks_anyway_where_the_rules_leave_no_break_that_fits() {
310 let brackets = CellWidths::wide(['「']);
312 assert_eq!(word_wrap("「「「「", 4, &brackets), ["「「", "「「"]);
313 let widths = CellWidths::wide("ね。」".chars());
315 assert_eq!(word_wrap("ね。」", 4, &widths), ["ね。", "」"]);
316 }
317
318 #[test]
319 fn a_latin_run_inside_japanese_is_kept_whole() {
320 let widths = japanese();
321 let text = "私の名前はMeiです";
322 assert_eq!(word_wrap(text, 12, &widths), ["私の名前は", "Meiです"]);
325 assert_eq!(
327 word_wrap("毎日遊ぶ2026回", 10, &widths),
328 ["毎日遊ぶ", "2026回"]
329 );
330 assert_eq!(word_wrap("私abcdefg", 4, &widths), ["私", "abcd", "efg"]);
332 }
333
334 #[test]
335 fn latin_and_japanese_with_spaces_break_at_the_spaces_and_between_wide_characters() {
336 let widths = japanese();
337 assert_eq!(
338 word_wrap("Press Z ゲームが好きで", 12, &widths),
339 ["Press Z ゲー", "ムが好きで"]
340 );
341 }
342
343 #[test]
344 fn never_breaks_at_a_no_break_space() {
345 let nbsp = NO_BREAK_SPACE;
346 let text = format!("press Left{nbsp}Shift to run");
347 assert_eq!(
348 wrap(&text, 12),
349 ["press", format!("Left{nbsp}Shift").as_str(), "to run"]
350 );
351 let widths = japanese();
353 let text = format!("毎日Left{nbsp}Shiftを押す");
354 assert_eq!(
355 word_wrap(&text, 12, &widths),
356 ["毎日", format!("Left{nbsp}Shiftを").as_str(), "押す"]
357 );
358 }
359
360 const CHINESE: &str = "他说:“今天天气很好,我们去公园散步吧。”她回答:“好啊……不过我有点累\
362 ——我们明天去,好吗?”《西游记》是一本书(很有名)。";
363
364 fn chinese() -> CellWidths {
367 let hanzi = CHINESE
368 .chars()
369 .filter(|c| !crate::font::is_wide_punctuation(*c));
370 CellWidths::wide(hanzi).in_lang(Some("zh"), true)
371 }
372
373 #[test]
374 fn a_chinese_paragraph_wraps_within_the_width_by_its_rules() {
375 let widths = chinese();
376 assert_eq!(
377 word_wrap(CHINESE, 16, &widths),
378 [
379 "他说:“今天天气",
380 "很好,我们去公园",
381 "散步吧。”她回",
382 "答:“好啊……不",
383 "过我有点累——我",
384 "们明天去,好",
385 "吗?”《西游记》",
386 "是一本书(很有",
387 "名)。",
388 ]
389 );
390 for width in 6..40 {
393 let lines = word_wrap(CHINESE, width, &widths);
394 assert_eq!(lines.concat(), CHINESE, "at {width}");
395 for (i, line) in lines.iter().enumerate() {
396 assert!(text_width(line, &widths) <= width, "{lines:?}");
397 assert!(
398 !line.starts_with([',', '。', '”', '》', ')']),
399 "{lines:?}"
400 );
401 assert!(!line.ends_with(['“', '《', '(']), "{lines:?}");
402 let next = lines.get(i + 1);
405 for pair in ['…', '—'] {
406 let split = line.ends_with(pair) && next.is_some_and(|n| n.starts_with(pair));
407 assert!(!split, "{pair} split at {width}: {lines:?}");
408 }
409 }
410 }
411 }
412
413 #[test]
414 fn chinese_punctuation_never_starts_or_ends_a_line() {
415 let widths = chinese();
416 assert_eq!(word_wrap("很好,我们", 4, &widths), ["很", "好,", "我们"]);
418 assert_eq!(word_wrap("他说“好”", 6, &widths), ["他说", "“好”"]);
420 assert_eq!(word_wrap("好啊……", 6, &widths), ["好", "啊……"]);
422 assert_eq!(word_wrap("累——我", 6, &widths), ["累——", "我"]);
423 assert_eq!(
425 word_wrap("他是《西游记》", 6, &widths),
426 ["他是", "《西游", "记》"]
427 );
428 }
429
430 #[test]
431 fn narrow_quotes_and_dashes_wrap_as_english_does() {
432 let text = "she said “wait… no—go” and left";
435 let lines = wrap(text, 10);
436 assert_eq!(lines, ["she said", "“wait…", "no—go” and", "left"]);
437 assert_eq!(lines, word_wrap(text, 10, &chinese().in_lang(None, false)));
438 }
439
440 fn words(max_len: usize) -> impl Strategy<Value = Vec<String>> {
441 prop::collection::vec(
442 prop::string::string_regex(&format!("[a-zA-Z.,!?']{{1,{max_len}}}")).unwrap(),
443 0..40,
444 )
445 }
446
447 fn japanese_text() -> impl Strategy<Value = String> {
450 prop::collection::vec(
451 prop::string::string_regex("[今日はいい天気ですね。、「」()ょっーab1]{1,4}").unwrap(),
452 0..30,
453 )
454 .prop_map(|parts| parts.concat())
455 }
456
457 proptest! {
458 #[test]
459 fn no_line_is_too_wide(ws in words(30), width in 1usize..40) {
460 for line in wrap(&ws.join(" "), width) {
461 prop_assert!(line.chars().count() <= width, "{line:?} > {width}");
462 prop_assert!(!line.is_empty());
463 }
464 }
465
466 #[test]
467 fn keeps_every_word_when_words_fit(
468 ws in words(12),
469 width in 12usize..40,
470 gaps in prop::collection::vec(1usize..4, 40),
471 ) {
472 let mut text = String::new();
473 for (w, &g) in ws.iter().zip(&gaps) {
474 text.push_str(w);
475 text.push_str(&" ".repeat(g));
476 }
477 let lines = wrap(&text, width);
478 let rejoined: Vec<&str> = lines.iter().flat_map(|l| l.split(' ')).collect();
479 prop_assert_eq!(rejoined, ws.iter().map(String::as_str).collect::<Vec<_>>());
480 }
481
482 #[test]
483 fn keeps_every_character(ws in words(30), width in 1usize..40) {
484 let text = ws.join(" ");
485 let kept: String = wrap(&text, width).concat().replace(' ', "");
486 prop_assert_eq!(kept, text.replace(' ', ""));
487 }
488
489 #[test]
490 fn lines_are_filled_greedily(ws in words(12), width in 12usize..40) {
491 let lines = wrap(&ws.join(" "), width);
492 for pair in lines.windows(2) {
493 let next_word = pair[1].split(' ').next().unwrap_or("");
494 prop_assert!(
495 pair[0].chars().count() + 1 + next_word.chars().count() > width,
496 "{:?} could have taken {next_word:?}", pair[0]
497 );
498 }
499 }
500
501 #[test]
502 fn japanese_lines_fit_and_join_back_to_the_text(
503 text in japanese_text(),
504 width in 2usize..30,
505 ) {
506 let widths = japanese();
507 let lines = word_wrap(&text, width, &widths);
508 for line in &lines {
509 let cells = text_width(line, &widths);
510 prop_assert!(cells <= width, "{line:?} is {cells} cells > {width}");
511 prop_assert!(!line.is_empty());
512 }
513 prop_assert_eq!(lines.concat(), text);
514 }
515
516 #[test]
517 fn chinese_lines_fit_and_join_back_to_the_text_with_wide_punctuation(
518 parts in prop::collection::vec(
519 prop::string::string_regex("[他说好我们,。“”‘’…—《》ab]{1,4}").unwrap(),
520 0..30,
521 ),
522 width in 2usize..30,
523 ) {
524 let text = parts.concat();
525 let widths = chinese();
526 let lines = word_wrap(&text, width, &widths);
527 for line in &lines {
528 let cells = text_width(line, &widths);
529 prop_assert!(cells <= width, "{line:?} is {cells} cells > {width}");
530 prop_assert!(!line.is_empty());
531 }
532 prop_assert_eq!(lines.concat(), text);
533 }
534
535 #[test]
536 fn japanese_lines_keep_the_rules_where_a_break_fits(
537 text in japanese_text(),
538 width in 8usize..30,
539 ) {
540 let widths = japanese();
541 let lines = word_wrap(&text, width, &widths);
542 for pair in lines.windows(2) {
543 let (end, start) = (pair[0].chars().last(), pair[1].chars().next());
544 let (Some(end), Some(start)) = (end, start) else { continue };
545 let unbreakable = pieces(&format!("{}{}", pair[0], pair[1]), &widths)
547 .iter()
548 .any(|p| text_width(p, &widths) > width);
549 if !unbreakable {
550 prop_assert!(may_break(end, start, &widths), "{lines:?}");
551 }
552 }
553 }
554 }
555}