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