1use crate::engine::ecs::ComponentId;
2use crate::engine::ecs::World;
3use crate::engine::ecs::component::{
4 ColorComponent, EmissiveComponent, OpacityComponent, RaycastableComponent, RenderableComponent,
5 SerializeComponent, TextComponent, TextInputComponent, TextInputGlyphHitComponent,
6 TextShadowComponent, TextureComponent, TextureFilteringComponent, TransformComponent,
7 TransparentCutoutComponent, UVComponent,
8};
9use crate::engine::ecs::{EventSignal, IntentValue};
10use crate::engine::graphics::TextureFiltering;
11use crate::engine::graphics::VisualWorld;
12#[derive(Debug, Default)]
13pub struct TextSystem;
14#[derive(Debug, Clone, Copy)]
15struct WordWrapState {
16 col: usize,
17 row: usize,
18 max_col: usize,
20 line_count: usize,
21 last_wrap_allowed: bool,
22 wrap_at: usize,
23 word_wrap: bool,
24 font_size: f32,
25}
26impl WordWrapState {
27 const TAB_WIDTH: usize = 4;
28 fn new(wrap_at: usize, word_wrap: bool, font_size: f32) -> Self {
29 Self {
30 col: 0,
31 row: 0,
32 max_col: 0,
33 line_count: 0,
34 last_wrap_allowed: false,
35 wrap_at,
36 word_wrap,
37 font_size,
38 }
39 }
40 fn newline(&mut self) {
41 self.max_col = self.max_col.max(self.col);
42 self.row += 1;
43 self.col = 0;
44 self.line_count = 0;
45 self.last_wrap_allowed = false;
46 }
47 fn apply_wrap_if_needed(&mut self) {
48 if self.wrap_at == 0 || self.line_count < self.wrap_at {
49 return;
50 }
51 let should_wrap = if self.word_wrap {
54 self.last_wrap_allowed && self.col > 0
55 } else {
56 true
57 };
58 if should_wrap {
59 self.max_col = self.max_col.max(self.col);
60 self.row += 1;
61 self.col = 0;
62 self.line_count = 0;
63 }
64 }
65 fn cursor_pos(&self) -> (f32, f32) {
66 (
70 (self.col as f32 + 0.5) * self.font_size,
71 -((self.row as f32 + 0.5) * self.font_size),
72 )
73 }
74 fn advance_space(&mut self, i: usize, wrap_allowed_after: &[bool]) {
75 self.col += 1;
76 self.line_count += 1;
77 self.last_wrap_allowed = wrap_allowed_after.get(i).copied().unwrap_or(false);
78 }
79 fn advance_tab(&mut self, i: usize, wrap_allowed_after: &[bool]) {
80 self.col += Self::TAB_WIDTH;
81 self.line_count += Self::TAB_WIDTH;
82 self.last_wrap_allowed = wrap_allowed_after.get(i).copied().unwrap_or(false);
83 }
84 fn advance_glyph(&mut self, i: usize, wrap_allowed_after: &[bool]) {
85 self.col += 1;
86 self.line_count += 1;
87 self.last_wrap_allowed = wrap_allowed_after.get(i).copied().unwrap_or(false);
88 }
89 fn apply_word_wrap_lookahead(&mut self, next_word_len: usize) {
98 if !self.word_wrap || self.wrap_at == 0 || self.col == 0 {
99 return;
100 }
101 if !self.last_wrap_allowed {
102 return;
103 }
104 if self.col + next_word_len > self.wrap_at {
105 self.max_col = self.max_col.max(self.col);
106 self.row += 1;
107 self.col = 0;
108 self.line_count = 0;
109 self.last_wrap_allowed = false;
110 }
111 }
112}
113fn compute_word_run_len(wrap_allowed_after: &[bool]) -> Vec<usize> {
118 let n = wrap_allowed_after.len();
119 let mut out = vec![0; n];
120 let mut run = 0usize;
121 for i in (0..n).rev() {
122 if wrap_allowed_after[i] {
123 run = 0;
124 } else {
125 run += 1;
126 }
127 out[i] = run;
128 }
129 out
130}
131#[derive(Debug, Clone, Copy)]
132enum TextLayoutEvent {
133 BeforeChar {
134 index: usize,
135 ch: char,
136 state: WordWrapState,
137 },
138}
139#[derive(Debug, Clone, Copy)]
140pub struct SpawnedGlyph {
141 pub transform: ComponentId,
142 pub renderable: ComponentId,
143 pub uv: ComponentId,
144 pub texture: ComponentId,
145}
146impl TextSystem {
147 pub(crate) fn on_parent_changed(
148 world: &mut World,
149 emit: &mut dyn crate::engine::ecs::SignalEmitter,
150 env: &crate::engine::ecs::Signal,
151 ) {
152 let Some(EventSignal::ParentChanged {
153 child, new_parent, ..
154 }) = env.event.as_ref()
155 else {
156 return;
157 };
158 let Some(parent) = *new_parent else {
160 return;
161 };
162 if world
163 .get_component_by_id_as::<TextComponent>(parent)
164 .is_none()
165 {
166 return;
167 }
168 if world
171 .get_component_by_id_as::<ColorComponent>(*child)
172 .is_some()
173 {
174 emit.push_intent_now(
175 *child,
176 IntentValue::RegisterColor {
177 component_ids: vec![*child],
178 },
179 );
180 }
181 }
182 fn spawn_glyph_quad(
183 world: &mut World,
184 parent: ComponentId,
185 uvs: Vec<[f32; 2]>,
186 texture_uri: &str,
187 filtering: Option<TextureFiltering>,
188 emissive: Option<bool>,
189 raycastable: Option<RaycastableComponent>,
190 color_override: Option<[f32; 4]>,
191 ) -> (ComponentId, ComponentId, ComponentId) {
192 let renderable_parent = if let Some(rgba) = color_override {
194 let c_id = world.add_component(ColorComponent { rgba });
195 let _ = world.add_child(parent, c_id);
196 c_id
197 } else {
198 parent
199 };
200 let r_id = world.add_component(RenderableComponent::square());
201 let _ = world.add_child(renderable_parent, r_id);
202 if let Some(rc) = raycastable {
203 let rc_id = world.add_component(rc);
204 let _ = world.add_child(r_id, rc_id);
205 }
206 let uv_id = world.add_component(UVComponent { uvs });
207 let _ = world.add_child(r_id, uv_id);
208 let tex_id = world.add_component(TextureComponent::with_uri(texture_uri.to_string()));
209 let _ = world.add_child(r_id, tex_id);
210 if let Some(filtering) = filtering {
211 let f_id = world.add_component(TextureFilteringComponent::new(filtering));
212 let _ = world.add_child(r_id, f_id);
213 }
214 if let Some(enabled) = emissive {
215 let e_id = world.add_component(EmissiveComponent::new(if enabled { 1.0 } else { 0.0 }));
216 let _ = world.add_child(r_id, e_id);
217 }
218 (r_id, uv_id, tex_id)
219 }
220 fn walk_text_layout<F>(
221 text: &str,
222 wrap_at: usize,
223 word_wrap: bool,
224 word_wrap_tokens: &[String],
225 font_size: f32,
226 mut callback: F,
227 ) -> WordWrapState
228 where
229 F: FnMut(TextLayoutEvent) -> bool,
230 {
231 let chars: Vec<char> = text.chars().collect();
232 let wrap_allowed_after = compute_wrap_allowed_after(&chars, word_wrap_tokens);
233 let word_run_len = compute_word_run_len(&wrap_allowed_after);
234 let mut state = WordWrapState::new(wrap_at, word_wrap, font_size);
235 for (i, ch) in chars.iter().copied().enumerate() {
236 if ch == '\n' {
237 if !callback(TextLayoutEvent::BeforeChar {
238 index: i,
239 ch,
240 state,
241 }) {
242 break;
243 }
244 state.newline();
245 continue;
246 }
247 state.apply_wrap_if_needed();
248 if ch == ' ' {
249 if !callback(TextLayoutEvent::BeforeChar {
250 index: i,
251 ch,
252 state,
253 }) {
254 break;
255 }
256 state.advance_space(i, &wrap_allowed_after);
257 continue;
258 }
259 if ch == '\t' {
260 if !callback(TextLayoutEvent::BeforeChar {
261 index: i,
262 ch,
263 state,
264 }) {
265 break;
266 }
267 state.advance_tab(i, &wrap_allowed_after);
268 continue;
269 }
270 state.apply_word_wrap_lookahead(word_run_len.get(i).copied().unwrap_or(1));
271 if !callback(TextLayoutEvent::BeforeChar {
272 index: i,
273 ch,
274 state,
275 }) {
276 break;
277 }
278 state.advance_glyph(i, &wrap_allowed_after);
279 }
280 state.max_col = state.max_col.max(state.col);
281 state
282 }
283 pub fn register_text(
284 &mut self,
285 world: &mut World,
286 _visuals: &mut VisualWorld,
287 component: ComponentId,
288 ) -> Vec<SpawnedGlyph> {
289 let Some(text_comp) = world.get_component_by_id_as::<TextComponent>(component) else {
290 return Vec::new();
291 };
292 if text_comp.is_built() {
293 return Vec::new();
294 }
295 let text = text_comp.text.clone();
296 let wrap_at = text_comp.wrap_at;
297 let font_size = text_comp.font_size;
298 let word_wrap = text_comp.word_wrap;
299 let word_wrap_tokens = text_comp.word_wrap_tokens.clone();
300 let inherited_font_texture_uri = world
303 .children_of(component)
304 .iter()
305 .find_map(|&ch| {
306 world
307 .get_component_by_id_as::<TextureComponent>(ch)
308 .and_then(|t| t.uri().map(|s| s.to_string()))
309 })
310 .unwrap_or_else(|| "assets/textures/font_system.dds".to_string());
311 let inherited_filtering = world.children_of(component).iter().find_map(|&ch| {
314 world
315 .get_component_by_id_as::<TextureFilteringComponent>(ch)
316 .map(|c| c.filtering)
317 });
318 let inherited_emissive = world.children_of(component).iter().find_map(|&ch| {
321 world
322 .get_component_by_id_as::<EmissiveComponent>(ch)
323 .map(|e| e.intensity > 0.0)
324 });
325 let inherited_raycastable = world.children_of(component).iter().find_map(|&ch| {
329 world
330 .get_component_by_id_as::<RaycastableComponent>(ch)
331 .copied()
332 .filter(|r| r.enable)
333 });
334 let shadow: Option<TextShadowComponent> =
337 world.children_of(component).iter().find_map(|&ch| {
338 world
339 .get_component_by_id_as::<TextShadowComponent>(ch)
340 .copied()
341 });
342 if let Some(text_comp) = world.get_component_by_id_as_mut::<TextComponent>(component) {
344 text_comp.mark_built();
345 }
346 let text_input_root = {
347 let mut cur = world.parent_of(component);
348 let mut found = None;
349 while let Some(node) = cur {
350 if world
351 .get_component_by_id_as::<TextInputComponent>(node)
352 .is_some()
353 {
354 found = Some(node);
355 break;
356 }
357 cur = world.parent_of(node);
358 }
359 found
360 };
361 let mut spawned = Vec::new();
362 let is_text_input = text_input_root.is_some();
363 let _ = Self::walk_text_layout(
364 &text,
365 wrap_at,
366 word_wrap,
367 &word_wrap_tokens,
368 font_size,
369 |event| {
370 let TextLayoutEvent::BeforeChar {
371 index: i,
372 ch,
373 state,
374 } = event;
375 if ch == ' ' || ch == '\t' {
376 if !is_text_input {
377 return true;
378 }
379 let (x, y) = state.cursor_pos();
380 let width = if ch == '\t' {
381 font_size * WordWrapState::TAB_WIDTH as f32
382 } else {
383 font_size
384 };
385 let t = TransformComponent::new()
386 .with_position(x, y, 0.0)
387 .with_scale(width, font_size, 1.0);
388 let t_id = world.add_component(t);
389 let _ = world.add_child(component, t_id);
390 let t_serialize = world.add_component(SerializeComponent::off());
391 let _ = world.add_child(t_id, t_serialize);
392 let color = world.add_component(ColorComponent {
393 rgba: [0.0, 0.0, 0.0, 0.0],
394 });
395 let _ = world.add_child(t_id, color);
396 let r_id = world.add_component(RenderableComponent::square());
397 let _ = world.add_child(color, r_id);
398 if let Some(rc) = inherited_raycastable {
399 let rc_id = world.add_component(rc);
400 let _ = world.add_child(r_id, rc_id);
401 }
402 let opacity = world.add_component(OpacityComponent::new().with_opacity(0.0));
403 let _ = world.add_child(r_id, opacity);
404 let hit = world.add_component(TextInputGlyphHitComponent {
405 text_input_root: text_input_root.unwrap(),
406 text_target: component,
407 char_index: i,
408 });
409 let _ = world.add_child(r_id, hit);
410 return true;
411 }
412 if ch == '\n' {
413 return true;
414 }
415 let (x, y) = state.cursor_pos();
416 let t = TransformComponent::new()
417 .with_position(x, y, 0.0)
418 .with_scale(font_size, font_size, 1.0);
419 let t_id = world.add_component(t);
420 let _ = world.add_child(component, t_id);
421 let t_serialize = world.add_component(SerializeComponent::off());
422 let _ = world.add_child(t_id, t_serialize);
423 let glyph_uvs = uvs_for_glyph(ch);
424 let (r_id, uv_id, tex_id) = Self::spawn_glyph_quad(
425 world,
426 t_id,
427 glyph_uvs.clone(),
428 &inherited_font_texture_uri,
429 inherited_filtering,
430 inherited_emissive,
431 inherited_raycastable,
432 None,
433 );
434 if let Some(text_input_root) = text_input_root {
435 let hit = world.add_component(TextInputGlyphHitComponent {
436 text_input_root,
437 text_target: component,
438 char_index: i,
439 });
440 let _ = world.add_child(r_id, hit);
441 }
442 if let Some(shadow) = shadow {
443 let z_back = -shadow.offset[2].abs();
444 let mut spawn_shadow = |scale: f32, z: f32| {
445 let ot = TransformComponent::new()
446 .with_position(shadow.offset[0], shadow.offset[1], z)
447 .with_scale(scale, scale, 1.0);
448 let ot_id = world.add_component(ot);
449 let _ = world.add_child(t_id, ot_id);
450 let ot_serialize = world.add_component(SerializeComponent::off());
451 let _ = world.add_child(ot_id, ot_serialize);
452 let _ = Self::spawn_glyph_quad(
454 world,
455 ot_id,
456 glyph_uvs.clone(),
457 &inherited_font_texture_uri,
458 inherited_filtering,
459 inherited_emissive,
460 None,
461 Some(shadow.rgba),
462 );
463 };
464 if shadow.scale > 1.0 {
466 spawn_shadow(1.0 / (shadow.scale * 1.3), z_back);
467 spawn_shadow(shadow.scale, z_back * 2.0);
468 } else {
469 spawn_shadow(shadow.scale, z_back);
470 }
471 }
472 spawned.push(SpawnedGlyph {
473 transform: t_id,
474 renderable: r_id,
475 uv: uv_id,
476 texture: tex_id,
477 });
478 true
479 },
480 );
481 spawned
482 }
483 pub fn measure(
489 text: &str,
490 wrap_at: usize,
491 word_wrap: bool,
492 word_wrap_tokens: &[String],
493 font_size: f32,
494 ) -> (f32, f32) {
495 let state = Self::walk_text_layout(
496 text,
497 wrap_at,
498 word_wrap,
499 word_wrap_tokens,
500 font_size,
501 |_| true,
502 );
503 (
504 state.max_col as f32 * font_size,
505 (state.row + 1) as f32 * font_size,
506 )
507 }
508 pub fn layout_position_for_index(
509 text: &str,
510 index: usize,
511 wrap_at: usize,
512 word_wrap: bool,
513 word_wrap_tokens: &[String],
514 font_size: f32,
515 ) -> (f32, f32) {
516 let mut result = None;
517 let state = Self::walk_text_layout(
518 text,
519 wrap_at,
520 word_wrap,
521 word_wrap_tokens,
522 font_size,
523 |event| match event {
524 TextLayoutEvent::BeforeChar {
525 index: i, state, ..
526 } if i == index => {
527 result = Some(state.cursor_pos());
528 false
529 }
530 _ => true,
531 },
532 );
533 result.unwrap_or_else(|| state.cursor_pos())
534 }
535 pub fn caret_local_position(
536 text: &str,
537 caret: usize,
538 wrap_at: usize,
539 word_wrap: bool,
540 word_wrap_tokens: &[String],
541 font_size: f32,
542 ) -> (f32, f32) {
543 Self::layout_position_for_index(
544 text,
545 caret,
546 wrap_at,
547 word_wrap,
548 word_wrap_tokens,
549 font_size,
550 )
551 }
552}
553fn compute_wrap_allowed_after(chars: &[char], tokens: &[String]) -> Vec<bool> {
554 let mut wrap_allowed_after: Vec<bool> = vec![false; chars.len()];
555 for (i, &ch) in chars.iter().enumerate() {
557 if ch == ' ' || ch == '\t' {
558 wrap_allowed_after[i] = true;
559 }
560 }
561 for tok in tokens {
562 if tok.is_empty() {
563 continue;
564 }
565 if tok == " " || tok == "\t" {
567 continue;
568 }
569 let tok_chars: Vec<char> = tok.chars().collect();
570 if tok_chars.is_empty() {
571 continue;
572 }
573 if tok_chars.len() > chars.len() {
574 continue;
575 }
576 for start in 0..=(chars.len() - tok_chars.len()) {
577 let mut matched = true;
578 for (j, &tch) in tok_chars.iter().enumerate() {
579 if chars[start + j] != tch {
580 matched = false;
581 break;
582 }
583 }
584 if matched {
585 let end = start + tok_chars.len() - 1;
586 wrap_allowed_after[end] = true;
587 }
588 }
589 }
590 wrap_allowed_after
591}
592impl crate::engine::ecs::system::System for TextSystem {
593 fn tick(
594 &mut self,
595 _world: &mut World,
596 _visuals: &mut VisualWorld,
597 _input: &crate::engine::user_input::InputState,
598 _dt_sec: f32,
599 ) {
600 }
602}
603fn uvs_for_glyph(ch: char) -> Vec<[f32; 2]> {
604 const COLS: f32 = 16.0;
605 const ROWS: f32 = 16.0;
606 let code: u8 = if ch.is_ascii() {
610 ch as u8
611 } else {
612 b'?' };
614 let row = (code / 16) as f32;
615 let col = (code % 16) as f32;
616 let u0 = col / COLS;
617 let u1 = (col + 1.0) / COLS;
618 let v0 = row / ROWS;
624 let v1 = (row + 1.0) / ROWS;
625 vec![[u0, v1], [u1, v1], [u1, v0], [u0, v0]]
629}
630#[cfg(test)]
631mod tests {
632 use super::TextSystem;
633 use crate::engine::ecs::World;
634 use crate::engine::ecs::component::{
635 SerializeComponent, TextComponent, TextInputGlyphHitComponent, TextureComponent,
636 TransformComponent,
637 };
638 use crate::engine::graphics::VisualWorld;
639 fn collect_descendants(
640 world: &World,
641 root: crate::engine::ecs::ComponentId,
642 ) -> Vec<crate::engine::ecs::ComponentId> {
643 let mut stack = vec![root];
644 let mut out = Vec::new();
645 while let Some(node) = stack.pop() {
646 for child in world.children_of(node).iter().copied() {
647 out.push(child);
648 stack.push(child);
649 }
650 }
651 out
652 }
653 fn register_text_scales_spawned_glyphs_by_font_size() {
654 let mut world = World::default();
655 let mut visuals = VisualWorld::new();
656 let text_id = world.add_component(TextComponent::new("A").with_font_size(0.25));
657 let mut text_system = TextSystem::default();
658 let spawned = text_system.register_text(&mut world, &mut visuals, text_id);
659 assert_eq!(spawned.len(), 1);
660 let glyph_t = world
661 .get_component_by_id_as::<crate::engine::ecs::component::TransformComponent>(
662 spawned[0].transform,
663 )
664 .expect("glyph transform");
665 assert!((glyph_t.transform.scale[0] - 0.25).abs() < 1e-6);
666 assert!((glyph_t.transform.scale[1] - 0.25).abs() < 1e-6);
667 }
668 fn register_text_marks_spawned_glyph_roots_serialize_off() {
669 let mut world = World::default();
670 let mut visuals = VisualWorld::new();
671 let text_id = world.add_component(TextComponent::new("A"));
672 let mut text_system = TextSystem::default();
673 let spawned = text_system.register_text(&mut world, &mut visuals, text_id);
674 assert_eq!(spawned.len(), 1);
675 let serialize_marker = world
676 .children_of(spawned[0].transform)
677 .iter()
678 .find(|&&child| {
679 world
680 .get_component_by_id_as::<SerializeComponent>(child)
681 .is_some()
682 })
683 .copied()
684 .expect("expected serialize marker on glyph root");
685 assert!(
686 world
687 .get_component_by_id_as::<SerializeComponent>(serialize_marker)
688 .is_some_and(|serialize| !serialize.enabled)
689 );
690 }
691 fn measure_font_size_scales_text_advance_and_height() {
692 let (w_small, h_small) = TextSystem::measure("AB", 0, true, &[], 0.5);
693 let (w_large, h_large) = TextSystem::measure("AB", 0, true, &[], 2.0);
694 assert!((w_small - 1.0).abs() < 1e-6);
695 assert!((w_large - 4.0).abs() < 1e-6);
696 assert!((h_small - 0.5).abs() < 1e-6);
697 assert!((h_large - 2.0).abs() < 1e-6);
698 }
699 fn layout_position_for_index_matches_caret_local_position() {
700 let text = "hello world";
701 let pos = TextSystem::layout_position_for_index(text, 4, 6, true, &[], 1.0);
702 let caret = TextSystem::caret_local_position(text, 4, 6, true, &[], 1.0);
703 assert_eq!(pos, caret);
704 }
705 fn layout_position_for_index_wraps_to_second_line_after_wrapped_word() {
706 let text = "hello world";
707 let pos = TextSystem::layout_position_for_index(text, 7, 6, true, &[], 1.0);
708 assert_eq!(pos, (1.5, -1.5));
709 }
710 fn layout_position_for_index_wraps_to_second_line_before_wrapped_word() {
711 let text = "hello world";
712 let pos = TextSystem::layout_position_for_index(text, 6, 6, true, &[], 1.0);
713 assert_eq!(pos, (0.5, -1.5));
714 }
715 fn register_text_spawns_text_input_whitespace_helpers() {
716 let mut world = World::default();
717 let mut visuals = VisualWorld::new();
718 let root = world.add_component(TransformComponent::new());
719 let text_input = world.add_component(
720 crate::engine::ecs::component::TextInputComponent::new("a b"),
721 );
722 let text = world.add_component(TextComponent::new("a b"));
723 let _ = world.add_child(root, text_input);
724 let _ = world.add_child(text_input, text);
725 let rc =
726 world.add_component(crate::engine::ecs::component::RaycastableComponent::enabled());
727 let _ = world.add_child(text, rc);
728 let mut text_system = TextSystem::default();
729 let spawned = text_system.register_text(&mut world, &mut visuals, text);
730 assert_eq!(spawned.len(), 2);
731 let mut whitespace_hit_count = 0;
732 for descendant in collect_descendants(&world, text) {
733 if world
734 .get_component_by_id_as::<TextInputGlyphHitComponent>(descendant)
735 .is_some()
736 {
737 let has_texture = world.children_of(descendant).iter().copied().any(|grand| {
738 world
739 .get_component_by_id_as::<TextureComponent>(grand)
740 .is_some()
741 });
742 if !has_texture {
743 whitespace_hit_count += 1;
744 }
745 }
746 }
747 assert!(
748 whitespace_hit_count >= 1,
749 "expected at least one whitespace helper hit quad"
750 );
751 }
752 fn register_text_does_not_spawn_whitespace_helpers_for_plain_text() {
753 let mut world = World::default();
754 let mut visuals = VisualWorld::new();
755 let root = world.add_component(TransformComponent::new());
756 let text = world.add_component(TextComponent::new("a b"));
757 let _ = world.add_child(root, text);
758 let rc =
759 world.add_component(crate::engine::ecs::component::RaycastableComponent::enabled());
760 let _ = world.add_child(text, rc);
761 let mut text_system = TextSystem::default();
762 let _ = text_system.register_text(&mut world, &mut visuals, text);
763 let mut whitespace_hit_found = false;
764 for descendant in collect_descendants(&world, text) {
765 if world
766 .get_component_by_id_as::<TextInputGlyphHitComponent>(descendant)
767 .is_some()
768 {
769 let has_texture = world.children_of(descendant).iter().copied().any(|grand| {
770 world
771 .get_component_by_id_as::<TextureComponent>(grand)
772 .is_some()
773 });
774 if !has_texture {
775 whitespace_hit_found = true;
776 }
777 }
778 }
779 assert!(
780 !whitespace_hit_found,
781 "plain text should not spawn whitespace hit helpers"
782 );
783 }
784}