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