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,
43 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#[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 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
138fn 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
151fn 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}