Skip to main content

repose_core/
text.rs

1use crate::Color;
2use std::fmt::Debug;
3use std::rc::Rc;
4use std::sync::Arc;
5
6/// A range of text measured in byte offsets, matching Compose's `TextRange`.
7///
8/// When `start == end`, the range is collapsed (cursor position).
9/// When `start > end`, the range is reversed (selection direction matters).
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub struct TextRange {
12    pub start: usize,
13    pub end: usize,
14}
15
16impl TextRange {
17    pub const ZERO: TextRange = TextRange { start: 0, end: 0 };
18
19    pub fn new(start: usize, end: usize) -> Self {
20        Self { start, end }
21    }
22
23    pub fn collapsed(at: usize) -> Self {
24        Self { start: at, end: at }
25    }
26
27    pub fn min(self) -> usize {
28        self.start.min(self.end)
29    }
30
31    pub fn max(self) -> usize {
32        self.start.max(self.end)
33    }
34
35    pub fn is_collapsed(self) -> bool {
36        self.start == self.end
37    }
38
39    pub fn reversed(self) -> bool {
40        self.start > self.end
41    }
42
43    pub fn length(self) -> usize {
44        self.max() - self.min()
45    }
46
47    pub fn intersects(self, other: TextRange) -> bool {
48        self.min() < other.max() && other.min() < self.max()
49    }
50
51    pub fn contains(self, offset: usize) -> bool {
52        self.min() <= offset && offset < self.max()
53    }
54
55    pub fn coerce_in(self, min: usize, max: usize) -> Self {
56        Self {
57            start: self.start.clamp(min, max),
58            end: self.end.clamp(min, max),
59        }
60    }
61}
62
63impl From<(usize, usize)> for TextRange {
64    fn from((start, end): (usize, usize)) -> Self {
65        Self { start, end }
66    }
67}
68
69/// Geometry the layout engine measured for a selectable `Text` node.
70///
71/// Published so hit testing wraps and measures lines exactly like the paint
72/// pass does, instead of re-deriving them from font size alone.
73#[derive(Clone, Copy, Debug, Default, PartialEq)]
74pub struct TextLayoutGeometry {
75    /// Width the text was wrapped into, in px.
76    pub wrap_width_px: f32,
77    /// Distance between consecutive line tops, in px.
78    pub line_height_px: f32,
79    /// Top-left of the text block, relative to the node's top-left, in px.
80    pub origin: (f32, f32),
81}
82
83/// Selected range of a `Text` node, drawn by the layout engine behind the
84/// glyphs the way Compose's `SelectionController` draws from a
85/// `TextLayoutResult`.
86///
87/// A range with `start > end` is reversed (the anchor sits at `end`);
88/// painting normalizes it.
89#[derive(Clone, Default)]
90pub struct TextSelection {
91    range: Rc<std::cell::RefCell<Option<TextRange>>>,
92    geometry: Rc<std::cell::RefCell<TextLayoutGeometry>>,
93}
94
95impl TextSelection {
96    pub fn range(&self) -> Option<TextRange> {
97        *self.range.borrow()
98    }
99
100    pub fn set_range(&self, range: Option<TextRange>) {
101        *self.range.borrow_mut() = range;
102    }
103
104    pub fn geometry(&self) -> TextLayoutGeometry {
105        *self.geometry.borrow()
106    }
107
108    /// Called by the layout engine after measuring the node.
109    pub fn set_geometry(&self, geometry: TextLayoutGeometry) {
110        *self.geometry.borrow_mut() = geometry;
111    }
112}
113
114impl Debug for TextSelection {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.debug_struct("TextSelection")
117            .field("range", &self.range())
118            .finish_non_exhaustive()
119    }
120}
121
122/// Snapshot of a text field's editing state including text, selection, and
123/// IME composition range. Corresponds to Compose's `TextFieldValue`.
124#[derive(Clone, Debug, PartialEq)]
125pub struct TextFieldValue {
126    /// The annotated text. Plain text is accessible via `text()` or `annotated_string.text`.
127    pub annotated_string: AnnotatedString,
128    /// Selection range in byte offsets. Collapsed range (start == end) = cursor.
129    pub selection: TextRange,
130    /// Active IME composition range in byte offsets, or None.
131    pub composition: Option<TextRange>,
132}
133
134impl TextFieldValue {
135    /// Create from plain text (no annotations).
136    pub fn new(text: impl Into<String>) -> Self {
137        let annotated = AnnotatedString::from(text.into());
138        let len = annotated.text.len();
139        Self {
140            selection: TextRange::collapsed(len),
141            annotated_string: annotated,
142            composition: None,
143        }
144    }
145
146    /// Create from an `AnnotatedString` preserving all annotations.
147    pub fn from_annotated(annotated: AnnotatedString) -> Self {
148        let len = annotated.text.len();
149        Self {
150            selection: TextRange::collapsed(len),
151            annotated_string: annotated,
152            composition: None,
153        }
154    }
155
156    /// Convenience: plain text content (delegates to `annotated_string.text`).
157    pub fn text(&self) -> &str {
158        &self.annotated_string.text
159    }
160
161    pub fn with_selection(mut self, start: usize, end: usize) -> Self {
162        let len = self.annotated_string.text.len();
163        self.selection = TextRange::new(start.min(len), end.min(len));
164        self
165    }
166
167    pub fn get_text_before_selection(&self, max_chars: usize) -> AnnotatedString {
168        let sel_min = self.selection.min();
169        let start = sel_min.saturating_sub(max_chars);
170        let text = self.annotated_string.text[start..sel_min].to_string();
171        // Preserve spans that intersect the sub-range
172        let spans: Vec<TextSpan> = self
173            .annotated_string
174            .spans
175            .iter()
176            .filter(|s| s.start >= start && s.end <= sel_min)
177            .map(|s| TextSpan {
178                start: s.start - start,
179                end: s.end - start,
180                style: s.style.clone(),
181                url: s.url.clone(),
182            })
183            .collect();
184        AnnotatedString::new(text, spans)
185    }
186
187    pub fn get_text_after_selection(&self, max_chars: usize) -> AnnotatedString {
188        let sel_max = self.selection.max();
189        let end = (sel_max + max_chars).min(self.annotated_string.text.len());
190        let text = self.annotated_string.text[sel_max..end].to_string();
191        let spans: Vec<TextSpan> = self
192            .annotated_string
193            .spans
194            .iter()
195            .filter(|s| s.start >= sel_max && s.end <= end)
196            .map(|s| TextSpan {
197                start: s.start - sel_max,
198                end: s.end - sel_max,
199                style: s.style.clone(),
200                url: s.url.clone(),
201            })
202            .collect();
203        AnnotatedString::new(text, spans)
204    }
205
206    pub fn get_selected_text(&self) -> AnnotatedString {
207        let r = self.selection.min()..self.selection.max();
208        let text = self.annotated_string.text[r.clone()].to_string();
209        let spans: Vec<TextSpan> = self
210            .annotated_string
211            .spans
212            .iter()
213            .filter(|s| s.start >= r.start && s.end <= r.end)
214            .map(|s| TextSpan {
215                start: s.start - r.start,
216                end: s.end - r.start,
217                style: s.style.clone(),
218                url: s.url.clone(),
219            })
220            .collect();
221        AnnotatedString::new(text, spans)
222    }
223
224    pub fn copy(&self, annotated_string: AnnotatedString) -> Self {
225        TextFieldValue {
226            annotated_string,
227            selection: self.selection,
228            composition: self.composition,
229        }
230    }
231
232    pub fn copy_text(&self, text: String) -> Self {
233        TextFieldValue {
234            annotated_string: AnnotatedString::from(text),
235            selection: self.selection,
236            composition: self.composition,
237        }
238    }
239}
240
241/// Result of text layout for `on_text_layout`.
242#[derive(Clone, Debug)]
243pub struct TextLayoutResult {
244    pub line_count: usize,
245    pub width_px: f32,
246    pub height_px: f32,
247    pub first_baseline: f32,
248    pub last_baseline: f32,
249    pub did_overflow_width: bool,
250    pub did_overflow_height: bool,
251    pub lines: Vec<TextLineInfo>,
252}
253
254/// How text is obfuscated in secure fields.
255#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
256pub enum TextObfuscationMode {
257    /// Text is visible, no obfuscation.
258    Visible,
259    /// Reveal the last typed character briefly, then hide.
260    RevealLastTyped,
261    /// All characters are obfuscated.
262    Hidden,
263    /// Uses the platform's default obfuscation behavior.
264    #[default]
265    System,
266}
267
268/// Layout information for a single line of text.
269#[derive(Clone, Debug)]
270pub struct TextLineInfo {
271    /// Byte offset of the line start in the text.
272    pub start: usize,
273    /// Byte offset of the line end (exclusive) in the text.
274    pub end: usize,
275    /// Top y position in px relative to the text field content area.
276    pub top: f32,
277    /// Baseline y position in px relative to the text field content area.
278    pub baseline: f32,
279    /// Bottom y position in px relative to the text field content area.
280    pub bottom: f32,
281    /// Left x position in px relative to the text field content area.
282    pub left: f32,
283    /// Right x position in px relative to the text field content area.
284    pub right: f32,
285    /// Width of this line in px.
286    pub width: f32,
287}
288
289impl TextLayoutResult {
290    pub fn get_line_start(&self, line_index: usize) -> Option<usize> {
291        self.lines.get(line_index).map(|l| l.start)
292    }
293
294    pub fn get_line_end(&self, line_index: usize) -> Option<usize> {
295        self.lines.get(line_index).map(|l| l.end)
296    }
297
298    pub fn get_line_end_visible(&self, line_index: usize) -> Option<usize> {
299        self.lines.get(line_index).map(|l| l.end)
300    }
301
302    pub fn is_line_ellipsized(&self, _line_index: usize) -> bool {
303        false
304    }
305
306    pub fn get_line_top(&self, line_index: usize) -> Option<f32> {
307        self.lines.get(line_index).map(|l| l.top)
308    }
309
310    pub fn get_line_baseline(&self, line_index: usize) -> Option<f32> {
311        self.lines.get(line_index).map(|l| l.baseline)
312    }
313
314    pub fn get_line_bottom(&self, line_index: usize) -> Option<f32> {
315        self.lines.get(line_index).map(|l| l.bottom)
316    }
317
318    pub fn get_line_left(&self, line_index: usize) -> Option<f32> {
319        self.lines.get(line_index).map(|l| l.left)
320    }
321
322    pub fn get_line_right(&self, line_index: usize) -> Option<f32> {
323        self.lines.get(line_index).map(|l| l.right)
324    }
325
326    pub fn get_line_for_offset(&self, offset: usize) -> usize {
327        for (i, line) in self.lines.iter().enumerate() {
328            if offset >= line.start && offset < line.end {
329                return i;
330            }
331        }
332        self.line_count.saturating_sub(1)
333    }
334
335    pub fn get_line_for_vertical_position(&self, vertical: f32) -> usize {
336        for (i, line) in self.lines.iter().enumerate() {
337            if vertical >= line.top && vertical < line.bottom {
338                return i;
339            }
340        }
341        self.line_count.saturating_sub(1)
342    }
343
344    pub fn get_horizontal_position(&self, offset: usize, _use_primary_direction: bool) -> f32 {
345        self.lines
346            .iter()
347            .find(|l| offset >= l.start && offset <= l.end)
348            .map(|l| l.left)
349            .unwrap_or(0.0)
350    }
351
352    pub fn has_visual_overflow(&self) -> bool {
353        self.did_overflow_width || self.did_overflow_height
354    }
355
356    pub fn get_bounding_box(&self, offset: usize) -> crate::Rect {
357        if self.lines.is_empty() || offset > self.lines.last().unwrap().end {
358            return crate::Rect::default();
359        }
360        let line = self
361            .lines
362            .iter()
363            .find(|l| offset >= l.start && offset < l.end)
364            .unwrap_or_else(|| {
365                if offset >= self.lines.last().unwrap().end {
366                    self.lines.last().unwrap()
367                } else {
368                    &self.lines[0]
369                }
370            });
371        let _line_idx = self.get_line_for_offset(offset);
372        let char_in_line = offset - line.start;
373        let pos_in_line = if char_in_line == 0 {
374            line.left
375        } else {
376            let chars = line.end - line.start;
377            if chars == 0 {
378                line.left
379            } else {
380                line.left + (line.width * (char_in_line as f32) / (chars as f32))
381            }
382        };
383        crate::Rect {
384            x: pos_in_line,
385            y: line.top,
386            w: if char_in_line < (line.end - line.start) {
387                line.width / (line.end - line.start).max(1) as f32
388            } else {
389                1.0
390            },
391            h: line.bottom - line.top,
392        }
393    }
394
395    pub fn get_cursor_rect(&self, offset: usize) -> crate::Rect {
396        let line = self
397            .lines
398            .iter()
399            .find(|l| offset >= l.start && offset <= l.end)
400            .unwrap_or_else(|| {
401                if offset >= self.lines.last().map(|l| l.end).unwrap_or(0) {
402                    self.lines.last().unwrap()
403                } else {
404                    &self.lines[0]
405                }
406            });
407        let char_in_line = offset.saturating_sub(line.start);
408        let chars = (line.end - line.start).max(1);
409        let x = line.left + (line.width * (char_in_line as f32) / (chars as f32));
410        crate::Rect {
411            x,
412            y: line.top,
413            w: 1.0,
414            h: line.bottom - line.top,
415        }
416    }
417
418    pub fn get_offset_for_position(&self, position: (f32, f32)) -> Option<usize> {
419        let line_idx = self.get_line_for_vertical_position(position.1);
420        let line = self.lines.get(line_idx)?;
421        if position.0 <= line.left {
422            return Some(line.start);
423        }
424        if position.0 >= line.right {
425            return Some(line.end);
426        }
427        let fraction = (position.0 - line.left) / line.width.max(1.0);
428        let offset_in_line = ((line.end - line.start) as f32 * fraction).round() as usize;
429        Some((line.start + offset_in_line).min(line.end))
430    }
431
432    pub fn get_word_boundary(&self, _offset: usize) -> super::TextRange {
433        // Stub: layout results don't retain the full text, so real word
434        // boundaries can't be computed yet.
435        super::TextRange::new(0, 0)
436    }
437
438    pub fn get_paragraph_direction(&self, _offset: usize) -> u8 {
439        0 // LTR
440    }
441
442    pub fn get_bidi_run_direction(&self, _offset: usize) -> u8 {
443        0 // LTR
444    }
445}
446
447pub trait OffsetMapping: Debug + Send + Sync + 'static {
448    fn original_to_transformed(&self, offset: usize) -> usize;
449    fn transformed_to_original(&self, offset: usize) -> usize;
450    fn clone_box(&self) -> Box<dyn OffsetMapping>;
451}
452
453/// Identity offset mapping: original and transformed offsets are the same.
454#[derive(Clone, Copy, Debug)]
455pub struct IdentityOffsetMapping;
456
457impl OffsetMapping for IdentityOffsetMapping {
458    fn original_to_transformed(&self, offset: usize) -> usize {
459        offset
460    }
461    fn transformed_to_original(&self, offset: usize) -> usize {
462        offset
463    }
464    fn clone_box(&self) -> Box<dyn OffsetMapping> {
465        Box::new(*self)
466    }
467}
468
469/// Transforms display text without changing the underlying value (e.g. password masking).
470pub trait VisualTransformation: Debug + Send + Sync + 'static {
471    /// Transform the text for display. Takes the original `AnnotatedString` and returns
472    /// the transformed `TransformedText` with an offset mapping.
473    fn filter(&self, text: &AnnotatedString) -> TransformedText;
474}
475
476/// Output of `VisualTransformation::filter`.
477pub struct TransformedText {
478    pub text: AnnotatedString,
479    pub offset_mapping: Box<dyn OffsetMapping>,
480}
481
482impl TransformedText {
483    pub fn new(text: AnnotatedString, offset_mapping: Box<dyn OffsetMapping>) -> Self {
484        TransformedText {
485            text,
486            offset_mapping,
487        }
488    }
489}
490
491impl Clone for TransformedText {
492    fn clone(&self) -> Self {
493        Self {
494            text: self.text.clone(),
495            offset_mapping: self.offset_mapping.clone_box(),
496        }
497    }
498}
499
500impl Debug for TransformedText {
501    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
502        f.debug_struct("TransformedText")
503            .field("text", &self.text.text)
504            .finish()
505    }
506}
507
508#[derive(Clone, Copy, Debug)]
509pub struct IdentityVisualTransformation;
510
511impl VisualTransformation for IdentityVisualTransformation {
512    fn filter(&self, text: &AnnotatedString) -> TransformedText {
513        TransformedText {
514            text: text.clone(),
515            offset_mapping: Box::new(IdentityOffsetMapping),
516        }
517    }
518}
519
520/// A `VisualTransformation` that masks all characters with a given character.
521/// Matches Compose's `PasswordVisualTransformation`.
522#[derive(Clone, Copy, Debug)]
523pub struct PasswordVisualTransformation {
524    pub mask: char,
525}
526
527impl Default for PasswordVisualTransformation {
528    fn default() -> Self {
529        Self { mask: '\u{2022}' }
530    }
531}
532
533/// Char-index offset mapping for masked text: the Nth original char maps to
534/// the Nth mask char (byte offsets differ when widths differ, e.g. `a\U0001F44D`
535/// (5B) masked to `\u{2022}\u{2022}` (6B)). Identity mapping here silently
536/// mis-placed carets mid-mask-char for non-BMP input.
537#[derive(Clone, Debug)]
538pub struct PasswordOffsetMapping {
539    original: Vec<usize>,
540    transformed: Vec<usize>,
541}
542
543impl PasswordOffsetMapping {
544    pub fn new(original: &str, mask: char, masked: &str) -> Self {
545        let mut o = Vec::with_capacity(original.chars().count() + 1);
546        for (i, _) in original.char_indices() {
547            o.push(i);
548        }
549        o.push(original.len());
550        let mut t = vec![0usize];
551        let n = original.chars().count();
552        let mut byte = 0usize;
553        for _ in 0..n {
554            byte += mask.len_utf8();
555            t.push(byte);
556        }
557        if byte != masked.len() {
558            t.clear();
559            t.push(0);
560            let mut b = 0usize;
561            for ch in masked.chars() {
562                b += ch.len_utf8();
563                t.push(b);
564            }
565        }
566        Self {
567            original: o,
568            transformed: t,
569        }
570    }
571
572    fn snap_back(offsets: &[usize], text_len: usize, offset: usize) -> usize {
573        let o = offset.min(text_len);
574        match offsets.binary_search(&o) {
575            Ok(_) => o,
576            Err(i) => offsets.get(i.saturating_sub(1)).copied().unwrap_or(0),
577        }
578    }
579    fn index_of(offsets: &[usize], snapped: usize) -> usize {
580        match offsets.binary_search(&snapped) {
581            Ok(i) => i,
582            Err(i) => i.min(offsets.len().saturating_sub(1)),
583        }
584    }
585}
586
587impl OffsetMapping for PasswordOffsetMapping {
588    fn original_to_transformed(&self, offset: usize) -> usize {
589        let o_text_len = *self.original.last().unwrap_or(&0);
590        let snapped = Self::snap_back(&self.original, o_text_len, offset);
591        let idx = Self::index_of(&self.original, snapped);
592        self.transformed.get(idx).copied().unwrap_or(0)
593    }
594    fn transformed_to_original(&self, offset: usize) -> usize {
595        let t_text_len = *self.transformed.last().unwrap_or(&0);
596        let snapped = Self::snap_back(&self.transformed, t_text_len, offset);
597        let idx = Self::index_of(&self.transformed, snapped);
598        self.original.get(idx).copied().unwrap_or(0)
599    }
600    fn clone_box(&self) -> Box<dyn OffsetMapping> {
601        Box::new(self.clone())
602    }
603}
604
605impl VisualTransformation for PasswordVisualTransformation {
606    fn filter(&self, text: &AnnotatedString) -> TransformedText {
607        let masked_text: String = text.text.chars().map(|_| self.mask).collect();
608        let mapping = PasswordOffsetMapping::new(&text.text, self.mask, &masked_text);
609        TransformedText {
610            text: AnnotatedString::new(masked_text, vec![]),
611            offset_mapping: Box::new(mapping),
612        }
613    }
614}
615
616/// Convert a byte offset in the original text to the corresponding byte offset
617/// in the visually-transformed display text.
618pub fn original_offset_to_display(original: &str, display: &str, original_byte: usize) -> usize {
619    original_offset_to_display_with_mapping(original, display, original_byte, None)
620}
621
622/// Convert a byte offset in the original text to the corresponding byte offset
623/// in the visually-transformed display text, using the provided `OffsetMapping` if available.
624pub fn original_offset_to_display_with_mapping(
625    original: &str,
626    display: &str,
627    original_byte: usize,
628    offset_mapping: Option<&dyn OffsetMapping>,
629) -> usize {
630    if let Some(om) = offset_mapping {
631        om.original_to_transformed(original_byte)
632    } else {
633        let char_idx = original[..original_byte.min(original.len())]
634            .chars()
635            .count();
636        display
637            .char_indices()
638            .nth(char_idx)
639            .map(|(i, _)| i)
640            .unwrap_or(display.len())
641    }
642}
643
644/// Configures automatic capitalization behavior for the keyboard.
645/// Corresponds to Compose's `KeyboardCapitalization`.
646#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
647pub enum KeyboardCapitalization {
648    #[default]
649    Unspecified,
650    None,
651    Characters,
652    Words,
653    Sentences,
654}
655
656/// Text shadow. Offsets and blur are in [`Dp`](crate::units::Dp),
657/// converted to px at the paint boundary.
658#[derive(Clone, Copy, Debug, PartialEq)]
659pub struct Shadow {
660    pub color: Color,
661    /// Horizontal offset in [`Dp`](crate::units::Dp).
662    pub offset_x: crate::units::Dp,
663    /// Vertical offset in [`Dp`](crate::units::Dp).
664    pub offset_y: crate::units::Dp,
665    /// Blur radius in [`Dp`](crate::units::Dp).
666    pub blur_radius: crate::units::Dp,
667}
668
669/// Font synthesis controls whether the font renderer may synthesize
670/// bold, italic, or small-caps variants when the font lacks them.
671#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
672pub enum FontSynthesis {
673    #[default]
674    Unspecified,
675    None,
676    Weight,
677    Style,
678    SmallCaps,
679    All,
680}
681
682/// Baseline shift for subscript/superscript.
683/// Wraps a multiplier applied against font_size for glyph y-offset.
684#[derive(Clone, Copy, Debug, PartialEq)]
685pub struct BaselineShift(pub f32);
686
687#[allow(non_upper_case_globals)]
688impl BaselineShift {
689    /// No baseline shift (zero offset).
690    pub const Unspecified: BaselineShift = BaselineShift(0.0);
691    /// Default superscript offset: shift up by 33.3% of font_size (CSS standard).
692    pub const Superscript: BaselineShift = BaselineShift(-0.333);
693    /// Default subscript offset: shift down by 20% of font_size (CSS standard).
694    pub const Subscript: BaselineShift = BaselineShift(0.2);
695}
696
697impl Default for BaselineShift {
698    fn default() -> Self {
699        BaselineShift::Unspecified
700    }
701}
702
703/// Hyphenation behavior.
704#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
705pub enum Hyphens {
706    #[default]
707    Unspecified,
708    None,
709    Auto,
710}
711
712/// Line break behavior.
713#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
714pub enum LineBreak {
715    #[default]
716    Unspecified,
717    Simple,
718    Heading,
719    Paragraph,
720}
721
722/// First-line and rest-line indent in [`Dp`](crate::units::Dp).
723#[derive(Clone, Copy, Debug, PartialEq)]
724pub struct TextIndent {
725    pub first_line: crate::units::Dp,
726    pub rest_lines: crate::units::Dp,
727}
728
729impl Default for TextIndent {
730    fn default() -> Self {
731        Self {
732            first_line: crate::units::Dp::ZERO,
733            rest_lines: crate::units::Dp::ZERO,
734        }
735    }
736}
737
738/// Path effect applied to a stroked path.
739/// Mirrors Compose's `PathEffect`.
740#[derive(Clone, Debug, PartialEq)]
741pub enum PathEffect {
742    /// Replace sharp corners with rounded arcs of the given radius.
743    Corner {
744        /// Corner radius in em-units.
745        radius: f32,
746    },
747    /// Draw the path as a dashed line.
748    Dash {
749        /// Interleaved on/off lengths in em-units.
750        intervals: Vec<f32>,
751        /// Starting phase offset in em-units.
752        phase: f32,
753    },
754}
755
756/// Draw style for text (fill or stroke).
757#[derive(Clone, Debug, PartialEq, Default)]
758pub enum DrawStyle {
759    #[default]
760    Fill,
761    Stroke {
762        /// Stroke width in em-units (fraction of font size). 0.05 = 5% of em.
763        width: f32,
764        /// Line cap style for stroke endpoints.
765        cap: crate::StrokeCap,
766        /// Line join style for stroke segment joins.
767        join: crate::StrokeJoin,
768        /// Miter limit for miter joins.
769        miter: f32,
770        /// Optional path effect (dash, corner rounding, etc.).
771        path_effect: Option<PathEffect>,
772    },
773    /// Fill plus an outline in the same color. Renders the fill first,
774    /// then the stroke on top (same layout; stroke width in em-units).
775    /// Faux-bold for fonts without a bold face: single layout pass,
776    /// no extra view needed.
777    FillAndStroke {
778        /// Stroke width in em-units (fraction of font size). 0.05 = 5% of em.
779        width: f32,
780        /// Line cap style for stroke endpoints.
781        cap: crate::StrokeCap,
782        /// Line join style for stroke segment joins.
783        join: crate::StrokeJoin,
784        /// Miter limit for miter joins.
785        miter: f32,
786        /// Optional path effect (dash, corner rounding, etc.).
787        path_effect: Option<PathEffect>,
788    },
789}
790
791impl DrawStyle {
792    /// Create a `Stroke` variant with default cap, join, miter, and no path effect.
793    pub const fn stroke(width: f32) -> Self {
794        Self::Stroke {
795            width,
796            cap: crate::StrokeCap::Butt,
797            join: crate::StrokeJoin::Miter,
798            miter: 4.0,
799            path_effect: None,
800        }
801    }
802
803    /// Create a `FillAndStroke` variant with default cap, join, miter,
804    /// and no path effect.
805    pub const fn fill_and_stroke(width: f32) -> Self {
806        Self::FillAndStroke {
807            width,
808            cap: crate::StrokeCap::Butt,
809            join: crate::StrokeJoin::Miter,
810            miter: 4.0,
811            path_effect: None,
812        }
813    }
814
815    /// Outline parameters when this style draws a stroke, else `None`.
816    pub const fn stroke_params(
817        &self,
818    ) -> Option<(
819        f32,
820        crate::StrokeCap,
821        crate::StrokeJoin,
822        f32,
823        &Option<PathEffect>,
824    )> {
825        match self {
826            Self::Stroke {
827                width,
828                cap,
829                join,
830                miter,
831                path_effect,
832            }
833            | Self::FillAndStroke {
834                width,
835                cap,
836                join,
837                miter,
838                path_effect,
839            } => Some((*width, *cap, *join, *miter, path_effect)),
840            Self::Fill => None,
841        }
842    }
843
844    /// Whether this style draws the glyph fill.
845    pub const fn draws_fill(&self) -> bool {
846        !matches!(self, Self::Stroke { .. })
847    }
848}
849
850/// Style configuration for text displayed in a text field.
851/// Corresponds to Compose's `TextStyle` (font sizes are `TextUnit` Sp there).
852#[derive(Clone, Debug)]
853pub struct TextStyle {
854    /// Font size in [`Sp`](crate::units::Sp). `Sp::ZERO` = use default (16sp for TextField).
855    pub font_size: crate::units::Sp,
856    /// Text color. None = use theme default.
857    pub color: Option<Color>,
858    /// Font weight. None = NORMAL.
859    pub font_weight: Option<u16>,
860    /// Font family. None = use default (sans-serif).
861    pub font_family: Option<&'static str>,
862    /// Font style. None = Normal.
863    pub font_style: Option<u8>,
864    /// Text alignment. Unspecified = inherit.
865    pub text_align: crate::TextAlign,
866    /// Letter spacing in [`Sp`](crate::units::Sp). `Sp::ZERO` = no extra spacing.
867    pub letter_spacing: crate::units::Sp,
868    /// Line height in [`Sp`](crate::units::Sp). `Sp::ZERO` = default (font_size).
869    pub line_height: crate::units::Sp,
870    /// Text background color. None = transparent.
871    pub background: Option<Color>,
872    /// Text decoration (underline, strikethrough). None = no decoration.
873    pub text_decoration: Option<crate::TextDecoration>,
874    /// Text shadow. None = no shadow.
875    pub shadow: Option<Shadow>,
876    /// Text direction. None = inherit from thread-local default (usually LTR).
877    pub text_direction: Option<crate::TextDirection>,
878    /// Font synthesis policy (synthesize missing bold/italic/small-caps).
879    pub font_synthesis: FontSynthesis,
880    /// Baseline shift (superscript/subscript).
881    pub baseline_shift: BaselineShift,
882    /// Hyphenation behavior.
883    pub hyphens: Hyphens,
884    /// Line break behavior.
885    pub line_break: LineBreak,
886    /// First-line and rest-line indent in dp.
887    pub text_indent: Option<TextIndent>,
888    /// Draw style (fill or stroke).
889    pub draw_style: DrawStyle,
890    /// Text opacity (0.0-1.0). 0.0 = use default (fully opaque).
891    pub alpha: f32,
892    /// Locale hint for text shaping. Empty = use default.
893    pub locale_list: Option<String>,
894    /// OpenType font feature settings (e.g. "liga", "kern").
895    pub font_feature_settings: Option<String>,
896    /// OpenType font variation settings (e.g. "wght 700, opsz 24").
897    pub font_variation_settings: Option<String>,
898}
899
900impl Default for TextStyle {
901    fn default() -> Self {
902        Self {
903            font_size: crate::units::Sp::ZERO,
904            color: None,
905            font_weight: None,
906            font_family: Some("sans-serif"),
907            font_style: None,
908            text_align: crate::TextAlign::Unspecified,
909            letter_spacing: crate::units::Sp::ZERO,
910            line_height: crate::units::Sp::ZERO,
911            background: None,
912            text_decoration: None,
913            shadow: None,
914            text_direction: None,
915            font_synthesis: FontSynthesis::Unspecified,
916            baseline_shift: BaselineShift::Unspecified,
917            hyphens: Hyphens::Unspecified,
918            line_break: LineBreak::Unspecified,
919            text_indent: None,
920            draw_style: DrawStyle::Fill,
921            alpha: 0.0,
922            locale_list: None,
923            font_feature_settings: None,
924            font_variation_settings: None,
925        }
926    }
927}
928
929/// Hints the platform about the type of keyboard to show.
930#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
931pub enum KeyboardType {
932    #[default]
933    Unspecified,
934    Text,
935    Ascii,
936    Number,
937    Phone,
938    Uri,
939    Email,
940    Password,
941    NumberPassword,
942    Decimal,
943    PasswordVisible,
944    PostalAddress,
945    PersonName,
946    EmailSubject,
947    ShortMessage,
948    LongMessage,
949    Filter,
950    Phonetic,
951    DateTime,
952    Date,
953    Time,
954    NumberSigned,
955    DecimalSigned,
956    DecimalPassword,
957    NumberPasswordSigned,
958    DecimalPasswordSigned,
959}
960
961/// The action button on the IME (soft keyboard).
962#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
963pub enum ImeAction {
964    #[default]
965    Unspecified,
966    None,
967    Default,
968    Go,
969    Search,
970    Send,
971    Previous,
972    Next,
973    Done,
974}
975
976/// High-level IME purpose for the platform's soft keyboard.
977///
978/// Unlike [`KeyboardType`], which enumerates the many Compose-style keyboard
979/// layouts, this is the small set of intents platforms can actually react (for NativeA) to
980/// (password fields, e-mail addresses, URLs, phone numbers, plain text).
981/// On the web it also selects the `inputmode` attribute for mobile browsers.
982#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
983pub enum ImePurposeHint {
984    #[default]
985    Normal,
986    Password,
987    Email,
988    Url,
989    Phone,
990    Number,
991}
992
993impl KeyboardType {
994    /// Map this keyboard type to the coarser platform IME purpose.
995    pub fn ime_purpose_hint(self) -> ImePurposeHint {
996        use KeyboardType::*;
997        match self {
998            Password
999            | NumberPassword
1000            | DecimalPassword
1001            | NumberPasswordSigned
1002            | DecimalPasswordSigned => ImePurposeHint::Password,
1003            Email | EmailSubject => ImePurposeHint::Email,
1004            Uri => ImePurposeHint::Url,
1005            Phone => ImePurposeHint::Phone,
1006            Number | NumberSigned | Decimal | DecimalSigned => ImePurposeHint::Number,
1007            _ => ImePurposeHint::Normal,
1008        }
1009    }
1010}
1011
1012/// Scope provided to `KeyboardActions` callbacks, allowing fallback to the
1013/// platform's default IME action behavior. Corresponds to Compose's `KeyboardActionScope`.
1014pub trait KeyboardActionScope {
1015    fn default_keyboard_action(&self, action: ImeAction);
1016}
1017
1018/// Callbacks for IME action button presses on the soft keyboard.
1019/// Corresponds to Compose's legacy `KeyboardActions`.
1020#[derive(Clone, Default)]
1021pub struct KeyboardActions {
1022    pub on_done: Option<Rc<dyn Fn(&dyn KeyboardActionScope)>>,
1023    pub on_go: Option<Rc<dyn Fn(&dyn KeyboardActionScope)>>,
1024    pub on_next: Option<Rc<dyn Fn(&dyn KeyboardActionScope)>>,
1025    pub on_previous: Option<Rc<dyn Fn(&dyn KeyboardActionScope)>>,
1026    pub on_search: Option<Rc<dyn Fn(&dyn KeyboardActionScope)>>,
1027    pub on_send: Option<Rc<dyn Fn(&dyn KeyboardActionScope)>>,
1028}
1029
1030impl KeyboardActions {
1031    pub fn on_any(f: impl Fn(ImeAction, &dyn KeyboardActionScope) + 'static) -> Self {
1032        let f = Rc::new(f);
1033        KeyboardActions {
1034            on_done: Some({
1035                let f = f.clone();
1036                Rc::new(move |scope| f(ImeAction::Done, scope))
1037            }),
1038            on_go: Some({
1039                let f = f.clone();
1040                Rc::new(move |scope| f(ImeAction::Go, scope))
1041            }),
1042            on_next: Some({
1043                let f = f.clone();
1044                Rc::new(move |scope| f(ImeAction::Next, scope))
1045            }),
1046            on_previous: Some({
1047                let f = f.clone();
1048                Rc::new(move |scope| f(ImeAction::Previous, scope))
1049            }),
1050            on_search: Some({
1051                let f = f.clone();
1052                Rc::new(move |scope| f(ImeAction::Search, scope))
1053            }),
1054            on_send: Some(Rc::new(move |scope| f(ImeAction::Send, scope))),
1055        }
1056    }
1057}
1058
1059/// Handles IME action button presses. Single-callback interface used by the
1060/// new `BasicTextField(state, ...)` API. Corresponds to Compose's `KeyboardActionHandler`.
1061pub trait KeyboardActionHandler: Debug + 'static {
1062    fn on_keyboard_action(&self, perform_default: &dyn Fn());
1063}
1064
1065/// A simple `KeyboardActionHandler` that only executes the default behavior.
1066#[derive(Clone, Copy, Debug)]
1067pub struct DefaultKeyboardActionHandler;
1068
1069impl KeyboardActionHandler for DefaultKeyboardActionHandler {
1070    fn on_keyboard_action(&self, perform_default: &dyn Fn()) {
1071        perform_default();
1072    }
1073}
1074
1075/// Mutable text buffer used as the scope for `InputTransformation` and
1076/// `OutputTransformation`. Corresponds to Compose's `TextFieldBuffer`.
1077pub trait TextFieldBuffer {
1078    fn text(&self) -> &str;
1079    fn set_text(&mut self, text: &str);
1080    fn selection(&self) -> TextRange;
1081    fn set_selection(&mut self, sel: TextRange);
1082    fn length(&self) -> usize;
1083    fn replace(&mut self, start: usize, end: usize, text: &str);
1084    fn insert(&mut self, index: usize, text: &str);
1085    fn delete(&mut self, start: usize, end: usize);
1086    fn place_cursor_before_char_at(&mut self, index: usize);
1087    fn place_cursor_at_end(&mut self);
1088    fn select_all(&mut self);
1089    fn revert_all_changes(&mut self);
1090    fn original_text(&self) -> &str;
1091    fn original_selection(&self) -> TextRange;
1092    fn has_selection(&self) -> bool;
1093}
1094
1095/// Limits on the number of visible lines in a text field.
1096/// Corresponds to Compose's `TextFieldLineLimits`.
1097#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1098pub enum TextFieldLineLimits {
1099    SingleLine,
1100    MultiLine {
1101        min_height_in_lines: usize,
1102        max_height_in_lines: usize,
1103    },
1104}
1105
1106impl TextFieldLineLimits {
1107    pub fn default() -> Self {
1108        TextFieldLineLimits::MultiLine {
1109            min_height_in_lines: 1,
1110            max_height_in_lines: usize::MAX,
1111        }
1112    }
1113}
1114
1115/// Wraps the inner text field with custom decorations.
1116/// Corresponds to Compose's `TextFieldDecorator`.
1117pub trait TextFieldDecorator: Debug + 'static {
1118    fn decorate(&self, inner: crate::View) -> crate::View;
1119}
1120
1121/// A `TextFieldDecorator` that passes through the inner text field unchanged.
1122#[derive(Clone, Copy, Debug)]
1123pub struct DefaultTextFieldDecorator;
1124
1125impl TextFieldDecorator for DefaultTextFieldDecorator {
1126    fn decorate(&self, inner: crate::View) -> crate::View {
1127        inner
1128    }
1129}
1130
1131/// Transforms user input before it is applied to the text field.
1132/// Corresponds to Compose's `InputTransformation`.
1133pub trait InputTransformation: Debug + 'static {
1134    fn keyboard_options(&self) -> Option<KeyboardOptions> {
1135        None
1136    }
1137    fn transform_input(&self, buffer: &mut dyn TextFieldBuffer);
1138}
1139
1140/// Transforms text output for display.
1141/// Corresponds to Compose's `OutputTransformation`.
1142pub trait OutputTransformation: Debug + 'static {
1143    fn transform_output(&self, buffer: &mut dyn TextFieldBuffer);
1144}
1145
1146/// Internal 1-to-1 codepoint transformation for password obfuscation.
1147/// Corresponds to Compose's `CodepointTransformation`.
1148pub struct CodepointTransformation {
1149    pub transform: Box<dyn Fn(usize, char) -> char>,
1150}
1151
1152impl Clone for CodepointTransformation {
1153    fn clone(&self) -> Self {
1154        // Cannot clone Box<dyn Fn>; this is for internal use only.
1155        // In practice, CodepointTransformation is passed as an Option and
1156        // constructed fresh each time. If clone is needed, wrap the Fn in Rc.
1157        panic!("CodepointTransformation::clone() is not supported -> use Rc instead");
1158    }
1159}
1160
1161impl CodepointTransformation {
1162    pub fn new(transform: impl Fn(usize, char) -> char + 'static) -> Self {
1163        CodepointTransformation {
1164            transform: Box::new(transform),
1165        }
1166    }
1167
1168    pub fn transform(&self, codepoint_index: usize, codepoint: char) -> char {
1169        (self.transform)(codepoint_index, codepoint)
1170    }
1171}
1172
1173/// Input transformation settings: keyboard type, capitalization, and IME action.
1174/// Corresponds to Compose's `KeyboardOptions`.
1175#[derive(Clone, Copy, Debug, PartialEq)]
1176pub struct KeyboardOptions {
1177    pub keyboard_type: KeyboardType,
1178    pub capitalization: KeyboardCapitalization,
1179    pub ime_action: ImeAction,
1180    pub auto_correct_enabled: Option<bool>,
1181    pub show_keyboard_on_focus: Option<bool>,
1182    pub platform_ime_options: Option<&'static str>,
1183    pub hint_locales: Option<&'static str>,
1184}
1185
1186impl KeyboardOptions {
1187    pub const DEFAULT: KeyboardOptions = KeyboardOptions {
1188        keyboard_type: KeyboardType::Text,
1189        capitalization: KeyboardCapitalization::Unspecified,
1190        ime_action: ImeAction::Unspecified,
1191        auto_correct_enabled: None,
1192        show_keyboard_on_focus: None,
1193        platform_ime_options: None,
1194        hint_locales: None,
1195    };
1196
1197    pub const SECURE_TEXT_FIELD: KeyboardOptions = KeyboardOptions {
1198        keyboard_type: KeyboardType::Password,
1199        capitalization: KeyboardCapitalization::Unspecified,
1200        ime_action: ImeAction::Unspecified,
1201        auto_correct_enabled: Some(false),
1202        show_keyboard_on_focus: None,
1203        platform_ime_options: None,
1204        hint_locales: None,
1205    };
1206
1207    pub fn fill_unspecified_values_with(&self, other: Option<&KeyboardOptions>) -> KeyboardOptions {
1208        let other = match other {
1209            Some(o) => o,
1210            None => return *self,
1211        };
1212        KeyboardOptions {
1213            keyboard_type: if self.keyboard_type == KeyboardType::Unspecified {
1214                other.keyboard_type
1215            } else {
1216                self.keyboard_type
1217            },
1218            capitalization: if self.capitalization == KeyboardCapitalization::Unspecified {
1219                other.capitalization
1220            } else {
1221                self.capitalization
1222            },
1223            ime_action: if self.ime_action == ImeAction::Unspecified {
1224                other.ime_action
1225            } else {
1226                self.ime_action
1227            },
1228            auto_correct_enabled: self.auto_correct_enabled.or(other.auto_correct_enabled),
1229            show_keyboard_on_focus: self.show_keyboard_on_focus.or(other.show_keyboard_on_focus),
1230            platform_ime_options: self.platform_ime_options.or(other.platform_ime_options),
1231            hint_locales: self.hint_locales.or(other.hint_locales),
1232        }
1233    }
1234
1235    /// Returns a new [KeyboardOptions] that merges this with [other].
1236    /// [other]'s null or Unspecified values are replaced with this object's values.
1237    /// Corresponds to Compose's `KeyboardOptions.merge()`.
1238    pub fn merge(&self, other: Option<&KeyboardOptions>) -> KeyboardOptions {
1239        other.map_or(*self, |o| o.fill_unspecified_values_with(Some(self)))
1240    }
1241}
1242
1243impl Default for KeyboardOptions {
1244    fn default() -> Self {
1245        Self::DEFAULT
1246    }
1247}
1248
1249#[derive(Debug, Clone, PartialEq)]
1250pub struct SpanStyle {
1251    pub color: Option<Color>,
1252    pub font_size: Option<crate::units::Sp>,
1253    pub font_weight: Option<u16>,
1254    pub font_family: Option<&'static str>,
1255    pub font_style: Option<u8>,
1256    pub text_align: Option<crate::TextAlign>,
1257    pub letter_spacing: Option<crate::units::Sp>,
1258    pub line_height: Option<crate::units::Sp>,
1259    pub background: Option<Color>,
1260    pub text_decoration: Option<crate::TextDecoration>,
1261    pub text_direction: Option<crate::TextDirection>,
1262    pub font_synthesis: Option<FontSynthesis>,
1263    pub baseline_shift: Option<BaselineShift>,
1264    pub hyphens: Option<Hyphens>,
1265    pub line_break: Option<LineBreak>,
1266    pub text_indent: Option<TextIndent>,
1267    pub draw_style: Option<DrawStyle>,
1268    pub alpha: f32,
1269    /// OpenType font variation settings (e.g. "wght 700, opsz 24").
1270    pub font_variation_settings: Option<String>,
1271}
1272
1273impl SpanStyle {
1274    pub const fn default() -> Self {
1275        Self {
1276            color: None,
1277            font_size: None,
1278            font_weight: None,
1279            font_family: None,
1280            font_style: None,
1281            text_align: None,
1282            letter_spacing: None,
1283            line_height: None,
1284            background: None,
1285            text_decoration: None,
1286            text_direction: None,
1287            font_synthesis: None,
1288            baseline_shift: None,
1289            hyphens: None,
1290            line_break: None,
1291            text_indent: None,
1292            draw_style: None,
1293            alpha: 0.0,
1294            font_variation_settings: None,
1295        }
1296    }
1297
1298    pub fn color(mut self, c: Color) -> Self {
1299        self.color = Some(c);
1300        self
1301    }
1302
1303    pub fn font_size(mut self, size: crate::units::Sp) -> Self {
1304        self.font_size = Some(size);
1305        self
1306    }
1307
1308    pub fn text_decoration(mut self, d: TextDecoration) -> Self {
1309        self.text_decoration = Some(d);
1310        self
1311    }
1312
1313    pub fn font_weight(mut self, w: u16) -> Self {
1314        self.font_weight = Some(w);
1315        self
1316    }
1317
1318    pub fn font_style(mut self, s: u8) -> Self {
1319        self.font_style = Some(s);
1320        self
1321    }
1322
1323    pub fn draw_style(mut self, s: DrawStyle) -> Self {
1324        self.draw_style = Some(s);
1325        self
1326    }
1327
1328    pub fn background(mut self, c: Color) -> Self {
1329        self.background = Some(c);
1330        self
1331    }
1332
1333    pub fn baseline_shift(mut self, s: BaselineShift) -> Self {
1334        self.baseline_shift = Some(s);
1335        self
1336    }
1337}
1338
1339impl Default for SpanStyle {
1340    fn default() -> Self {
1341        Self::default()
1342    }
1343}
1344
1345/// A span of text with an associated style.
1346#[derive(Debug, Clone, PartialEq)]
1347pub struct TextSpan {
1348    /// Byte offset start in the original text.
1349    pub start: usize,
1350    /// Byte offset end (exclusive) in the original text.
1351    pub end: usize,
1352    pub style: SpanStyle,
1353    /// URL for clickable links.
1354    pub url: Option<Arc<str>>,
1355}
1356
1357/// Text with multiple styled spans.
1358///
1359/// Analogous to Compose's `AnnotatedString`.
1360#[derive(Debug, Clone, PartialEq)]
1361pub struct AnnotatedString {
1362    pub text: String,
1363    pub spans: Arc<[TextSpan]>,
1364}
1365
1366impl AnnotatedString {
1367    pub fn new(text: impl Into<String>, spans: Vec<TextSpan>) -> Self {
1368        let text = text.into();
1369        Self {
1370            text,
1371            spans: spans.into(),
1372        }
1373    }
1374
1375    pub fn as_str(&self) -> &str {
1376        &self.text
1377    }
1378}
1379
1380impl From<String> for AnnotatedString {
1381    fn from(text: String) -> Self {
1382        Self {
1383            text,
1384            spans: Arc::from([]),
1385        }
1386    }
1387}
1388
1389impl From<&str> for AnnotatedString {
1390    fn from(text: &str) -> Self {
1391        Self {
1392            text: text.to_string(),
1393            spans: Arc::from([]),
1394        }
1395    }
1396}
1397
1398/// Builder for constructing an `AnnotatedString`.
1399#[derive(Default)]
1400pub struct AnnotatedStringBuilder {
1401    text: String,
1402    spans: Vec<TextSpan>,
1403}
1404
1405impl AnnotatedStringBuilder {
1406    pub fn new() -> Self {
1407        Self::default()
1408    }
1409
1410    /// Append plain text (inherits parent style, or default if at top level).
1411    pub fn push(&mut self, text: &str) -> &mut Self {
1412        self.text.push_str(text);
1413        self
1414    }
1415
1416    /// Append text with a specific style.
1417    pub fn push_with_style(&mut self, text: &str, style: SpanStyle) -> &mut Self {
1418        let start = self.text.len();
1419        self.text.push_str(text);
1420        let end = self.text.len();
1421        if start < end {
1422            self.spans.push(TextSpan {
1423                start,
1424                end,
1425                style,
1426                url: None,
1427            });
1428        }
1429        self
1430    }
1431
1432    /// Append text in a specific color.
1433    pub fn push_color(&mut self, text: &str, color: Color) -> &mut Self {
1434        self.push_with_style(text, SpanStyle::default().color(color))
1435    }
1436
1437    /// Append text with a clickable link URL (auto-applies underline + blue color).
1438    pub fn push_link(&mut self, text: &str, url: impl Into<Arc<str>>) -> &mut Self {
1439        let start = self.text.len();
1440        self.text.push_str(text);
1441        let end = self.text.len();
1442        if start < end {
1443            self.spans.push(TextSpan {
1444                start,
1445                end,
1446                style: SpanStyle::default()
1447                    .color(Color::from_rgba(0x15, 0x76, 0xFF, 255))
1448                    .text_decoration(TextDecoration::UNDERLINE),
1449                url: Some(url.into()),
1450            });
1451        }
1452        self
1453    }
1454
1455    /// Apply a style to a range of already-appended text.
1456    pub fn add_style(&mut self, start: usize, end: usize, style: SpanStyle) -> &mut Self {
1457        if start < end && end <= self.text.len() {
1458            self.spans.push(TextSpan {
1459                start,
1460                end,
1461                style,
1462                url: None,
1463            });
1464        }
1465        self
1466    }
1467
1468    pub fn build(&mut self) -> AnnotatedString {
1469        let text = std::mem::take(&mut self.text);
1470        self.spans.sort_by_key(|s| s.start);
1471        // Merge overlapping/adjacent spans with same style
1472        let mut merged: Vec<TextSpan> = Vec::new();
1473        for span in std::mem::take(&mut self.spans) {
1474            if let Some(last) = merged.last_mut()
1475                && last.end == span.start
1476                && last.style == span.style
1477            {
1478                last.end = span.end;
1479                continue;
1480            }
1481            merged.push(span);
1482        }
1483        AnnotatedString {
1484            text,
1485            spans: merged.into(),
1486        }
1487    }
1488}
1489
1490/// Horizontal text alignment.
1491#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Default)]
1492pub enum TextAlign {
1493    Left,
1494    Right,
1495    Center,
1496    Justify,
1497    Start,
1498    End,
1499    #[default]
1500    Unspecified,
1501}
1502
1503/// Font weight as a numeric value 100-900, matching CSS `font-weight`.
1504#[derive(Clone, Copy, Debug, PartialEq)]
1505pub struct FontWeight(pub u16);
1506
1507impl FontWeight {
1508    pub const THIN: FontWeight = FontWeight(100);
1509    pub const EXTRA_LIGHT: FontWeight = FontWeight(200);
1510    pub const LIGHT: FontWeight = FontWeight(300);
1511    pub const NORMAL: FontWeight = FontWeight(400);
1512    pub const MEDIUM: FontWeight = FontWeight(500);
1513    pub const SEMI_BOLD: FontWeight = FontWeight(600);
1514    pub const BOLD: FontWeight = FontWeight(700);
1515    pub const EXTRA_BOLD: FontWeight = FontWeight(800);
1516    pub const BLACK: FontWeight = FontWeight(900);
1517}
1518
1519impl Default for FontWeight {
1520    fn default() -> Self {
1521        FontWeight::NORMAL
1522    }
1523}
1524
1525/// Font style: normal or italic.
1526#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Default)]
1527pub enum FontStyle {
1528    #[default]
1529    Normal,
1530    Italic,
1531}
1532
1533/// Text decoration state.
1534#[derive(Clone, Copy, Debug, PartialEq, Default)]
1535pub struct TextDecoration {
1536    pub underline: bool,
1537    pub strikethrough: bool,
1538    pub color: Option<Color>,
1539}
1540
1541impl TextDecoration {
1542    pub const UNDERLINE: TextDecoration = TextDecoration {
1543        underline: true,
1544        strikethrough: false,
1545        color: None,
1546    };
1547    pub const STRIKETHROUGH: TextDecoration = TextDecoration {
1548        underline: false,
1549        strikethrough: true,
1550        color: None,
1551    };
1552}
1553
1554/// Convenience function to build an `AnnotatedString`.
1555pub fn build_annotated_string(b: impl FnOnce(&mut AnnotatedStringBuilder)) -> AnnotatedString {
1556    let mut builder = AnnotatedStringBuilder::new();
1557    b(&mut builder);
1558    builder.build()
1559}