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