Skip to main content

aetna_core/text/
metrics.rs

1//! Font-backed text measurement and simple word wrapping.
2//!
3//! The production wgpu path uses [`crate::text::atlas::GlyphAtlas`] for
4//! shaping + rasterization; layout, lint, SVG artifacts, and draw-op IR
5//! all share this core layout artifact for measurement. Proportional
6//! text is shaped through `cosmic-text` using bundled UI fonts; the older
7//! TTF-advance path remains as a fallback and for monospace until Aetna
8//! has a bundled mono font.
9
10use crate::tokens;
11use crate::tree::{FontFamily, FontWeight, TextWrap};
12use cosmic_text::{
13    Attrs, Buffer, Cursor, Family, FontSystem, Metrics, Shaping, Weight, Wrap, fontdb,
14};
15use lru::LruCache;
16use std::cell::RefCell;
17use std::num::NonZeroUsize;
18
19const MONO_CHAR_WIDTH_FACTOR: f32 = 0.62;
20
21const BASELINE_MULTIPLIER: f32 = 0.93;
22
23#[derive(Clone, Debug, PartialEq)]
24pub struct TextLine {
25    pub text: String,
26    pub width: f32,
27    /// Top offset from the text layout origin, in logical pixels.
28    pub y: f32,
29    /// Baseline offset from the text layout origin, in logical pixels.
30    pub baseline: f32,
31    /// Paragraph direction as resolved by the shaping engine.
32    pub rtl: bool,
33}
34
35#[derive(Clone, Debug, PartialEq)]
36pub struct TextLayout {
37    pub lines: Vec<TextLine>,
38    pub width: f32,
39    pub height: f32,
40    pub line_height: f32,
41}
42
43impl TextLayout {
44    pub fn line_count(&self) -> usize {
45        self.lines.len().max(1)
46    }
47
48    pub fn measured(&self) -> MeasuredText {
49        MeasuredText {
50            width: self.width,
51            height: self.height,
52            line_count: self.line_count(),
53        }
54    }
55}
56
57#[derive(Clone, Debug, PartialEq)]
58pub struct MeasuredText {
59    pub width: f32,
60    pub height: f32,
61    pub line_count: usize,
62}
63
64/// Shared text geometry context for measurement, hit-testing, caret
65/// positioning, and selection rectangles.
66///
67/// This is intentionally a thin value over the existing cosmic-text-backed
68/// helpers: callers spell the text/style/wrap inputs once, then ask the
69/// same context for the geometry operation they need. Keeping these calls
70/// together matters for widgets like `text_input`, `text_area`, and
71/// selectable text, where measurement, hit-testing, caret placement, and
72/// selection bands must agree on font, wrap width, and line metrics.
73#[derive(Clone, Debug, PartialEq)]
74pub struct TextGeometry<'a> {
75    text: &'a str,
76    size: f32,
77    family: FontFamily,
78    weight: FontWeight,
79    mono: bool,
80    wrap: TextWrap,
81    available_width: Option<f32>,
82    layout: TextLayout,
83}
84
85impl<'a> TextGeometry<'a> {
86    pub fn new(
87        text: &'a str,
88        size: f32,
89        weight: FontWeight,
90        mono: bool,
91        wrap: TextWrap,
92        available_width: Option<f32>,
93    ) -> Self {
94        Self::new_with_family(
95            text,
96            size,
97            FontFamily::default(),
98            weight,
99            mono,
100            wrap,
101            available_width,
102        )
103    }
104
105    pub fn new_with_family(
106        text: &'a str,
107        size: f32,
108        family: FontFamily,
109        weight: FontWeight,
110        mono: bool,
111        wrap: TextWrap,
112        available_width: Option<f32>,
113    ) -> Self {
114        let layout =
115            layout_text_with_family(text, size, family, weight, mono, wrap, available_width);
116        Self {
117            text,
118            size,
119            family,
120            weight,
121            mono,
122            wrap,
123            available_width,
124            layout,
125        }
126    }
127
128    pub fn text(&self) -> &'a str {
129        self.text
130    }
131
132    pub fn layout(&self) -> &TextLayout {
133        &self.layout
134    }
135
136    pub fn measured(&self) -> MeasuredText {
137        self.layout.measured()
138    }
139
140    pub fn line_height(&self) -> f32 {
141        self.layout.line_height
142    }
143
144    pub fn width(&self) -> f32 {
145        self.layout.width
146    }
147
148    pub fn height(&self) -> f32 {
149        self.layout.height
150    }
151
152    pub fn hit(&self, x: f32, y: f32) -> Option<TextHit> {
153        hit_text_with_family(
154            self.text,
155            self.size,
156            self.family,
157            self.weight,
158            self.wrap,
159            self.available_width,
160            x,
161            y,
162        )
163    }
164
165    /// Hit-test and convert the result to a global byte offset in
166    /// `self.text`. This is the shape most widgets want; cosmic-text's
167    /// cursor reports `(line, byte-in-line)` and hard line breaks need to
168    /// be folded back into the original string.
169    pub fn hit_byte(&self, x: f32, y: f32) -> Option<usize> {
170        let hit = self.hit(x, y)?;
171        Some(self.byte_from_line_position(hit.line, hit.byte_index))
172    }
173
174    pub fn caret_xy(&self, byte_index: usize) -> (f32, f32) {
175        caret_xy_with_family(
176            self.text,
177            byte_index,
178            self.size,
179            self.family,
180            self.weight,
181            self.wrap,
182            self.available_width,
183        )
184    }
185
186    /// X position of the caret at `byte_index`. For single-line text this
187    /// replaces ad-hoc substring measurement and preserves shaping/kerning
188    /// decisions made by the text engine.
189    pub fn prefix_width(&self, byte_index: usize) -> f32 {
190        self.caret_xy(byte_index).0
191    }
192
193    pub fn selection_rects(&self, lo: usize, hi: usize) -> Vec<(f32, f32, f32, f32)> {
194        selection_rects_with_family(
195            self.text,
196            lo,
197            hi,
198            self.size,
199            self.family,
200            self.weight,
201            self.wrap,
202            self.available_width,
203        )
204    }
205
206    fn byte_from_line_position(&self, line: usize, byte_in_line: usize) -> usize {
207        line_position_to_byte(self.text, line, byte_in_line)
208    }
209}
210
211/// Measure text in logical pixels. `available_width` is only used when
212/// `wrap == TextWrap::Wrap`; `None` means measure explicit newlines only.
213pub fn measure_text(
214    text: &str,
215    size: f32,
216    weight: FontWeight,
217    mono: bool,
218    wrap: TextWrap,
219    available_width: Option<f32>,
220) -> MeasuredText {
221    layout_text(text, size, weight, mono, wrap, available_width).measured()
222}
223
224/// Lay out text into measured lines. Coordinates in [`TextLine`] are
225/// relative to the layout origin; callers place the layout inside a
226/// rectangle and apply alignment/vertical centering as needed.
227pub fn layout_text(
228    text: &str,
229    size: f32,
230    weight: FontWeight,
231    mono: bool,
232    wrap: TextWrap,
233    available_width: Option<f32>,
234) -> TextLayout {
235    layout_text_with_family(
236        text,
237        size,
238        FontFamily::default(),
239        weight,
240        mono,
241        wrap,
242        available_width,
243    )
244}
245
246/// Lay out text with an explicit proportional UI font family.
247pub fn layout_text_with_family(
248    text: &str,
249    size: f32,
250    family: FontFamily,
251    weight: FontWeight,
252    mono: bool,
253    wrap: TextWrap,
254    available_width: Option<f32>,
255) -> TextLayout {
256    layout_text_with_line_height_and_family(
257        text,
258        size,
259        line_height(size),
260        family,
261        weight,
262        mono,
263        wrap,
264        available_width,
265    )
266}
267
268/// Lay out text with an explicit line-height token. This is the
269/// preferred path for styled elements; [`layout_text`] remains the
270/// fallback for arbitrary measurement callers.
271#[allow(clippy::too_many_arguments)]
272pub fn layout_text_with_line_height(
273    text: &str,
274    size: f32,
275    line_height: f32,
276    weight: FontWeight,
277    mono: bool,
278    wrap: TextWrap,
279    available_width: Option<f32>,
280) -> TextLayout {
281    layout_text_with_line_height_and_family(
282        text,
283        size,
284        line_height,
285        FontFamily::default(),
286        weight,
287        mono,
288        wrap,
289        available_width,
290    )
291}
292
293#[allow(clippy::too_many_arguments)]
294pub fn layout_text_with_line_height_and_family(
295    text: &str,
296    size: f32,
297    line_height: f32,
298    family: FontFamily,
299    weight: FontWeight,
300    mono: bool,
301    wrap: TextWrap,
302    available_width: Option<f32>,
303) -> TextLayout {
304    // Cache by full shaping inputs. The intrinsic measurement pass
305    // walks subtrees recursively at every flex level, so the same text
306    // node easily gets shaped 2 × tree-depth times per frame.
307    // `ellipsize_text_with_family`'s binary search adds further
308    // repetition by reshaping each candidate prefix. The cache
309    // amortizes all of that to a single hash lookup once the layout
310    // has been computed for the first time. `TextLayout` is fully
311    // owned (no borrows back into `FontSystem`), so the cached values
312    // are safe to clone out across frames. Bounded LRU keeps memory
313    // predictable; eviction is benign — the next call recomputes.
314    let key = ShapeKey {
315        text: Box::from(text),
316        size_bits: size.to_bits(),
317        line_height_bits: line_height.to_bits(),
318        family,
319        weight,
320        mono,
321        wrap,
322        available_width_bits: available_width.map(f32::to_bits),
323    };
324    if let Some(cached) = SHAPE_CACHE.with_borrow_mut(|c| c.get(&key).cloned()) {
325        return cached;
326    }
327    let layout = layout_text_uncached(
328        text,
329        size,
330        line_height,
331        family,
332        weight,
333        mono,
334        wrap,
335        available_width,
336    );
337    SHAPE_CACHE.with_borrow_mut(|c| {
338        c.put(key, layout.clone());
339    });
340    layout
341}
342
343#[allow(clippy::too_many_arguments)]
344fn layout_text_uncached(
345    text: &str,
346    size: f32,
347    line_height: f32,
348    family: FontFamily,
349    weight: FontWeight,
350    mono: bool,
351    wrap: TextWrap,
352    available_width: Option<f32>,
353) -> TextLayout {
354    if !mono
355        && let Some(layout) = layout_text_cosmic(
356            text,
357            size,
358            line_height,
359            family,
360            weight,
361            wrap,
362            available_width,
363        )
364    {
365        return layout;
366    }
367
368    let raw_lines = match (wrap, available_width) {
369        (TextWrap::Wrap, Some(width)) => {
370            wrap_lines_by_width(text, width, size, family, weight, mono)
371        }
372        _ => text.split('\n').map(str::to_string).collect(),
373    };
374    build_layout(raw_lines, size, line_height, family, weight, mono)
375}
376
377/// Return a single-line string that fits within `available_width`,
378/// appending an ellipsis when truncation is needed.
379pub fn ellipsize_text(
380    text: &str,
381    size: f32,
382    weight: FontWeight,
383    mono: bool,
384    available_width: f32,
385) -> String {
386    ellipsize_text_with_family(
387        text,
388        size,
389        FontFamily::default(),
390        weight,
391        mono,
392        available_width,
393    )
394}
395
396pub fn ellipsize_text_with_family(
397    text: &str,
398    size: f32,
399    family: FontFamily,
400    weight: FontWeight,
401    mono: bool,
402    available_width: f32,
403) -> String {
404    if available_width <= 0.0 || text.is_empty() {
405        return String::new();
406    }
407    let full = layout_text_with_family(text, size, family, weight, mono, TextWrap::NoWrap, None);
408    if full.width <= available_width + 0.5 {
409        return text.to_string();
410    }
411
412    let ellipsis = "…";
413    let ellipsis_w =
414        layout_text_with_family(ellipsis, size, family, weight, mono, TextWrap::NoWrap, None).width;
415    if ellipsis_w > available_width + 0.5 {
416        return ellipsis.to_string();
417    }
418
419    let chars: Vec<char> = text.chars().collect();
420    let mut lo = 0usize;
421    let mut hi = chars.len();
422    while lo < hi {
423        let mid = (lo + hi).div_ceil(2);
424        let candidate: String = chars[..mid].iter().collect();
425        let candidate = format!("{candidate}{ellipsis}");
426        let width = layout_text_with_family(
427            &candidate,
428            size,
429            family,
430            weight,
431            mono,
432            TextWrap::NoWrap,
433            None,
434        )
435        .width;
436        if width <= available_width + 0.5 {
437            lo = mid;
438        } else {
439            hi = mid - 1;
440        }
441    }
442
443    let prefix: String = chars[..lo].iter().collect();
444    format!("{prefix}{ellipsis}")
445}
446
447/// Return wrapped text capped to `max_lines`, ellipsizing the final
448/// visible line when truncation is needed.
449pub fn clamp_text_to_lines(
450    text: &str,
451    size: f32,
452    weight: FontWeight,
453    mono: bool,
454    available_width: f32,
455    max_lines: usize,
456) -> String {
457    clamp_text_to_lines_with_family(
458        text,
459        size,
460        FontFamily::default(),
461        weight,
462        mono,
463        available_width,
464        max_lines,
465    )
466}
467
468pub fn clamp_text_to_lines_with_family(
469    text: &str,
470    size: f32,
471    family: FontFamily,
472    weight: FontWeight,
473    mono: bool,
474    available_width: f32,
475    max_lines: usize,
476) -> String {
477    if text.is_empty() || available_width <= 0.0 || max_lines == 0 {
478        return String::new();
479    }
480
481    let layout = layout_text_with_family(
482        text,
483        size,
484        family,
485        weight,
486        mono,
487        TextWrap::Wrap,
488        Some(available_width),
489    );
490    if layout.lines.len() <= max_lines {
491        return text.to_string();
492    }
493
494    let mut lines: Vec<String> = layout
495        .lines
496        .iter()
497        .take(max_lines)
498        .map(|line| line.text.clone())
499        .collect();
500    if let Some(last) = lines.last_mut() {
501        let marked = format!("{last}…");
502        *last = ellipsize_text_with_family(&marked, size, family, weight, mono, available_width);
503    }
504    lines.join("\n")
505}
506
507/// Result of a click-to-caret hit-test against a laid-out text run.
508/// Coordinates are in byte units within the source text — convertible
509/// to character indices via `text.char_indices()`.
510#[derive(Clone, Copy, Debug, PartialEq, Eq)]
511pub struct TextHit {
512    /// Logical line within the source text (zero-based). For a
513    /// single-line input always 0; for a wrapped paragraph this is
514    /// the visual line index (line breaks introduced by `\n` or by
515    /// soft wrapping both bump it).
516    pub line: usize,
517    /// Byte offset within that logical line's text. Snaps to the
518    /// nearest grapheme boundary cosmic-text reports.
519    pub byte_index: usize,
520}
521
522/// Hit-test a pixel `(x, y)` against the laid-out form of `text` and
523/// return the cursor position the click would land at. Coordinates
524/// are relative to the layout origin (top-left of the rect that the
525/// layout pass would draw the text into). Returns `None` when the
526/// point is above/left of the first glyph; cosmic-text's clamping
527/// behavior places clicks below the last line at end-of-text.
528///
529/// Used by text-input widgets: clicking inside the rect produces a
530/// caret position by routing the local pointer (pointer minus rect
531/// origin) through this function.
532pub fn hit_text(
533    text: &str,
534    size: f32,
535    weight: FontWeight,
536    wrap: TextWrap,
537    available_width: Option<f32>,
538    x: f32,
539    y: f32,
540) -> Option<TextHit> {
541    hit_text_with_family(
542        text,
543        size,
544        FontFamily::default(),
545        weight,
546        wrap,
547        available_width,
548        x,
549        y,
550    )
551}
552
553#[allow(clippy::too_many_arguments)]
554pub fn hit_text_with_family(
555    text: &str,
556    size: f32,
557    family: FontFamily,
558    weight: FontWeight,
559    wrap: TextWrap,
560    available_width: Option<f32>,
561    x: f32,
562    y: f32,
563) -> Option<TextHit> {
564    FONT_SYSTEM.with_borrow_mut(|font_system| {
565        let line_height = line_height(size);
566        let mut buffer = Buffer::new(font_system, Metrics::new(size, line_height));
567        buffer.set_wrap(match wrap {
568            TextWrap::NoWrap => Wrap::None,
569            TextWrap::Wrap => Wrap::WordOrGlyph,
570        });
571        buffer.set_size(
572            match wrap {
573                TextWrap::NoWrap => None,
574                TextWrap::Wrap => available_width,
575            },
576            None,
577        );
578        let attrs = Attrs::new()
579            .family(Family::Name(family.family_name()))
580            .weight(cosmic_weight(weight));
581        buffer.set_text(text, &attrs, Shaping::Advanced, None);
582        buffer.shape_until_scroll(font_system, false);
583        let cursor = buffer.hit(x, y)?;
584        Some(TextHit {
585            line: cursor.line,
586            byte_index: cursor.index,
587        })
588    })
589}
590
591/// Pixel position of the caret at byte offset `byte_index` in the
592/// laid-out form of `text`. Coordinates are relative to the layout
593/// origin (top-left of the rect that the layout pass would draw the
594/// text into); `(0.0, 0.0)` is the start of the first line.
595///
596/// Used by multi-line text widgets: the caret bar's `translate()` is
597/// the result of this call. See [`hit_text`] for the inverse.
598///
599/// `byte_index` is interpreted as a byte offset into the source string
600/// where `\n` separates buffer lines. Out-of-range or non-boundary
601/// indices are clamped to the nearest UTF-8 char boundary.
602pub fn caret_xy(
603    text: &str,
604    byte_index: usize,
605    size: f32,
606    weight: FontWeight,
607    wrap: TextWrap,
608    available_width: Option<f32>,
609) -> (f32, f32) {
610    caret_xy_with_family(
611        text,
612        byte_index,
613        size,
614        FontFamily::default(),
615        weight,
616        wrap,
617        available_width,
618    )
619}
620
621pub fn caret_xy_with_family(
622    text: &str,
623    byte_index: usize,
624    size: f32,
625    family: FontFamily,
626    weight: FontWeight,
627    wrap: TextWrap,
628    available_width: Option<f32>,
629) -> (f32, f32) {
630    let (target_line, byte_in_line) = byte_to_line_position(text, byte_index);
631    FONT_SYSTEM.with_borrow_mut(|font_system| {
632        let line_h = line_height(size);
633        let buffer = build_buffer(
634            font_system,
635            text,
636            size,
637            family,
638            weight,
639            wrap,
640            available_width,
641        );
642        let cursor = Cursor::new(target_line, byte_in_line);
643        // cosmic-text's Buffer::cursor_position handles the past-end case
644        // (caret after the last glyph on a line) which highlight() omits
645        // because zero-width segments are filtered out.
646        if let Some((x, y)) = buffer.cursor_position(&cursor) {
647            return (x, y);
648        }
649        // Phantom line beyond the last visible run (e.g. caret right
650        // after a trailing `\n`). Position by line index alone.
651        (0.0, target_line as f32 * line_h)
652    })
653}
654
655/// Per-visual-line highlight rectangles for the byte range `lo..hi`.
656/// Each rect is `(x, y, width, height)` in layout-origin coordinates;
657/// the list is empty when `lo >= hi`.
658///
659/// Used by multi-line text widgets to paint the selection band: a
660/// selection that spans three visual lines yields three rectangles
661/// (partial on the first, full on the middle, partial on the last).
662pub fn selection_rects(
663    text: &str,
664    lo: usize,
665    hi: usize,
666    size: f32,
667    weight: FontWeight,
668    wrap: TextWrap,
669    available_width: Option<f32>,
670) -> Vec<(f32, f32, f32, f32)> {
671    selection_rects_with_family(
672        text,
673        lo,
674        hi,
675        size,
676        FontFamily::default(),
677        weight,
678        wrap,
679        available_width,
680    )
681}
682
683#[allow(clippy::too_many_arguments)]
684pub fn selection_rects_with_family(
685    text: &str,
686    lo: usize,
687    hi: usize,
688    size: f32,
689    family: FontFamily,
690    weight: FontWeight,
691    wrap: TextWrap,
692    available_width: Option<f32>,
693) -> Vec<(f32, f32, f32, f32)> {
694    if lo >= hi {
695        return Vec::new();
696    }
697    let (lo_line, lo_in_line) = byte_to_line_position(text, lo);
698    let (hi_line, hi_in_line) = byte_to_line_position(text, hi);
699    FONT_SYSTEM.with_borrow_mut(|font_system| {
700        let buffer = build_buffer(
701            font_system,
702            text,
703            size,
704            family,
705            weight,
706            wrap,
707            available_width,
708        );
709        let c_lo = Cursor::new(lo_line, lo_in_line);
710        let c_hi = Cursor::new(hi_line, hi_in_line);
711        let mut rects = Vec::new();
712        for run in buffer.layout_runs() {
713            for (x, w) in run.highlight(c_lo, c_hi) {
714                rects.push((x, run.line_top, w, run.line_height));
715            }
716        }
717        rects
718    })
719}
720
721/// Convert a global byte offset in `text` to the (BufferLine index,
722/// byte-in-line) pair that cosmic-text uses for cursors. `\n`
723/// characters are *not* part of any line — they just bump the line
724/// counter.
725fn byte_to_line_position(text: &str, byte_index: usize) -> (usize, usize) {
726    let byte_index = byte_index.min(text.len());
727    let mut line = 0;
728    let mut line_start = 0;
729    for (i, ch) in text.char_indices() {
730        if i >= byte_index {
731            break;
732        }
733        if ch == '\n' {
734            line += 1;
735            line_start = i + ch.len_utf8();
736        }
737    }
738    (line, byte_index - line_start)
739}
740
741fn line_position_to_byte(text: &str, line: usize, byte_in_line: usize) -> usize {
742    let mut current_line = 0;
743    let mut line_start = 0;
744    for (i, ch) in text.char_indices() {
745        if current_line == line {
746            let candidate = line_start + byte_in_line;
747            return clamp_to_char_boundary(text, candidate.min(text.len()));
748        }
749        if ch == '\n' {
750            current_line += 1;
751            line_start = i + ch.len_utf8();
752        }
753    }
754    if current_line == line {
755        clamp_to_char_boundary(text, (line_start + byte_in_line).min(text.len()))
756    } else {
757        text.len()
758    }
759}
760
761fn clamp_to_char_boundary(text: &str, mut byte: usize) -> usize {
762    byte = byte.min(text.len());
763    while byte > 0 && !text.is_char_boundary(byte) {
764        byte -= 1;
765    }
766    byte
767}
768
769fn build_buffer(
770    font_system: &mut FontSystem,
771    text: &str,
772    size: f32,
773    family: FontFamily,
774    weight: FontWeight,
775    wrap: TextWrap,
776    available_width: Option<f32>,
777) -> Buffer {
778    let line_h = line_height(size);
779    let mut buffer = Buffer::new(font_system, Metrics::new(size, line_h));
780    buffer.set_wrap(match wrap {
781        TextWrap::NoWrap => Wrap::None,
782        TextWrap::Wrap => Wrap::WordOrGlyph,
783    });
784    buffer.set_size(
785        match wrap {
786            TextWrap::NoWrap => None,
787            TextWrap::Wrap => available_width,
788        },
789        None,
790    );
791    let attrs = Attrs::new()
792        .family(Family::Name(family.family_name()))
793        .weight(cosmic_weight(weight));
794    buffer.set_text(text, &attrs, Shaping::Advanced, None);
795    buffer.shape_until_scroll(font_system, false);
796    buffer
797}
798
799/// Word-wrap text into lines whose measured width stays within
800/// `max_width` whenever possible. Explicit newlines always split
801/// paragraphs. Oversized words are split by character.
802pub fn wrap_lines(
803    text: &str,
804    max_width: f32,
805    size: f32,
806    weight: FontWeight,
807    mono: bool,
808) -> Vec<String> {
809    wrap_lines_with_family(text, max_width, size, FontFamily::default(), weight, mono)
810}
811
812pub fn wrap_lines_with_family(
813    text: &str,
814    max_width: f32,
815    size: f32,
816    family: FontFamily,
817    weight: FontWeight,
818    mono: bool,
819) -> Vec<String> {
820    if !mono
821        && let Some(layout) = layout_text_cosmic(
822            text,
823            size,
824            line_height(size),
825            family,
826            weight,
827            TextWrap::Wrap,
828            Some(max_width),
829        )
830    {
831        return layout.lines.into_iter().map(|line| line.text).collect();
832    }
833    wrap_lines_by_width(text, max_width, size, family, weight, mono)
834}
835
836fn wrap_lines_by_width(
837    text: &str,
838    max_width: f32,
839    size: f32,
840    family: FontFamily,
841    weight: FontWeight,
842    mono: bool,
843) -> Vec<String> {
844    if max_width <= 0.0 {
845        return vec![String::new()];
846    }
847
848    let ctx = WrapMeasure {
849        max_width,
850        size,
851        family,
852        weight,
853        mono,
854    };
855    let mut out = Vec::new();
856    for paragraph in text.split('\n') {
857        if paragraph.is_empty() {
858            out.push(String::new());
859            continue;
860        }
861
862        let mut line = String::new();
863        for word in paragraph.split_whitespace() {
864            if line.is_empty() {
865                push_word_wrapped(&mut out, &mut line, word, ctx);
866                continue;
867            }
868
869            let candidate = format!("{line} {word}");
870            if line_width_with_family(&candidate, size, family, weight, mono) <= max_width {
871                line = candidate;
872            } else {
873                out.push(std::mem::take(&mut line));
874                push_word_wrapped(&mut out, &mut line, word, ctx);
875            }
876        }
877
878        if !line.is_empty() {
879            out.push(line);
880        }
881    }
882
883    if out.is_empty() {
884        out.push(String::new());
885    }
886    out
887}
888
889/// Measure one single-line string. Newline characters are ignored; use
890/// [`measure_text`] for multi-line text.
891pub fn line_width(text: &str, size: f32, weight: FontWeight, mono: bool) -> f32 {
892    line_width_with_family(text, size, FontFamily::default(), weight, mono)
893}
894
895pub fn line_width_with_family(
896    text: &str,
897    size: f32,
898    family: FontFamily,
899    weight: FontWeight,
900    mono: bool,
901) -> f32 {
902    if !mono
903        && let Some(layout) = layout_text_cosmic(
904            text,
905            size,
906            line_height(size),
907            family,
908            weight,
909            TextWrap::NoWrap,
910            None,
911        )
912    {
913        return layout.width;
914    }
915    line_width_by_ttf(text, size, family, weight, mono)
916}
917
918fn line_width_by_ttf(
919    text: &str,
920    size: f32,
921    family: FontFamily,
922    weight: FontWeight,
923    mono: bool,
924) -> f32 {
925    if mono {
926        return text
927            .chars()
928            .filter(|c| *c != '\n' && *c != '\r')
929            .map(|c| if c == '\t' { 4.0 } else { 1.0 })
930            .sum::<f32>()
931            * size
932            * MONO_CHAR_WIDTH_FACTOR;
933    }
934
935    let Ok(face) = ttf_parser::Face::parse(font_bytes(family, weight), 0) else {
936        return fallback_line_width(text, size, mono);
937    };
938    let scale = size / face.units_per_em() as f32;
939    let fallback_advance = face.units_per_em() as f32 * 0.5;
940    let mut width = 0.0;
941    let mut prev = None;
942
943    for c in text.chars() {
944        if c == '\n' || c == '\r' {
945            continue;
946        }
947        if c == '\t' {
948            width += line_width_with_family("    ", size, family, weight, mono);
949            prev = None;
950            continue;
951        }
952
953        let Some(glyph) = glyph_for(&face, c) else {
954            continue;
955        };
956        if let Some(left) = prev {
957            width += kern(&face, left, glyph) * scale;
958        }
959        width += face
960            .glyph_hor_advance(glyph)
961            .map(|advance| advance as f32)
962            .unwrap_or(fallback_advance)
963            * scale;
964        prev = Some(glyph);
965    }
966    width
967}
968
969pub fn line_height(size: f32) -> f32 {
970    // Styled elements carry an explicit `line_height`; this fallback is
971    // for raw measurement callers and custom `.font_size(...)` values.
972    // Known design-token sizes return their paired Tailwind/shadcn line
973    // height, while arbitrary sizes keep a snapped multiplier.
974    tokens::line_height_for_size(size)
975}
976
977fn build_layout(
978    lines: Vec<String>,
979    size: f32,
980    line_height: f32,
981    family: FontFamily,
982    weight: FontWeight,
983    mono: bool,
984) -> TextLayout {
985    let raw_lines = if lines.is_empty() {
986        vec![String::new()]
987    } else {
988        lines
989    };
990    let lines: Vec<TextLine> = raw_lines
991        .into_iter()
992        .enumerate()
993        .map(|(i, text)| {
994            let y = i as f32 * line_height;
995            TextLine {
996                width: line_width_with_family(&text, size, family, weight, mono),
997                text,
998                y,
999                baseline: y + size * BASELINE_MULTIPLIER,
1000                rtl: false,
1001            }
1002        })
1003        .collect();
1004    let width = lines.iter().map(|line| line.width).fold(0.0, f32::max);
1005    TextLayout {
1006        width,
1007        height: lines.len().max(1) as f32 * line_height,
1008        line_height,
1009        lines,
1010    }
1011}
1012
1013fn layout_text_cosmic(
1014    text: &str,
1015    size: f32,
1016    line_height: f32,
1017    family: FontFamily,
1018    weight: FontWeight,
1019    wrap: TextWrap,
1020    available_width: Option<f32>,
1021) -> Option<TextLayout> {
1022    let options = CosmicLayoutOptions {
1023        size,
1024        line_height,
1025        family,
1026        weight,
1027        wrap,
1028        available_width,
1029    };
1030    FONT_SYSTEM.with_borrow_mut(|font_system| layout_text_cosmic_with(font_system, text, options))
1031}
1032
1033#[derive(Copy, Clone)]
1034struct CosmicLayoutOptions {
1035    size: f32,
1036    line_height: f32,
1037    family: FontFamily,
1038    weight: FontWeight,
1039    wrap: TextWrap,
1040    available_width: Option<f32>,
1041}
1042
1043fn layout_text_cosmic_with(
1044    font_system: &mut FontSystem,
1045    text: &str,
1046    options: CosmicLayoutOptions,
1047) -> Option<TextLayout> {
1048    let CosmicLayoutOptions {
1049        size,
1050        line_height,
1051        family,
1052        weight,
1053        wrap,
1054        available_width,
1055    } = options;
1056    let mut buffer = Buffer::new(font_system, Metrics::new(size, line_height));
1057    buffer.set_wrap(match wrap {
1058        TextWrap::NoWrap => Wrap::None,
1059        TextWrap::Wrap => Wrap::WordOrGlyph,
1060    });
1061    buffer.set_size(
1062        match wrap {
1063            TextWrap::NoWrap => None,
1064            TextWrap::Wrap => available_width,
1065        },
1066        None,
1067    );
1068    let attrs = Attrs::new()
1069        .family(Family::Name(family.family_name()))
1070        .weight(cosmic_weight(weight));
1071    buffer.set_text(text, &attrs, Shaping::Advanced, None);
1072    buffer.shape_until_scroll(font_system, false);
1073
1074    let mut lines = Vec::new();
1075    let mut height: f32 = 0.0;
1076    for run in buffer.layout_runs() {
1077        height = height.max(run.line_top + run.line_height);
1078        lines.push(TextLine {
1079            text: layout_run_text(&run),
1080            width: run.line_w,
1081            y: run.line_top,
1082            baseline: run.line_y,
1083            rtl: run.rtl,
1084        });
1085    }
1086
1087    if lines.is_empty() {
1088        return None;
1089    }
1090
1091    let width = lines.iter().map(|line| line.width).fold(0.0, f32::max);
1092    Some(TextLayout {
1093        lines,
1094        width,
1095        height: height.max(line_height),
1096        line_height,
1097    })
1098}
1099
1100// `FontSystem` construction loads the bundled UI faces and builds a
1101// fontdb. Doing it per text-shape call burned ~22ms in the layout pass
1102// on the wasm showcase — basically all of it. Cache once per thread;
1103// cosmic-text's internal shape cache also accumulates across calls now,
1104// which is the side benefit.
1105thread_local! {
1106    static FONT_SYSTEM: RefCell<FontSystem> = RefCell::new(bundled_font_system());
1107}
1108
1109/// Cache key for [`layout_text_with_line_height_and_family`]. Captures
1110/// every input that influences the produced [`TextLayout`]; floats are
1111/// stored as `to_bits` so they participate in `Hash` / `Eq` directly.
1112/// Same shape on every entry — no enum variant hidden behind a `Box`,
1113/// since the lookup happens once per text node per frame and we want
1114/// the fast path to be a single hash.
1115#[derive(Clone, PartialEq, Eq, Hash)]
1116struct ShapeKey {
1117    text: Box<str>,
1118    size_bits: u32,
1119    line_height_bits: u32,
1120    family: FontFamily,
1121    weight: FontWeight,
1122    mono: bool,
1123    wrap: TextWrap,
1124    available_width_bits: Option<u32>,
1125}
1126
1127/// Bounded thread-local LRU of shaped layouts. 1024 is comfortably more
1128/// than the ~50 distinct text labels a typical Aetna app renders per
1129/// frame, plus the binary-search prefixes `ellipsize_text_with_family`
1130/// produces. Eviction is benign — the next miss reshapes via cosmic.
1131const SHAPE_CACHE_CAPACITY: usize = 1024;
1132thread_local! {
1133    static SHAPE_CACHE: RefCell<LruCache<ShapeKey, TextLayout>> =
1134        RefCell::new(LruCache::new(NonZeroUsize::new(SHAPE_CACHE_CAPACITY).unwrap()));
1135}
1136
1137fn bundled_font_system() -> FontSystem {
1138    let mut db = fontdb::Database::new();
1139    db.set_sans_serif_family(FontFamily::default().family_name());
1140    for bytes in aetna_fonts::DEFAULT_FONTS {
1141        db.load_font_data(bytes.to_vec());
1142    }
1143    FontSystem::new_with_locale_and_db("en-US".to_string(), db)
1144}
1145
1146fn cosmic_weight(weight: FontWeight) -> Weight {
1147    match weight {
1148        FontWeight::Regular => Weight::NORMAL,
1149        FontWeight::Medium => Weight::MEDIUM,
1150        FontWeight::Semibold => Weight::SEMIBOLD,
1151        FontWeight::Bold => Weight::BOLD,
1152    }
1153}
1154
1155fn layout_run_text(run: &cosmic_text::LayoutRun<'_>) -> String {
1156    let Some(start) = run.glyphs.iter().map(|glyph| glyph.start).min() else {
1157        return String::new();
1158    };
1159    let end = run
1160        .glyphs
1161        .iter()
1162        .map(|glyph| glyph.end)
1163        .max()
1164        .unwrap_or(start);
1165    run.text
1166        .get(start..end)
1167        .unwrap_or_default()
1168        .trim_end()
1169        .to_string()
1170}
1171
1172#[derive(Copy, Clone)]
1173struct WrapMeasure {
1174    max_width: f32,
1175    size: f32,
1176    family: FontFamily,
1177    weight: FontWeight,
1178    mono: bool,
1179}
1180
1181fn push_word_wrapped(out: &mut Vec<String>, line: &mut String, word: &str, ctx: WrapMeasure) {
1182    let WrapMeasure {
1183        max_width,
1184        size,
1185        family,
1186        weight,
1187        mono,
1188    } = ctx;
1189    if line_width_with_family(word, size, family, weight, mono) <= max_width {
1190        line.push_str(word);
1191        return;
1192    }
1193
1194    for ch in word.chars() {
1195        let candidate = format!("{line}{ch}");
1196        if !line.is_empty()
1197            && line_width_with_family(&candidate, size, family, weight, mono) > max_width
1198        {
1199            out.push(std::mem::take(line));
1200        }
1201        line.push(ch);
1202    }
1203}
1204
1205fn glyph_for(face: &ttf_parser::Face<'_>, c: char) -> Option<ttf_parser::GlyphId> {
1206    face.glyph_index(c)
1207        .or_else(|| face.glyph_index('\u{FFFD}'))
1208        .or_else(|| face.glyph_index('?'))
1209        .or_else(|| face.glyph_index(' '))
1210}
1211
1212fn kern(face: &ttf_parser::Face<'_>, left: ttf_parser::GlyphId, right: ttf_parser::GlyphId) -> f32 {
1213    let Some(kern) = &face.tables().kern else {
1214        return 0.0;
1215    };
1216    kern.subtables
1217        .into_iter()
1218        .filter(|subtable| subtable.horizontal && !subtable.has_cross_stream)
1219        .find_map(|subtable| subtable.glyphs_kerning(left, right))
1220        .map(|value| value as f32)
1221        .unwrap_or(0.0)
1222}
1223
1224fn font_bytes(family: FontFamily, weight: FontWeight) -> &'static [u8] {
1225    // ttf-parser fallback path (used only when cosmic-text is bypassed
1226    // for monospace measurement, etc.). Sourced from aetna-fonts so we
1227    // share one bundle with the cosmic-text path.
1228    match family {
1229        FontFamily::Inter => {
1230            #[cfg(feature = "inter")]
1231            {
1232                let _ = weight;
1233                aetna_fonts::INTER_VARIABLE
1234            }
1235            #[cfg(not(feature = "inter"))]
1236            {
1237                let _ = weight;
1238                &[]
1239            }
1240        }
1241        FontFamily::Roboto => {
1242            #[cfg(feature = "roboto")]
1243            {
1244                match weight {
1245                    FontWeight::Regular => aetna_fonts::ROBOTO_REGULAR,
1246                    FontWeight::Medium => aetna_fonts::ROBOTO_MEDIUM,
1247                    FontWeight::Semibold | FontWeight::Bold => aetna_fonts::ROBOTO_BOLD,
1248                }
1249            }
1250            #[cfg(not(feature = "roboto"))]
1251            {
1252                let _ = weight;
1253                &[]
1254            }
1255        }
1256        FontFamily::JetBrainsMono => {
1257            #[cfg(feature = "jetbrains-mono")]
1258            {
1259                let _ = weight;
1260                aetna_fonts::JETBRAINS_MONO_VARIABLE
1261            }
1262            #[cfg(not(feature = "jetbrains-mono"))]
1263            {
1264                let _ = weight;
1265                &[]
1266            }
1267        }
1268    }
1269}
1270
1271fn fallback_line_width(text: &str, size: f32, mono: bool) -> f32 {
1272    let char_w = size * if mono { MONO_CHAR_WIDTH_FACTOR } else { 0.60 };
1273    text.chars().count() as f32 * char_w
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278    use super::*;
1279
1280    #[test]
1281    fn proportional_measurement_distinguishes_narrow_and_wide_glyphs() {
1282        let narrow = line_width("iiiiii", 16.0, FontWeight::Regular, false);
1283        let wide = line_width("WWWWWW", 16.0, FontWeight::Regular, false);
1284
1285        assert!(wide > narrow * 2.0, "wide={wide} narrow={narrow}");
1286    }
1287
1288    #[cfg(feature = "roboto")]
1289    #[test]
1290    fn font_family_changes_proportional_measurement() {
1291        let roboto = line_width_with_family(
1292            "Save changes",
1293            14.0,
1294            FontFamily::Roboto,
1295            FontWeight::Semibold,
1296            false,
1297        );
1298        let inter = line_width_with_family(
1299            "Save changes",
1300            14.0,
1301            FontFamily::Inter,
1302            FontWeight::Semibold,
1303            false,
1304        );
1305
1306        assert!(
1307            (inter - roboto).abs() > 1.0,
1308            "inter={inter} roboto={roboto}"
1309        );
1310    }
1311
1312    #[test]
1313    fn wrap_lines_respects_measured_widths() {
1314        let lines = wrap_lines(
1315            "wide WWW words stay measured",
1316            120.0,
1317            16.0,
1318            FontWeight::Regular,
1319            false,
1320        );
1321
1322        assert!(lines.len() > 1);
1323        for line in lines {
1324            assert!(
1325                line_width(&line, 16.0, FontWeight::Regular, false) <= 121.0,
1326                "{line:?} overflowed"
1327            );
1328        }
1329    }
1330
1331    #[test]
1332    fn layout_text_carries_line_positions_and_measurement() {
1333        let layout = layout_text(
1334            "alpha beta gamma",
1335            16.0,
1336            FontWeight::Regular,
1337            false,
1338            TextWrap::Wrap,
1339            Some(80.0),
1340        );
1341
1342        assert!(layout.lines.len() > 1);
1343        assert_eq!(layout.measured().line_count, layout.lines.len());
1344        assert_eq!(layout.lines[0].y, 0.0);
1345        assert_eq!(layout.lines[1].y, layout.line_height);
1346        assert!(layout.lines[0].baseline > layout.lines[0].y);
1347        assert!(layout.height >= layout.line_height * 2.0);
1348    }
1349
1350    #[test]
1351    fn tokenized_line_heights_match_shadcn_scale() {
1352        assert_eq!(line_height(12.0), 16.0);
1353        assert_eq!(line_height(14.0), 20.0);
1354        assert_eq!(line_height(16.0), 24.0);
1355        assert_eq!(line_height(24.0), 32.0);
1356        assert_eq!(line_height(30.0), 36.0);
1357    }
1358
1359    #[test]
1360    fn hit_text_at_origin_lands_on_first_byte() {
1361        let hit = hit_text(
1362            "hello world",
1363            16.0,
1364            FontWeight::Regular,
1365            TextWrap::NoWrap,
1366            None,
1367            0.0,
1368            8.0,
1369        )
1370        .expect("hit at origin");
1371        assert_eq!(hit.line, 0);
1372        assert_eq!(hit.byte_index, 0);
1373    }
1374
1375    #[test]
1376    fn hit_text_past_last_glyph_clamps_to_end() {
1377        let text = "hello";
1378        // y=8 lands inside the line; a huge x clamps to end-of-line.
1379        let hit = hit_text(
1380            text,
1381            16.0,
1382            FontWeight::Regular,
1383            TextWrap::NoWrap,
1384            None,
1385            1000.0,
1386            8.0,
1387        )
1388        .expect("hit past end");
1389        assert_eq!(hit.line, 0);
1390        assert_eq!(hit.byte_index, text.len());
1391    }
1392
1393    #[test]
1394    fn hit_text_walks_columns_left_to_right() {
1395        // Successive x positions inside the same line should produce
1396        // monotonically non-decreasing byte indices — the basic contract
1397        // a text input relies on for click-to-caret.
1398        let text = "abcdefghij";
1399        let mut prev = 0usize;
1400        for x in [4.0, 16.0, 32.0, 64.0, 96.0] {
1401            let hit = hit_text(
1402                text,
1403                16.0,
1404                FontWeight::Regular,
1405                TextWrap::NoWrap,
1406                None,
1407                x,
1408                8.0,
1409            );
1410            let Some(hit) = hit else { continue };
1411            assert!(
1412                hit.byte_index >= prev,
1413                "byte_index regressed at x={x}: {} < {prev}",
1414                hit.byte_index
1415            );
1416            prev = hit.byte_index;
1417        }
1418    }
1419
1420    #[test]
1421    fn text_geometry_hit_byte_maps_hard_line_offsets_to_source_bytes() {
1422        let text = "alpha\nbeta";
1423        let geometry = TextGeometry::new(
1424            text,
1425            16.0,
1426            FontWeight::Regular,
1427            false,
1428            TextWrap::NoWrap,
1429            None,
1430        );
1431        let y = geometry.line_height() * 1.5;
1432        let byte = geometry.hit_byte(1000.0, y).expect("hit on second line");
1433        assert_eq!(byte, text.len());
1434    }
1435
1436    #[test]
1437    fn text_geometry_prefix_width_matches_caret_x() {
1438        let text = "hello world";
1439        let geometry = TextGeometry::new(
1440            text,
1441            16.0,
1442            FontWeight::Regular,
1443            false,
1444            TextWrap::NoWrap,
1445            None,
1446        );
1447        let (x, _y) = geometry.caret_xy(5);
1448        assert!((geometry.prefix_width(5) - x).abs() < 0.01);
1449    }
1450
1451    #[test]
1452    fn caret_xy_at_origin_is_zero_zero() {
1453        let (x, y) = caret_xy(
1454            "hello",
1455            0,
1456            16.0,
1457            FontWeight::Regular,
1458            TextWrap::NoWrap,
1459            None,
1460        );
1461        assert!(x.abs() < 0.01, "x={x}");
1462        assert_eq!(y, 0.0);
1463    }
1464
1465    #[test]
1466    fn caret_xy_at_end_of_line_is_at_line_width() {
1467        let text = "hello";
1468        let width = line_width(text, 16.0, FontWeight::Regular, false);
1469        let (x, y) = caret_xy(
1470            text,
1471            text.len(),
1472            16.0,
1473            FontWeight::Regular,
1474            TextWrap::NoWrap,
1475            None,
1476        );
1477        assert!((x - width).abs() < 1.0, "x={x} expected~{width}");
1478        assert_eq!(y, 0.0);
1479    }
1480
1481    #[test]
1482    fn caret_xy_drops_to_next_line_after_newline() {
1483        let text = "foo\nbar";
1484        let line_h = line_height(16.0);
1485        // Right after the \n: should land at start of line 1.
1486        let (x, y) = caret_xy(text, 4, 16.0, FontWeight::Regular, TextWrap::NoWrap, None);
1487        assert!(x.abs() < 0.01, "x={x}");
1488        assert!((y - line_h).abs() < 0.01, "y={y} expected~{line_h}");
1489    }
1490
1491    #[test]
1492    fn caret_xy_on_phantom_trailing_line_falls_below_text() {
1493        let text = "foo\n";
1494        let line_h = line_height(16.0);
1495        let (x, y) = caret_xy(
1496            text,
1497            text.len(),
1498            16.0,
1499            FontWeight::Regular,
1500            TextWrap::NoWrap,
1501            None,
1502        );
1503        assert!(x.abs() < 0.01, "x={x}");
1504        assert!(y >= line_h - 0.01, "y={y} expected ≥ line_h={line_h}");
1505    }
1506
1507    #[test]
1508    fn selection_rects_returns_one_per_visual_line() {
1509        let text = "alpha\nbeta\ngamma";
1510        let rects = selection_rects(
1511            text,
1512            0,
1513            text.len(),
1514            16.0,
1515            FontWeight::Regular,
1516            TextWrap::NoWrap,
1517            None,
1518        );
1519        assert_eq!(
1520            rects.len(),
1521            3,
1522            "expected one rect per BufferLine, got {rects:?}"
1523        );
1524        // Rects are ordered top-down.
1525        assert!(rects[0].1 < rects[1].1);
1526        assert!(rects[1].1 < rects[2].1);
1527        for (_x, _y, w, _h) in &rects {
1528            assert!(*w > 0.0, "empty width: {rects:?}");
1529        }
1530    }
1531
1532    #[test]
1533    fn selection_rects_empty_for_collapsed_range() {
1534        let rects = selection_rects(
1535            "alpha",
1536            2,
1537            2,
1538            16.0,
1539            FontWeight::Regular,
1540            TextWrap::NoWrap,
1541            None,
1542        );
1543        assert!(rects.is_empty());
1544    }
1545
1546    #[test]
1547    fn proportional_layout_uses_cosmic_shaping_widths() {
1548        let layout = layout_text(
1549            "Roboto shaping",
1550            18.0,
1551            FontWeight::Medium,
1552            false,
1553            TextWrap::NoWrap,
1554            None,
1555        );
1556
1557        assert_eq!(layout.lines.len(), 1);
1558        assert!((layout.lines[0].width - layout.width).abs() < 0.01);
1559        assert!(layout.lines[0].baseline > layout.lines[0].y);
1560    }
1561
1562    #[test]
1563    fn ellipsize_text_shortens_to_available_width() {
1564        let source = "this is a long branch name";
1565        let available = line_width("this is a…", 14.0, FontWeight::Regular, false);
1566        let clipped = ellipsize_text(source, 14.0, FontWeight::Regular, false, available);
1567        let width = line_width(&clipped, 14.0, FontWeight::Regular, false);
1568
1569        assert!(clipped.ends_with('…'), "clipped={clipped}");
1570        assert!(clipped.len() < source.len());
1571        assert!(
1572            width <= available + 0.5,
1573            "width={width} available={available}"
1574        );
1575    }
1576
1577    #[test]
1578    fn ellipsize_text_keeps_fitting_text_unchanged() {
1579        let source = "short";
1580        let available = line_width(source, 14.0, FontWeight::Regular, false) + 4.0;
1581        assert_eq!(
1582            ellipsize_text(source, 14.0, FontWeight::Regular, false, available),
1583            source
1584        );
1585    }
1586
1587    #[test]
1588    fn clamp_text_to_lines_caps_wrapped_text_with_final_ellipsis() {
1589        let source = "alpha beta gamma delta epsilon zeta";
1590        let available = line_width("alpha beta", 14.0, FontWeight::Regular, false);
1591        let clamped = clamp_text_to_lines(source, 14.0, FontWeight::Regular, false, available, 2);
1592        let layout = layout_text(
1593            &clamped,
1594            14.0,
1595            FontWeight::Regular,
1596            false,
1597            TextWrap::Wrap,
1598            Some(available),
1599        );
1600
1601        assert!(clamped.ends_with('…'), "clamped={clamped}");
1602        assert!(layout.lines.len() <= 2, "layout={layout:?}");
1603    }
1604}