cranpose_ui/widgets/
secure_text_field.rs1use 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
19const REVEAL_DURATION: Duration = Duration::from_millis(1500);
22
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
25pub enum TextObfuscationMode {
26 #[default]
28 RevealLastTyped,
29 Hidden,
31 Visible,
33}
34
35#[derive(Clone, Debug, PartialEq)]
37pub struct SecureTextFieldOptions {
38 pub field: BasicTextFieldOptions,
41 pub obfuscation_mode: TextObfuscationMode,
42 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#[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 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
137fn 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
150fn 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}