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