1#![forbid(unsafe_code)]
2
3use core::time::Duration;
36
37use ftui_backend::{BackendClock, BackendEventSource, BackendPresenter};
38use ftui_core::event::Event;
39use ftui_render::buffer::{Buffer, DoubleBuffer};
40use ftui_render::diff::BufferDiff;
41use ftui_render::frame::Frame;
42use ftui_render::grapheme_pool::GraphemePool;
43use ftui_runtime::program::{Cmd, Model};
44
45use crate::{WebBackend, WebBackendError, WebOutputs};
46
47const POOL_GC_INTERVAL_FRAMES: u64 = 256;
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct StepResult {
53 pub running: bool,
55 pub rendered: bool,
57 pub events_processed: u32,
59 pub frame_idx: u64,
61}
62
63pub struct StepProgram<M: Model> {
76 model: M,
77 backend: WebBackend,
78 pool: GraphemePool,
79 running: bool,
80 initialized: bool,
81 dirty: bool,
82 frame_idx: u64,
83 tick_rate: Option<Duration>,
84 last_tick: Duration,
85 width: u16,
86 height: u16,
87 dbl_buf: Option<DoubleBuffer>,
89}
90
91impl<M: Model> StepProgram<M> {
92 #[must_use]
94 pub fn new(model: M, width: u16, height: u16) -> Self {
95 Self {
96 model,
97 backend: WebBackend::new(width, height),
98 pool: GraphemePool::new(),
99 running: true,
100 initialized: false,
101 dirty: true,
102 frame_idx: 0,
103 tick_rate: None,
104 last_tick: Duration::ZERO,
105 width,
106 height,
107 dbl_buf: None,
108 }
109 }
110
111 #[must_use]
113 pub fn with_backend(model: M, mut backend: WebBackend) -> Self {
114 let (width, height) = backend.events_mut().size().unwrap_or((80, 24));
115 Self {
116 model,
117 backend,
118 pool: GraphemePool::new(),
119 running: true,
120 initialized: false,
121 dirty: true,
122 frame_idx: 0,
123 tick_rate: None,
124 last_tick: Duration::ZERO,
125 width,
126 height,
127 dbl_buf: None,
128 }
129 }
130
131 pub fn init(&mut self) -> Result<(), WebBackendError> {
137 assert!(!self.initialized, "StepProgram::init() called twice");
138 self.initialized = true;
139 let cmd = self.model.init();
140 self.execute_cmd(cmd);
141 if self.running {
142 self.render_frame()?;
143 }
144 Ok(())
145 }
146
147 pub fn step(&mut self) -> Result<StepResult, WebBackendError> {
154 assert!(self.initialized, "StepProgram::step() called before init()");
155
156 if !self.running {
157 return Ok(StepResult {
158 running: false,
159 rendered: false,
160 events_processed: 0,
161 frame_idx: self.frame_idx,
162 });
163 }
164
165 let mut events_processed: u32 = 0;
167 while let Some(event) = self.backend.events.read_event()? {
168 events_processed += 1;
169 self.handle_event(event);
170 if !self.running {
171 break;
172 }
173 }
174
175 if self.running
177 && let Some(rate) = self.tick_rate
178 {
179 let now = self.backend.clock.now_mono();
180 let delta = now.saturating_sub(self.last_tick);
181 let should_tick = if rate.is_zero() { true } else { delta >= rate };
182 if should_tick {
183 if rate.is_zero() {
186 self.last_tick = now;
187 } else {
188 let rem_ns = delta.as_nanos() % rate.as_nanos();
189 let rem = Duration::from_nanos(rem_ns as u64);
190 self.last_tick = now.saturating_sub(rem);
191 }
192 let msg = M::Message::from(Event::Tick);
193 let cmd = self.model.update(msg);
194 self.dirty = true;
195 self.execute_cmd(cmd);
196 }
197 }
198
199 let rendered = if self.running && self.dirty {
201 self.render_frame()?;
202 true
203 } else {
204 false
205 };
206
207 Ok(StepResult {
208 running: self.running,
209 rendered,
210 events_processed,
211 frame_idx: self.frame_idx,
212 })
213 }
214
215 pub fn push_event(&mut self, event: Event) {
219 if let Event::Resize { width, height } = &event {
221 self.width = *width;
222 self.height = *height;
223 self.backend.events_mut().set_size(*width, *height);
224 }
225 self.backend.events_mut().push_event(event);
226 }
227
228 pub fn advance_time(&mut self, dt: Duration) {
230 self.backend.clock_mut().advance(dt);
231 }
232
233 pub fn set_time(&mut self, now: Duration) {
235 self.backend.clock_mut().set(now);
236 }
237
238 pub fn resize(&mut self, width: u16, height: u16) {
243 self.push_event(Event::Resize { width, height });
244 }
245
246 pub fn take_outputs(&mut self) -> WebOutputs {
248 self.backend.presenter_mut().take_outputs()
249 }
250
251 pub fn outputs(&self) -> &WebOutputs {
253 self.backend.presenter.outputs()
254 }
255
256 pub fn model(&self) -> &M {
258 &self.model
259 }
260
261 pub fn model_mut(&mut self) -> &mut M {
263 &mut self.model
264 }
265
266 pub fn backend(&self) -> &WebBackend {
268 &self.backend
269 }
270
271 pub fn backend_mut(&mut self) -> &mut WebBackend {
273 &mut self.backend
274 }
275
276 pub fn is_running(&self) -> bool {
278 self.running
279 }
280
281 pub fn is_initialized(&self) -> bool {
283 self.initialized
284 }
285
286 pub fn frame_idx(&self) -> u64 {
288 self.frame_idx
289 }
290
291 pub fn size(&self) -> (u16, u16) {
293 (self.width, self.height)
294 }
295
296 pub fn tick_rate(&self) -> Option<Duration> {
298 self.tick_rate
299 }
300
301 pub fn pool(&self) -> &GraphemePool {
303 &self.pool
304 }
305
306 fn handle_event(&mut self, event: Event) {
309 if let Event::Resize { width, height } = &event {
310 self.width = *width;
311 self.height = *height;
312 self.dbl_buf = None;
314 }
315 let msg = M::Message::from(event);
316 let cmd = self.model.update(msg);
317 self.dirty = true;
318 self.execute_cmd(cmd);
319 }
320
321 fn render_frame(&mut self) -> Result<(), WebBackendError> {
322 let full_repaint = self.dbl_buf.is_none();
324 if self.dbl_buf.is_none() {
325 self.dbl_buf = Some(DoubleBuffer::new(self.width, self.height));
326 }
327
328 {
330 let dbl = self.dbl_buf.as_mut().unwrap();
331 dbl.swap();
332 dbl.current_mut().clear();
333 }
334
335 let render_buf = std::mem::replace(
338 self.dbl_buf.as_mut().unwrap().current_mut(),
339 Buffer::new(1, 1),
340 );
341 let mut frame = Frame::from_buffer(render_buf, &mut self.pool);
342 self.model.view(&mut frame);
343
344 *self.dbl_buf.as_mut().unwrap().current_mut() = frame.buffer;
346
347 let dbl = self.dbl_buf.as_ref().unwrap();
349 let diff = if full_repaint {
350 None
351 } else {
352 Some(BufferDiff::compute(dbl.previous(), dbl.current()))
353 };
354 let buf = dbl.current().clone();
355
356 self.backend
357 .presenter_mut()
358 .present_ui_owned(buf, diff.as_ref(), full_repaint);
359
360 self.dirty = false;
361 self.frame_idx += 1;
362
363 if self.frame_idx.is_multiple_of(POOL_GC_INTERVAL_FRAMES) {
366 let Self { dbl_buf, pool, .. } = self;
367 let dbl = dbl_buf.as_ref().unwrap();
368 pool.gc(&[dbl.current(), dbl.previous()]);
369 }
370 Ok(())
371 }
372
373 fn execute_cmd(&mut self, cmd: Cmd<M::Message>) {
374 match cmd {
375 Cmd::None => {}
376 Cmd::Quit => {
377 self.running = false;
378 }
379 Cmd::Msg(m) => {
380 let cmd = self.model.update(m);
381 self.execute_cmd(cmd);
382 }
383 Cmd::Batch(cmds) => {
384 for c in cmds {
385 self.execute_cmd(c);
386 if !self.running {
387 break;
388 }
389 }
390 }
391 Cmd::Sequence(cmds) => {
392 for c in cmds {
393 self.execute_cmd(c);
394 if !self.running {
395 break;
396 }
397 }
398 }
399 Cmd::Tick(duration) => {
400 self.tick_rate = Some(duration);
401 }
402 Cmd::Log(text) => {
403 let _ = self.backend.presenter_mut().write_log(&text);
404 }
405 Cmd::Task(_spec, f) => {
406 let msg = f();
408 let cmd = self.model.update(msg);
409 self.execute_cmd(cmd);
410 }
411 Cmd::SetMouseCapture(enabled) => {
412 let mut features = self.backend.events_mut().features();
413 features.mouse_capture = enabled;
414 let _ = self.backend.events_mut().set_features(features);
415 }
416 Cmd::SaveState | Cmd::RestoreState => {
417 }
419 }
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
427 use ftui_render::cell::Cell;
428 use ftui_render::drawing::Draw;
429 use pretty_assertions::assert_eq;
430
431 struct Counter {
434 value: i32,
435 initialized: bool,
436 }
437
438 #[derive(Debug)]
439 enum CounterMsg {
440 Increment,
441 Decrement,
442 Reset,
443 Quit,
444 LogValue,
445 BatchIncrement(usize),
446 SpawnTask,
447 }
448
449 impl From<Event> for CounterMsg {
450 fn from(event: Event) -> Self {
451 match event {
452 Event::Key(k) if k.code == KeyCode::Char('+') => CounterMsg::Increment,
453 Event::Key(k) if k.code == KeyCode::Char('-') => CounterMsg::Decrement,
454 Event::Key(k) if k.code == KeyCode::Char('r') => CounterMsg::Reset,
455 Event::Key(k) if k.code == KeyCode::Char('q') => CounterMsg::Quit,
456 Event::Tick => CounterMsg::Increment,
457 _ => CounterMsg::Increment,
458 }
459 }
460 }
461
462 impl Model for Counter {
463 type Message = CounterMsg;
464
465 fn init(&mut self) -> Cmd<Self::Message> {
466 self.initialized = true;
467 Cmd::none()
468 }
469
470 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
471 match msg {
472 CounterMsg::Increment => {
473 self.value += 1;
474 Cmd::none()
475 }
476 CounterMsg::Decrement => {
477 self.value -= 1;
478 Cmd::none()
479 }
480 CounterMsg::Reset => {
481 self.value = 0;
482 Cmd::none()
483 }
484 CounterMsg::Quit => Cmd::quit(),
485 CounterMsg::LogValue => Cmd::log(format!("value={}", self.value)),
486 CounterMsg::BatchIncrement(n) => {
487 let cmds: Vec<_> = (0..n).map(|_| Cmd::msg(CounterMsg::Increment)).collect();
488 Cmd::batch(cmds)
489 }
490 CounterMsg::SpawnTask => Cmd::task(|| CounterMsg::Increment),
491 }
492 }
493
494 fn view(&self, frame: &mut Frame) {
495 let text = format!("Count: {}", self.value);
496 for (i, c) in text.chars().enumerate() {
497 if (i as u16) < frame.width() {
498 frame.buffer.set_raw(i as u16, 0, Cell::from_char(c));
499 }
500 }
501 }
502 }
503
504 struct GraphemeChurn {
508 value: u32,
509 }
510
511 impl Model for GraphemeChurn {
512 type Message = CounterMsg;
513
514 fn init(&mut self) -> Cmd<Self::Message> {
515 Cmd::none()
516 }
517
518 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
519 if let CounterMsg::Increment = msg {
520 self.value = self.value.wrapping_add(1);
521 }
522 Cmd::none()
523 }
524
525 fn view(&self, frame: &mut Frame) {
526 let base = char::from_u32(0x4e00 + (self.value % 2048)).unwrap_or('字');
527 let text = format!("{base}\u{0301}");
528 frame.print_text(0, 0, &text, Cell::default());
529 }
530 }
531
532 fn key_event(c: char) -> Event {
533 Event::Key(KeyEvent {
534 code: KeyCode::Char(c),
535 modifiers: Modifiers::empty(),
536 kind: KeyEventKind::Press,
537 })
538 }
539
540 fn new_counter(value: i32) -> Counter {
541 Counter {
542 value,
543 initialized: false,
544 }
545 }
546
547 fn new_grapheme_churn() -> GraphemeChurn {
548 GraphemeChurn { value: 0 }
549 }
550
551 #[test]
554 fn new_creates_uninitialized_program() {
555 let prog = StepProgram::new(new_counter(0), 80, 24);
556 assert!(!prog.is_initialized());
557 assert!(prog.is_running());
558 assert_eq!(prog.size(), (80, 24));
559 assert_eq!(prog.frame_idx(), 0);
560 assert!(prog.tick_rate().is_none());
561 }
562
563 #[test]
564 fn init_initializes_model_and_renders_first_frame() {
565 let mut prog = StepProgram::new(new_counter(0), 80, 24);
566 prog.init().unwrap();
567
568 assert!(prog.is_initialized());
569 assert!(prog.model().initialized);
570 assert_eq!(prog.frame_idx(), 1); let outputs = prog.outputs();
573 assert!(outputs.last_buffer.is_some());
574 assert!(outputs.last_full_repaint_hint); assert_eq!(outputs.last_patches.len(), 1);
576 let stats = outputs
577 .last_patch_stats
578 .expect("patch stats should be captured");
579 assert_eq!(stats.patch_count, 1);
580 assert_eq!(stats.dirty_cells, 80 * 24);
581 }
582
583 #[test]
584 #[should_panic(expected = "init() called twice")]
585 fn double_init_panics() {
586 let mut prog = StepProgram::new(new_counter(0), 80, 24);
587 prog.init().unwrap();
588 prog.init().unwrap();
589 }
590
591 #[test]
592 #[should_panic(expected = "step() called before init()")]
593 fn step_before_init_panics() {
594 let mut prog = StepProgram::new(new_counter(0), 80, 24);
595 let _ = prog.step();
596 }
597
598 #[test]
601 fn step_processes_pushed_events() {
602 let mut prog = StepProgram::new(new_counter(0), 80, 24);
603 prog.init().unwrap();
604
605 prog.push_event(key_event('+'));
606 prog.push_event(key_event('+'));
607 prog.push_event(key_event('+'));
608 let result = prog.step().unwrap();
609
610 assert!(result.running);
611 assert!(result.rendered);
612 assert_eq!(result.events_processed, 3);
613 assert_eq!(prog.model().value, 3);
614 }
615
616 #[test]
617 fn step_with_no_events_does_not_render() {
618 let mut prog = StepProgram::new(new_counter(0), 80, 24);
619 prog.init().unwrap();
620
621 prog.take_outputs();
623
624 let result = prog.step().unwrap();
625 assert!(result.running);
626 assert!(!result.rendered);
627 assert_eq!(result.events_processed, 0);
628 }
629
630 #[test]
631 fn quit_event_stops_program() {
632 let mut prog = StepProgram::new(new_counter(0), 80, 24);
633 prog.init().unwrap();
634
635 prog.push_event(key_event('+'));
636 prog.push_event(key_event('q'));
637 prog.push_event(key_event('+')); let result = prog.step().unwrap();
639
640 assert!(!result.running);
641 assert!(!prog.is_running());
642 assert_eq!(prog.model().value, 1); }
644
645 #[test]
646 fn step_after_quit_returns_immediately() {
647 let mut prog = StepProgram::new(new_counter(0), 80, 24);
648 prog.init().unwrap();
649
650 prog.push_event(key_event('q'));
651 prog.step().unwrap();
652
653 prog.push_event(key_event('+'));
655 let result = prog.step().unwrap();
656 assert!(!result.running);
657 assert!(!result.rendered);
658 assert_eq!(result.events_processed, 0);
659 assert_eq!(prog.model().value, 0);
660 }
661
662 #[test]
665 fn resize_updates_dimensions() {
666 let mut prog = StepProgram::new(new_counter(0), 80, 24);
667 prog.init().unwrap();
668
669 prog.resize(120, 40);
670 prog.step().unwrap();
671
672 assert_eq!(prog.size(), (120, 40));
673 }
674
675 #[test]
676 fn resize_produces_correctly_sized_buffer() {
677 let mut prog = StepProgram::new(new_counter(42), 80, 24);
678 prog.init().unwrap();
679
680 prog.resize(40, 10);
681 prog.step().unwrap();
682
683 let outputs = prog.outputs();
684 let buf = outputs.last_buffer.as_ref().unwrap();
685 assert_eq!(buf.width(), 40);
686 assert_eq!(buf.height(), 10);
687 }
688
689 #[test]
692 fn tick_fires_when_rate_elapsed() {
693 let mut prog = StepProgram::new(new_counter(0), 80, 24);
694 prog.init().unwrap();
695
696 prog.push_event(key_event('+')); prog.step().unwrap();
699
700 prog.model_mut().value = 0;
702 prog.execute_cmd(Cmd::tick(Duration::from_millis(100)));
704 prog.dirty = false; prog.advance_time(Duration::from_millis(50));
708 let result = prog.step().unwrap();
709 assert_eq!(prog.model().value, 0);
710 assert!(!result.rendered);
711
712 prog.advance_time(Duration::from_millis(60));
714 let result = prog.step().unwrap();
715 assert_eq!(prog.model().value, 1); assert!(result.rendered);
717 }
718
719 #[test]
720 fn tick_uses_deterministic_clock() {
721 let mut prog = StepProgram::new(new_counter(0), 80, 24);
722 prog.init().unwrap();
723 prog.execute_cmd(Cmd::tick(Duration::from_millis(100)));
724
725 prog.set_time(Duration::from_millis(200));
727 prog.step().unwrap();
728 assert_eq!(prog.model().value, 1);
729
730 prog.set_time(Duration::from_millis(350));
732 prog.step().unwrap();
733 assert_eq!(prog.model().value, 2);
734 }
735
736 #[test]
739 fn log_command_captures_to_presenter() {
740 let mut prog = StepProgram::new(new_counter(5), 80, 24);
741 prog.init().unwrap();
742
743 prog.execute_cmd(Cmd::msg(CounterMsg::LogValue));
745
746 let outputs = prog.outputs();
747 assert_eq!(outputs.logs, vec!["value=5"]);
748 }
749
750 #[test]
751 fn batch_command_executes_all() {
752 let mut prog = StepProgram::new(new_counter(0), 80, 24);
753 prog.init().unwrap();
754
755 prog.execute_cmd(Cmd::msg(CounterMsg::BatchIncrement(5)));
756 assert_eq!(prog.model().value, 5);
757 }
758
759 #[test]
760 fn task_executes_synchronously() {
761 let mut prog = StepProgram::new(new_counter(0), 80, 24);
762 prog.init().unwrap();
763
764 prog.execute_cmd(Cmd::msg(CounterMsg::SpawnTask));
765 assert_eq!(prog.model().value, 1); }
767
768 #[test]
769 fn set_mouse_capture_updates_features() {
770 let mut prog = StepProgram::new(new_counter(0), 80, 24);
771 prog.init().unwrap();
772
773 prog.execute_cmd(Cmd::set_mouse_capture(true));
774 assert!(prog.backend().events.features().mouse_capture);
775
776 prog.execute_cmd(Cmd::set_mouse_capture(false));
777 assert!(!prog.backend().events.features().mouse_capture);
778 }
779
780 #[test]
783 fn rendered_buffer_reflects_model_state() {
784 let mut prog = StepProgram::new(new_counter(42), 80, 24);
785 prog.init().unwrap();
786
787 let outputs = prog.outputs();
788 let buf = outputs.last_buffer.as_ref().unwrap();
789
790 assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('C'));
792 assert_eq!(buf.get(7, 0).unwrap().content.as_char(), Some('4'));
793 assert_eq!(buf.get(8, 0).unwrap().content.as_char(), Some('2'));
794 }
795
796 #[test]
797 fn subsequent_renders_produce_diffs() {
798 let mut prog = StepProgram::new(new_counter(0), 80, 24);
799 prog.init().unwrap();
800
801 let outputs = prog.take_outputs();
803 assert!(outputs.last_full_repaint_hint);
804
805 prog.push_event(key_event('+'));
807 prog.step().unwrap();
808
809 let outputs = prog.outputs();
810 assert!(!outputs.last_full_repaint_hint);
811 assert!(!outputs.last_patches.is_empty());
812 let stats = outputs
813 .last_patch_stats
814 .expect("patch stats should be captured");
815 assert!(stats.patch_count >= 1);
816 assert!(stats.dirty_cells >= 1);
817 }
818
819 #[test]
820 fn take_outputs_clears_state() {
821 let mut prog = StepProgram::new(new_counter(0), 80, 24);
822 prog.init().unwrap();
823
824 let outputs = prog.take_outputs();
825 assert!(outputs.last_buffer.is_some());
826
827 let outputs = prog.outputs();
829 assert!(outputs.last_buffer.is_none());
830 assert!(outputs.logs.is_empty());
831 }
832
833 #[test]
836 fn identical_inputs_produce_identical_outputs() {
837 fn run_scenario() -> (i32, u64, Vec<Option<char>>) {
838 let mut prog = StepProgram::new(new_counter(0), 20, 1);
839 prog.init().unwrap();
840
841 prog.push_event(key_event('+'));
842 prog.push_event(key_event('+'));
843 prog.push_event(key_event('-'));
844 prog.push_event(key_event('+'));
845 prog.step().unwrap();
846
847 let outputs = prog.outputs();
848 let buf = outputs.last_buffer.as_ref().unwrap();
849 let chars: Vec<Option<char>> = (0..20)
850 .map(|x| buf.get(x, 0).and_then(|c| c.content.as_char()))
851 .collect();
852
853 (prog.model().value, prog.frame_idx(), chars)
854 }
855
856 let (v1, f1, c1) = run_scenario();
857 let (v2, f2, c2) = run_scenario();
858 let (v3, f3, c3) = run_scenario();
859
860 assert_eq!(v1, v2);
861 assert_eq!(v2, v3);
862 assert_eq!(v1, 2); assert_eq!(f1, f2);
864 assert_eq!(f2, f3);
865 assert_eq!(c1, c2);
866 assert_eq!(c2, c3);
867 }
868
869 #[test]
872 fn with_backend_uses_provided_backend() {
873 let mut backend = WebBackend::new(100, 50);
874 backend.clock_mut().set(Duration::from_secs(10));
875
876 let prog = StepProgram::with_backend(new_counter(0), backend);
877 assert_eq!(prog.size(), (100, 50));
878 }
879
880 #[test]
883 fn multi_step_interaction() {
884 let mut prog = StepProgram::new(new_counter(0), 80, 24);
885 prog.init().unwrap();
886
887 prog.push_event(key_event('+'));
889 prog.push_event(key_event('+'));
890 let r1 = prog.step().unwrap();
891 assert_eq!(r1.events_processed, 2);
892 assert!(r1.rendered);
893 assert_eq!(prog.model().value, 2);
894
895 prog.push_event(key_event('-'));
897 let r2 = prog.step().unwrap();
898 assert_eq!(r2.events_processed, 1);
899 assert_eq!(prog.model().value, 1);
900
901 let r3 = prog.step().unwrap();
903 assert_eq!(r3.events_processed, 0);
904 assert!(!r3.rendered);
905
906 assert!(r2.frame_idx > r1.frame_idx);
908 assert_eq!(r3.frame_idx, r2.frame_idx); }
910
911 #[test]
912 fn periodic_pool_gc_bounds_grapheme_growth() {
913 let mut prog = StepProgram::new(new_grapheme_churn(), 8, 1);
914 prog.init().unwrap();
915 prog.execute_cmd(Cmd::tick(Duration::from_millis(1)));
916
917 let mut peak_pool_len = prog.pool().len();
918 for _ in 0..2000 {
919 prog.advance_time(Duration::from_millis(1));
920 let _ = prog.step().unwrap();
921 peak_pool_len = peak_pool_len.max(prog.pool().len());
922 }
923
924 let final_pool_len = prog.pool().len();
925 assert!(
926 peak_pool_len <= (POOL_GC_INTERVAL_FRAMES as usize).saturating_add(2),
927 "peak grapheme pool length should stay bounded by GC interval (peak={peak_pool_len})"
928 );
929 assert!(
930 final_pool_len <= (POOL_GC_INTERVAL_FRAMES as usize).saturating_add(2),
931 "final grapheme pool length should stay bounded by GC interval (final={final_pool_len})"
932 );
933 }
934}