Skip to main content

cranpose_ui/widgets/
secure_text_field.rs

1//! Compose's `BasicSecureTextField`: a text field for a secret.
2
3use std::time::Duration;
4
5use cranpose_core::{LaunchedEffectAsync, NodeId, mutableStateOf, remember, rememberKeyed};
6use cranpose_foundation::text::{
7    InputTransformation, OutputTransformation, TextFieldLineLimits, TextFieldState,
8    previous_grapheme_boundary,
9};
10use unicode_segmentation::UnicodeSegmentation;
11
12use super::{BasicTextFieldOptions, basic_text_field::TextFieldNode};
13use crate::{
14    composable,
15    keyboard_options::{KeyboardOptions, KeyboardType},
16    modifier::Modifier,
17};
18
19/// How long [`TextObfuscationMode::RevealLastTyped`] shows a typed
20/// character, as Compose's secure field does.
21const REVEAL_DURATION: Duration = Duration::from_millis(1500);
22
23/// How a secure field hides its text: Compose's `TextObfuscationMode`.
24#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
25pub enum TextObfuscationMode {
26    /// Every character hidden, but the last one typed shows for a moment.
27    #[default]
28    RevealLastTyped,
29    /// Every character hidden.
30    Hidden,
31    /// The text shown as it is, for a "show password" toggle.
32    Visible,
33}
34
35/// Options for a [`BasicSecureTextField`].
36#[derive(Clone, Debug, PartialEq)]
37pub struct SecureTextFieldOptions {
38    /// The field's options. Their output transformation is replaced by the
39    /// obfuscation.
40    pub field: BasicTextFieldOptions,
41    pub obfuscation_mode: TextObfuscationMode,
42    /// The character every hidden character shows as.
43    pub obfuscation_character: char,
44}
45
46impl Default for SecureTextFieldOptions {
47    fn default() -> Self {
48        Self {
49            field: BasicTextFieldOptions {
50                line_limits: TextFieldLineLimits::SingleLine,
51                keyboard_options: KeyboardOptions {
52                    keyboard_type: KeyboardType::Password,
53                    auto_correct: false,
54                    ..KeyboardOptions::default()
55                },
56                ..BasicTextFieldOptions::default()
57            },
58            obfuscation_mode: TextObfuscationMode::default(),
59            obfuscation_character: '\u{2022}',
60        }
61    }
62}
63
64/// A text field for a password or another secret, as Compose's
65/// `BasicSecureTextField`: it shows its characters as
66/// [`SecureTextFieldOptions::obfuscation_character`], asks for the
67/// platform's secure keyboard, leaves the text out of copy, cut and
68/// accessibility, and keeps it out of debug output.
69///
70/// # Example
71///
72/// ```rust
73/// use cranpose_ui::*;
74///
75/// #[composable]
76/// fn PasswordField() {
77///     let password = cranpose_core::remember(|| TextFieldState::new("")).with(|state| *state);
78///     BasicSecureTextField(
79///         password,
80///         Modifier::empty(),
81///         SecureTextFieldOptions::default(),
82///     );
83/// }
84/// ```
85#[composable]
86pub fn BasicSecureTextField(
87    state: TextFieldState,
88    modifier: Modifier,
89    options: SecureTextFieldOptions,
90) -> NodeId {
91    let SecureTextFieldOptions {
92        field,
93        obfuscation_mode,
94        obfuscation_character,
95    } = options;
96    // The start of the character typed last, while it shows.
97    let revealed = remember(|| mutableStateOf(None::<usize>)).with(|state| *state);
98    let reveal = obfuscation_mode == TextObfuscationMode::RevealLastTyped;
99    let shown_character = revealed.get().filter(|_| reveal);
100    let output_transformation = rememberKeyed(
101        (obfuscation_mode, obfuscation_character, shown_character),
102        |&(mode, mask, shown)| {
103            (mode != TextObfuscationMode::Visible).then(|| obfuscation(mask, shown))
104        },
105    );
106    let input_transformation = rememberKeyed(
107        (reveal, field.input_transformation.clone()),
108        move |(reveal, filter)| {
109            if *reveal {
110                Some(note_typed_character(filter.clone(), revealed))
111            } else {
112                filter.clone()
113            }
114        },
115    );
116    LaunchedEffectAsync(shown_character, move |_| {
117        Box::pin(async move {
118            if shown_character.is_some() {
119                cranpose_core::delay(REVEAL_DURATION).await;
120                revealed.set(None);
121            }
122        })
123    });
124    TextFieldNode(
125        state,
126        modifier,
127        BasicTextFieldOptions {
128            input_transformation,
129            output_transformation,
130            ..field
131        },
132        None,
133        true,
134    )
135}
136
137/// Shows every character as `mask` but the one that starts at `shown`.
138fn obfuscation(mask: char, shown: Option<usize>) -> OutputTransformation {
139    let mut buffer = [0; 4];
140    let mask: std::rc::Rc<str> = std::rc::Rc::from(&*mask.encode_utf8(&mut buffer));
141    OutputTransformation::new(move |text, changes| {
142        for (start, grapheme) in text.grapheme_indices(true) {
143            if Some(start) != shown {
144                changes.replace(start..start + grapheme.len(), &mask);
145            }
146        }
147    })
148}
149
150/// `filter`, then a note of where a single typed character landed, which
151/// the field shows for a moment. Any other edit hides it again.
152fn note_typed_character(
153    filter: Option<InputTransformation>,
154    revealed: cranpose_core::MutableState<Option<usize>>,
155) -> InputTransformation {
156    let note = InputTransformation::new(move |original, buffer| {
157        let selection = buffer.selection();
158        let caret = selection.end;
159        let start = previous_grapheme_boundary(buffer.text(), caret);
160        let grown = buffer.len().saturating_sub(original.text.len());
161        let typed_one = selection.collapsed() && grown > 0 && grown == caret - start;
162        revealed.set(typed_one.then_some(start));
163    });
164    match filter {
165        Some(filter) => filter.then(note),
166        None => note,
167    }
168}