1use std::time::Duration;
2
3use omp_core::{Str, StrMut};
4use smallvec::SmallVec;
5use xutf::Text;
6
7use crate::{
8 anim,
9 component::{Component, MemoKey, PaintCtx, Slot, next_slot},
10 frame::{Rect, Style},
11 markup::Truncate,
12 props::{Prop, PropValue, Props},
13 rich::{Pipeline, RichSink, RichText, cell_width},
14};
15
16pub struct TextLeaf {
18 props: Props,
19 slot: Slot,
20 text: Str,
21 rich: RichText,
22 version: u64,
23 cached_width: u16,
24 cached: Option<MemoKey>,
25 cached_style: Style,
28 cached_end: usize,
32 reveal: Option<Box<RevealState>>,
34}
35
36impl TextLeaf {
37 pub fn new() -> Self {
39 Self {
40 props: Props::new(),
41 slot: next_slot(),
42 text: Str::default(),
43 rich: RichText::default(),
44 version: 1,
45 cached_width: 0,
46 cached: None,
47 cached_style: Style::new(),
48 cached_end: 0,
49 reveal: None,
50 }
51 }
52
53 pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
55 self.props.set(prop, value);
56 self.version = self.version.wrapping_add(1);
57 self
58 }
59
60 pub fn with_str(self, prop: Prop, value: &str) -> Self {
62 self.with(prop, value)
63 }
64
65 pub fn text(mut self, text: impl Into<Str>) -> Self {
67 append(&mut self.text, text.into());
68 self.version = self.version.wrapping_add(1);
69 self
70 }
71
72 fn render(&mut self, ctx: &crate::UiContext, width: u16) {
73 let width = width.max(1);
74 let style = self.props.style(&ctx.theme);
75 let key = MemoKey::new(self.version, ctx);
76 let end = if let Some(horizon) = self.props.reveal() {
77 let reveal = self.reveal.get_or_insert_default();
78 reveal.sync(&self.text);
79 reveal.advance(ctx.now, horizon)
80 } else {
81 self.reveal = None;
84 self.text.len()
85 };
86 if self.cached_width == width
87 && self.cached == Some(key)
88 && self.cached_style == style
89 && self.cached_end == end
90 {
91 return;
92 }
93 let visible = &self.text[..end];
94 self.rich.clear();
95 match self.props.truncate() {
96 Some(Truncate::End) => {
97 let mut clip = (&mut self.rich).clip(width, Some('…'));
98 for (index, line) in visible.split('\n').enumerate() {
99 if index > 0 {
100 clip.run(style, " ");
101 }
102 clip.run(style, line);
103 }
104 },
105 Some(Truncate::Start) => {
106 let mut runs: SmallVec<(Style, Str), 8> = SmallVec::new();
107 for (index, line) in visible.split('\n').enumerate() {
108 if index > 0 {
109 runs.push((style, Str::new_static(" ")));
110 }
111 if !line.is_empty() {
112 runs.push((style, self.text.slice_ref(line)));
113 }
114 }
115 clip_start_runs(&mut self.rich, width, &runs);
116 },
117 None if self.props.wrap_chars() => {
118 let mut wrap = (&mut self.rich).wrap_chars(width);
121 for (index, line) in visible.split('\n').enumerate() {
122 if index > 0 {
123 wrap.newline();
124 }
125 if !line.is_empty() {
126 wrap.run(style, line);
127 }
128 }
129 },
130 None => {
131 let mut wrap = (&mut self.rich).wrap(width);
132 for (index, line) in visible.split('\n').enumerate() {
135 if index > 0 {
136 wrap.newline();
137 }
138 if !line.is_empty() {
139 wrap.run(style, line);
140 }
141 }
142 wrap.finish();
143 },
144 }
145 self.cached_width = width;
146 self.cached = Some(key);
147 self.cached_style = style;
148 self.cached_end = end;
149 }
150}
151
152impl Default for TextLeaf {
153 fn default() -> Self {
154 Self::new()
155 }
156}
157
158impl Component for TextLeaf {
159 fn props(&self) -> &Props {
160 &self.props
161 }
162
163 fn props_mut(&mut self) -> &mut Props {
164 &mut self.props
165 }
166
167 fn slot(&self) -> Slot {
168 self.slot
169 }
170
171 fn measure(&mut self, _ctx: &crate::UiContext) -> (u16, u16) {
172 let mut widest_word = 0;
173 let mut total = 0u16;
174 for word in self.text.split_whitespace() {
175 let width = cell_width(word);
176 widest_word = widest_word.max(width);
177 total = total.saturating_add(width).saturating_add(1);
178 }
179 let natural = total.saturating_sub(1);
180 if self.props.truncate().is_some() || self.props.wrap_chars() {
183 return (natural.min(1), natural);
184 }
185 (widest_word, natural)
186 }
187
188 fn height(&mut self, ctx: &crate::UiContext, width: u16) -> u16 {
189 self.render(ctx, width);
190 RichText::rows(&self.rich)
191 }
192
193 fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
194 self.render(pc.ctx, rect.width);
195 match self.props.shimmer() {
196 Some(period) => {
197 paint_rich_shimmer(pc, rect, &self.rich, self.props.align(), period);
198 pc.wake(self.slot, pc.now.saturating_add(anim::FRAME));
199 },
200 None => paint_rich(pc, rect, &self.rich, self.props.align()),
201 }
202 if let Some(reveal) = self.reveal.as_deref()
205 && !reveal.is_settled()
206 {
207 pc.wake_layout(self.slot, pc.now.saturating_add(anim::FRAME));
208 }
209 }
210
211 fn set_text(&mut self, _ctx: &crate::UiContext, text: Str) -> bool {
212 if self.text == text {
213 return false;
214 }
215 self.text = text;
216 self.version = self.version.wrapping_add(1);
217 true
218 }
219}
220
221impl TextLeaf {
222 pub(crate) const fn content(&self) -> &Str {
224 &self.text
225 }
226}
227
228#[derive(Default)]
234struct RevealState {
235 pace: anim::Reveal,
236 seen: Str,
238 total: usize,
240 tail: usize,
242 shown_units: usize,
244 shown_end: usize,
246 shown_from: usize,
248}
249
250impl RevealState {
251 fn sync(&mut self, text: &Str) {
255 if self.seen == *text {
256 return;
257 }
258 if text.len() > self.seen.len() && text.starts_with(self.seen.as_str()) {
259 let (count, tail) = count_clusters(text, self.tail);
260 self.total = if self.total == 0 {
261 count
262 } else {
263 self.total - 1 + count
264 };
265 self.tail = tail;
266 } else {
267 let (count, tail) = count_clusters(text, 0);
268 self.total = count;
269 self.tail = tail;
270 self.pace.reset();
271 self.shown_units = 0;
272 self.shown_end = 0;
273 self.shown_from = 0;
274 }
275 self.seen = text.clone();
276 }
277
278 fn advance(&mut self, now: Duration, horizon: Duration) -> usize {
282 let units = self.pace.advance(now, self.total, horizon);
283 if units >= self.total {
284 self.shown_units = self.total;
285 self.shown_end = self.seen.len();
286 self.shown_from = self.tail;
287 return self.shown_end;
288 }
289 if units == 0 {
290 self.shown_units = 0;
291 self.shown_end = 0;
292 self.shown_from = 0;
293 return 0;
294 }
295 let (start, need, base) = if self.shown_units > 0 && units >= self.shown_units {
296 (self.shown_from, units - self.shown_units + 1, self.shown_units - 1)
297 } else {
298 (0, units, 0)
299 };
300 let mut offset = start;
301 let mut last = start;
302 let mut walked = 0;
303 for cluster in xutf::graphemes_str(&self.seen[start..]) {
304 last = offset;
305 offset += cluster.len();
306 walked += 1;
307 if walked == need {
308 break;
309 }
310 }
311 self.shown_units = base + walked;
312 self.shown_from = last;
313 self.shown_end = offset;
314 self.shown_end
315 }
316
317 const fn is_settled(&self) -> bool {
319 self.shown_units >= self.total
320 }
321}
322
323fn count_clusters(text: &str, start: usize) -> (usize, usize) {
327 let mut count = 0;
328 let mut tail = start;
329 let mut offset = start;
330 for cluster in xutf::graphemes_str(&text[start..]) {
331 count += 1;
332 tail = offset;
333 offset += cluster.len();
334 }
335 (count, tail)
336}
337
338pub struct Pre {
340 props: Props,
341 slot: Slot,
342 text: Str,
343}
344
345impl Pre {
346 pub fn new() -> Self {
348 Self { props: Props::new(), slot: next_slot(), text: Str::default() }
349 }
350
351 pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
353 self.props.set(prop, value);
354 self
355 }
356
357 pub fn with_str(self, prop: Prop, value: &str) -> Self {
359 self.with(prop, value)
360 }
361
362 pub fn text(mut self, text: impl Into<Str>) -> Self {
364 append(&mut self.text, text.into());
365 self
366 }
367
368 pub(crate) const fn content(&self) -> &Str {
370 &self.text
371 }
372}
373
374impl Default for Pre {
375 fn default() -> Self {
376 Self::new()
377 }
378}
379
380impl Component for Pre {
381 fn props(&self) -> &Props {
382 &self.props
383 }
384
385 fn props_mut(&mut self) -> &mut Props {
386 &mut self.props
387 }
388
389 fn slot(&self) -> Slot {
390 self.slot
391 }
392
393 fn measure(&mut self, _ctx: &crate::UiContext) -> (u16, u16) {
394 let width = self.text.lines().map(cell_width).max().unwrap_or(0);
395 (width, width)
396 }
397
398 fn height(&mut self, _ctx: &crate::UiContext, _width: u16) -> u16 {
399 u16::try_from(self.text.lines().count()).unwrap_or(u16::MAX)
400 }
401
402 fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
403 let width = self.text.lines().map(cell_width).max().unwrap_or(0);
404 let slack = rect.width.saturating_sub(width.min(rect.width));
405 let x = rect
406 .x
407 .saturating_add(alignment_slack(self.props.align(), slack));
408 let right = rect.x.saturating_add(rect.width);
409 let style = self.props.style(&pc.ctx.theme);
410 let clip = pc.clip.min(rect.y.saturating_add(rect.height));
411 for (row, line) in self.text.lines().enumerate() {
412 let y = rect
413 .y
414 .saturating_add(u16::try_from(row).unwrap_or(u16::MAX));
415 if y >= clip {
416 break;
417 }
418 put_clipped(pc.frame, x, y, right, line, style);
419 }
420 }
421
422 fn gradient_bounds(&self, content: Rect) -> Option<Rect> {
423 let width = self
424 .text
425 .lines()
426 .map(cell_width)
427 .max()
428 .unwrap_or(0)
429 .min(content.width);
430 let slack = content.width.saturating_sub(width);
431 let x = content
432 .x
433 .saturating_add(alignment_slack(self.props.align(), slack));
434 let height = u16::try_from(self.text.lines().count())
435 .unwrap_or(u16::MAX)
436 .min(content.height);
437 Some(Rect::new(x, content.y, width, height))
438 }
439
440 fn set_text(&mut self, _ctx: &crate::UiContext, text: Str) -> bool {
441 if self.text == text {
442 return false;
443 }
444 self.text = text;
445 true
446 }
447}
448
449pub(super) fn append(target: &mut Str, suffix: Str) {
450 if target.is_empty() {
451 *target = suffix;
452 return;
453 }
454 let mut joined = StrMut::with_capacity(target.len().saturating_add(suffix.len()));
455 joined.push_str(target);
456 joined.push_str(&suffix);
457 *target = joined.freeze();
458}
459
460pub(super) fn clip_start_runs(rich: &mut RichText, width: u16, runs: &[(Style, Str)]) {
463 let width = width.max(1);
464 let total = runs
465 .iter()
466 .fold(0_u16, |sum, (_, text)| sum.saturating_add(cell_width(text)));
467 if total <= width {
468 for (style, text) in runs {
469 rich.run(*style, text);
470 }
471 return;
472 }
473 let budget = width - 1;
476 let mut drop = total.saturating_add(1).saturating_sub(width);
477 let marker = runs.first().map_or(Style::new(), |(style, _)| *style);
478 rich.run(marker, "…");
479 let mut clip = (&mut *rich).clip(budget.saturating_add(1), None);
482 for (style, text) in runs {
483 if drop == 0 {
484 clip.run(*style, text);
485 continue;
486 }
487 let run_width = cell_width(text);
488 if run_width <= drop {
489 drop -= run_width;
490 continue;
491 }
492 let mut cut = text.len();
493 let mut walked = 0_u16;
494 for (offset, grapheme) in text.as_str().grapheme_indices() {
495 if walked >= drop {
496 cut = offset;
497 break;
498 }
499 walked = walked.saturating_add(cell_width(grapheme));
500 }
501 drop = 0;
502 clip.run(*style, &text.as_str()[cut..]);
503 }
504}
505
506pub(super) fn truncate_rich(
507 rich: &mut RichText,
508 width: u16,
509 fallback: Style,
510 truncate: Option<Truncate>,
511) {
512 let Some(mode) = truncate else { return };
513 if RichText::rows(rich) <= 1 {
514 return;
515 }
516 match mode {
517 Truncate::End => {
518 let row: SmallVec<(Style, Str), 4> = rich
519 .row_runs(0)
520 .map(|(style, text)| (style, Str::new(text)))
521 .collect();
522 rich.clear();
523 {
524 let mut clip = (&mut *rich).clip(width.saturating_sub(1), None);
525 for (style, text) in &row {
526 clip.run(*style, text);
527 }
528 }
529 let style = rich.row_runs(0).last().map_or(fallback, |(style, _)| style);
530 rich.run(style, "…");
531 },
532 Truncate::Start => {
533 let mut joined: SmallVec<(Style, Str), 8> = SmallVec::new();
535 for row in 0..RichText::rows(rich) {
536 if row > 0 {
537 let style = joined.last().map_or(fallback, |(style, _)| *style);
538 joined.push((style, Str::new_static(" ")));
539 }
540 for (style, text) in rich.row_runs(row) {
541 joined.push((style, Str::new(text)));
542 }
543 }
544 rich.clear();
545 clip_start_runs(rich, width, &joined);
546 },
547 }
548}
549
550pub(super) const fn alignment_slack(align: crate::markup::Align, slack: u16) -> u16 {
551 match align {
552 crate::markup::Align::Start => 0,
553 crate::markup::Align::Center => slack / 2,
554 crate::markup::Align::End => slack,
555 }
556}
557
558pub(super) fn put_clipped(
559 frame: &mut crate::Frame,
560 x: u16,
561 y: u16,
562 right: u16,
563 text: &str,
564 style: Style,
565) -> u16 {
566 let room = right.saturating_sub(x);
567 if room == 0 {
568 return x;
569 }
570 let visible = text.truncate_width(usize::from(room));
571 frame.put(x, y, visible, style)
572}
573
574pub(super) fn paint_rich(
575 pc: &mut PaintCtx<'_>,
576 rect: Rect,
577 rich: &RichText,
578 align: crate::markup::Align,
579) {
580 let right = rect.x.saturating_add(rect.width);
581 let clip = pc.clip.min(rect.y.saturating_add(rect.height));
582 let full_row = rect.x == 0 && rect.width == pc.frame.size().width;
585 for row in 0..RichText::rows(rich) {
586 let y = rect.y.saturating_add(row);
587 if y >= clip {
588 break;
589 }
590 if full_row && row > 0 && rich.row_soft_wrap(row - 1) {
591 pc.frame.set_soft_wrap(y - 1);
592 }
593 let slack = rect.width.saturating_sub(rich.row_width(row));
594 let mut x = rect.x.saturating_add(alignment_slack(align, slack));
595 for (style, text) in rich.row_runs(row) {
596 x = put_clipped(pc.frame, x, y, right, text, style);
597 if x >= right {
598 break;
599 }
600 }
601 }
602}
603
604fn paint_rich_shimmer(
607 pc: &mut PaintCtx<'_>,
608 rect: Rect,
609 rich: &RichText,
610 align: crate::markup::Align,
611 period: std::time::Duration,
612) {
613 let right = rect.x.saturating_add(rect.width);
614 let clip = pc.clip.min(rect.y.saturating_add(rect.height));
615 let full_row = rect.x == 0 && rect.width == pc.frame.size().width;
616 for row in 0..RichText::rows(rich) {
617 let y = rect.y.saturating_add(row);
618 if y >= clip {
619 break;
620 }
621 if full_row && row > 0 && rich.row_soft_wrap(row - 1) {
622 pc.frame.set_soft_wrap(y - 1);
623 }
624 let slack = rect.width.saturating_sub(rich.row_width(row));
625 let start = rect.x.saturating_add(alignment_slack(align, slack));
626 let shimmer = anim::Shimmer::new(pc.now, period, rich.row_width(row));
627 let mut x = start;
628 'runs: for (style, text) in rich.row_runs(row) {
629 for grapheme in xutf::graphemes_str(text) {
630 if x >= right {
631 break 'runs;
632 }
633 let next = pc
634 .frame
635 .put(x, y, grapheme, shimmer.style_at(x - start, style));
636 if next == x {
637 break 'runs;
638 }
639 x = next;
640 }
641 }
642 }
643}
644
645#[cfg(test)]
646mod tests {
647 use super::*;
648 use crate::{
649 UiContext,
650 component::{Component, PaintCtx},
651 components::{Callout, Icon, Latex, Markdown},
652 frame::{Frame, Rect, Size},
653 test_support::frame_row_text,
654 ui::Ui,
655 };
656
657 fn paint(component: &mut dyn Component, width: u16, height: u16) -> Frame {
658 let ctx = UiContext::default();
659 let mut frame = Frame::new(Size::new(width, height));
660 let mut hits = Vec::new();
661 let mut wakes = Vec::new();
662 let mut pc = PaintCtx::new(&mut frame, &ctx, &mut hits, &mut wakes);
663 component.paint(&mut pc, Rect::new(0, 0, width, height));
664 frame
665 }
666
667 #[test]
668 fn full_width_overflow_marks_soft_wrap_boundaries() {
669 let mut text = TextLeaf::new().text("abcdefghij");
670 let frame = paint(&mut text, 8, 2);
671 assert!(frame.soft_wrap(0), "a mid-word wrap at full width is joinable");
672 }
673
674 #[test]
675 fn char_wrap_prop_flows_terminal_exact() {
676 let mut text = TextLeaf::new().with(Prop::Wrap, "char").text("ab cdefgh x");
677 let frame = paint(&mut text, 8, 2);
678 assert_eq!(frame_row_text(&frame, 0), "ab cdefg");
679 assert_eq!(frame_row_text(&frame, 1), "h x");
680 assert!(frame.soft_wrap(0));
681 }
682
683 #[test]
684 fn offset_rects_keep_hard_boundaries() {
685 let ctx = UiContext::default();
686 let mut frame = Frame::new(Size::new(9, 2));
687 let mut hits = Vec::new();
688 let mut wakes = Vec::new();
689 let mut pc = PaintCtx::new(&mut frame, &ctx, &mut hits, &mut wakes);
690 let mut text = TextLeaf::new().text("abcdefghij");
691 text.paint(&mut pc, Rect::new(1, 0, 8, 2));
692 drop(pc);
693 assert!(!frame.soft_wrap(0), "offset text cannot byte-join through autowrap");
694 }
695 #[test]
696 fn text_wraps_and_aligns_rows() {
697 let mut text = TextLeaf::new()
698 .with(Prop::Align, "center")
699 .text("one two three");
700 let frame = paint(&mut text, 7, 2);
701 assert_eq!(frame_row_text(&frame, 0), "one two");
702 assert_eq!(frame_row_text(&frame, 1), " three");
703 }
704
705 #[test]
706 fn pre_paints_verbatim_rows() {
707 let mut pre = Pre::new().text("A\n B");
708 let frame = paint(&mut pre, 4, 2);
709 assert_eq!(frame_row_text(&frame, 0), "A");
710 assert_eq!(frame_row_text(&frame, 1), " B");
711 }
712
713 #[test]
714 fn markdown_paints_paragraph_and_fenced_code() {
715 let mut markdown = Markdown::new().text("paragraph\n\n```rust\nlet x = 1;\n```");
716 let frame = paint(&mut markdown, 24, 8);
717 let rows = (0..8)
718 .map(|row| frame_row_text(&frame, row))
719 .collect::<Vec<_>>();
720 assert!(rows.iter().any(|row| row.contains("paragraph")));
721 assert!(rows.iter().any(|row| row.contains("let x = 1;")));
722 }
723
724 #[test]
725 fn latex_paints_inline_when_block_layout_is_unavailable() {
726 let mut latex = Latex::new().text(r"\unknown{x}");
727 let frame = paint(&mut latex, 20, 3);
728 assert!((0..3).any(|row| !frame_row_text(&frame, row).is_empty()));
729 }
730
731 #[test]
732 fn callout_paints_header_and_body_rail() {
733 let mut callout = Callout::new()
734 .with(Prop::Title, "Advisor")
735 .with(Prop::Badge, "1")
736 .text("body");
737 let frame = paint(&mut callout, 20, 3);
738 assert!(frame_row_text(&frame, 0).contains("Advisor"));
739 assert!(frame_row_text(&frame, 1).contains("body"));
740 assert!(frame_row_text(&frame, 1).starts_with('▎'));
741 }
742
743 #[test]
744 fn icon_measure_matches_painted_glyph_width() {
745 let ctx = UiContext::default();
746 let mut icon = Icon::named("folder");
747 let (min, natural) = icon.measure(&ctx);
748 assert_eq!(min, natural);
749 let frame = paint(&mut icon, min.max(1), 1);
750 assert_eq!(cell_width(&frame_row_text(&frame, 0)), min);
751 }
752
753 #[test]
754 fn reveal_types_out_streamed_appends_and_settles() {
755 let mut ui = Ui::from_root(
756 TextLeaf::new()
757 .with(Prop::Reveal, true)
758 .with(Prop::Id, "stream")
759 .text("abcdef"),
760 20,
761 UiContext::default(),
762 );
763 assert_eq!(frame_row_text(ui.frame(), 0), "");
766 assert_eq!(ui.next_wake(), Some(Duration::from_millis(33)));
767
768 assert!(ui.tick(Duration::from_millis(34)));
770 assert_eq!(frame_row_text(ui.frame(), 0), "ab");
771 ui.tick(Duration::from_millis(68));
772 assert_eq!(frame_row_text(ui.frame(), 0), "abcde");
773 ui.tick(Duration::from_millis(102));
774 assert_eq!(frame_row_text(ui.frame(), 0), "abcdef");
775 assert_eq!(ui.next_wake(), None, "a settled reveal stops waking");
776
777 assert!(ui.set_text("stream", "abcdefghijkl"));
779 assert_eq!(frame_row_text(ui.frame(), 0), "abcdef");
780 assert!(ui.next_wake().is_some(), "new backlog re-arms the frame cadence");
781 ui.tick(Duration::from_millis(136));
782 assert_eq!(frame_row_text(ui.frame(), 0), "abcdefgh");
783 ui.tick(Duration::from_millis(170));
784 assert_eq!(frame_row_text(ui.frame(), 0), "abcdefghijk");
785 ui.tick(Duration::from_millis(204));
786 assert_eq!(frame_row_text(ui.frame(), 0), "abcdefghijkl");
787
788 assert!(ui.set_text("stream", "xyz"));
790 assert_eq!(frame_row_text(ui.frame(), 0), "");
791 ui.tick(Duration::from_millis(238));
792 assert_eq!(frame_row_text(ui.frame(), 0), "xy");
793 ui.tick(Duration::from_millis(272));
794 assert_eq!(frame_row_text(ui.frame(), 0), "xyz");
795 assert_eq!(ui.next_wake(), None);
796 }
797
798 #[test]
799 fn reveal_grows_height_as_rows_fill() {
800 let mut ui = Ui::from_root(
801 TextLeaf::new().with(Prop::Reveal, true).text("aaa bbb"),
802 3,
803 UiContext::default(),
804 );
805 assert_eq!(ui.height(), 1, "an empty reveal holds the blank row a bare leaf has");
806 ui.tick(Duration::from_millis(34));
807 assert_eq!(ui.height(), 1, "two clusters still fit the first row");
808 ui.tick(Duration::from_millis(67));
809 assert_eq!(ui.height(), 2, "the fifth cluster wraps onto a second row");
810 ui.tick(Duration::from_millis(100));
811 assert_eq!(frame_row_text(ui.frame(), 0), "aaa");
812 assert_eq!(frame_row_text(ui.frame(), 1), "bbb");
813 }
814
815 #[test]
816 fn reveal_state_extends_counts_across_cluster_boundaries() {
817 let mut state = RevealState::default();
818 state.sync(&Str::new("e"));
819 assert_eq!(state.total, 1);
820 state.sync(&Str::new("e\u{301}"));
822 assert_eq!(state.total, 1);
823 state.sync(&Str::new("e\u{301}f"));
824 assert_eq!(state.total, 2);
825 state.sync(&Str::new("zz"));
827 assert_eq!(state.total, 2);
828 assert_eq!(state.advance(Duration::ZERO, Duration::from_millis(250)), 0);
829 }
830
831 #[test]
832 fn reveal_state_reslices_the_boundary_cluster_after_an_append() {
833 let mut state = RevealState::default();
834 state.sync(&Str::new("ab"));
835 assert_eq!(state.advance(Duration::ZERO, Duration::ZERO), 2);
837 state.sync(&Str::new("ab\u{301}c"));
838 let end = state.advance(Duration::from_millis(1), Duration::from_millis(250));
841 assert_eq!(&state.seen[..end], "ab\u{301}");
842 assert!(!state.is_settled());
843 }
844}