|
| 1 | +//! An example of masked text inputs for a credit card form. |
| 2 | +//! |
| 3 | +//! The displayed value contains formatting characters (spaces, `/`), |
| 4 | +//! while the stored value contains only the raw digits. |
| 5 | +//! |
| 6 | +//! Cursor positions are automatically adjusted so that typing at the end |
| 7 | +//! of a field keeps the cursor correctly aligned after formatting. |
| 8 | +//! |
| 9 | +//! Run with: |
| 10 | +//! ```sh |
| 11 | +//! cargo run --package masked_input |
| 12 | +//! ``` |
| 13 | +
|
| 14 | +use iced::widget::{center, column, row, text, text_input}; |
| 15 | +use iced::{Element}; |
| 16 | + |
| 17 | +pub fn main() -> iced::Result { |
| 18 | + iced::application( |
| 19 | + CreditCardForm::new, |
| 20 | + CreditCardForm::update, |
| 21 | + CreditCardForm::view, |
| 22 | + ) |
| 23 | + .title("Credit Card Masked Input") |
| 24 | + .run() |
| 25 | +} |
| 26 | + |
| 27 | +struct CreditCardForm { |
| 28 | + card_number: String, |
| 29 | + expiry: String, |
| 30 | + cvv: String, |
| 31 | +} |
| 32 | + |
| 33 | +#[derive(Debug, Clone)] |
| 34 | +enum Message { |
| 35 | + CardNumberChanged(String), |
| 36 | + ExpiryChanged(String), |
| 37 | + CvvChanged(String), |
| 38 | +} |
| 39 | + |
| 40 | +impl CreditCardForm { |
| 41 | + fn new() -> Self { |
| 42 | + Self { |
| 43 | + card_number: String::new(), |
| 44 | + expiry: String::new(), |
| 45 | + cvv: String::new(), |
| 46 | + } |
| 47 | + } |
| 48 | + |
| 49 | + fn update(&mut self, message: Message) { |
| 50 | + match message { |
| 51 | + Message::CardNumberChanged(value) => { |
| 52 | + let raw: String = value.chars().filter(|c| c.is_ascii_digit()).collect(); |
| 53 | + |
| 54 | + if raw.len() <= 16 { |
| 55 | + self.card_number = raw; |
| 56 | + } |
| 57 | + } |
| 58 | + Message::ExpiryChanged(value) => { |
| 59 | + let raw: String = value.chars().filter(|c| c.is_ascii_digit()).collect(); |
| 60 | + |
| 61 | + if raw.len() <= 4 { |
| 62 | + self.expiry = raw; |
| 63 | + } |
| 64 | + } |
| 65 | + Message::CvvChanged(value) => { |
| 66 | + let raw: String = value.chars().filter(|c| c.is_ascii_digit()).collect(); |
| 67 | + |
| 68 | + if raw.len() <= 4 { |
| 69 | + self.cvv = raw; |
| 70 | + } |
| 71 | + } |
| 72 | + } |
| 73 | + } |
| 74 | + |
| 75 | + fn view(&self) -> Element<'_, Message> { |
| 76 | + let card_display = format_card(&self.card_number); |
| 77 | + let expiry_display = format_expiry(&self.expiry); |
| 78 | + |
| 79 | + // Compute the cursor position in the formatted display text. |
| 80 | + // The raw cursor is at the end (cursor = raw.len()) because the |
| 81 | + // most common editing operations (typing, backspace at end) leave |
| 82 | + // the cursor at the end of the content. |
| 83 | + let card_cursor = raw_cursor_to_display(&self.card_number, self.card_number.len(), 4, None); |
| 84 | + let expiry_cursor = raw_cursor_to_display(&self.expiry, self.expiry.len(), 4, Some(2)); |
| 85 | + |
| 86 | + let content = column![ |
| 87 | + text("Credit Card").size(24), |
| 88 | + // Card number field — formatted with spaces every 4 digits |
| 89 | + column![ |
| 90 | + text("Card Number").size(14), |
| 91 | + text_input("1234 5678 9012 3456", &card_display) |
| 92 | + .on_input(Message::CardNumberChanged) |
| 93 | + .cursor_at(card_cursor) |
| 94 | + .width(300), |
| 95 | + text(format!("Stored: {}", self.card_number)).size(12), |
| 96 | + ] |
| 97 | + .spacing(4), |
| 98 | + // Expiry and CVV side by side |
| 99 | + row![ |
| 100 | + column![ |
| 101 | + text("Expiry").size(14), |
| 102 | + text_input("MM/YY", &expiry_display) |
| 103 | + .on_input(Message::ExpiryChanged) |
| 104 | + .cursor_at(expiry_cursor) |
| 105 | + .width(100), |
| 106 | + text(format!("Stored: {}", self.expiry)).size(12), |
| 107 | + ] |
| 108 | + .spacing(4), |
| 109 | + column![ |
| 110 | + text("CVV").size(14), |
| 111 | + text_input("123", &self.cvv) |
| 112 | + .on_input(Message::CvvChanged) |
| 113 | + .secure(true) |
| 114 | + .width(100), |
| 115 | + ] |
| 116 | + .spacing(4), |
| 117 | + ] |
| 118 | + .spacing(20), |
| 119 | + ] |
| 120 | + .spacing(20) |
| 121 | + .padding(20) |
| 122 | + .max_width(400); |
| 123 | + |
| 124 | + center(content).into() |
| 125 | + } |
| 126 | +} |
| 127 | + |
| 128 | +/// Formats card digits with a space every 4 digits. |
| 129 | +/// |
| 130 | +/// "4111111111111111" becomes "4111 1111 1111 1111" |
| 131 | +fn format_card(digits: &str) -> String { |
| 132 | + let mut formatted = String::with_capacity(19); |
| 133 | + |
| 134 | + for (i, ch) in digits.chars().enumerate() { |
| 135 | + if i > 0 && i % 4 == 0 { |
| 136 | + formatted.push(' '); |
| 137 | + } |
| 138 | + |
| 139 | + formatted.push(ch); |
| 140 | + } |
| 141 | + |
| 142 | + formatted |
| 143 | +} |
| 144 | + |
| 145 | +/// Formats expiry digits as MM/YY. |
| 146 | +/// |
| 147 | +/// "1228" becomes "12/28" |
| 148 | +fn format_expiry(digits: &str) -> String { |
| 149 | + let mut formatted = String::with_capacity(5); |
| 150 | + |
| 151 | + for (i, ch) in digits.chars().enumerate() { |
| 152 | + if i == 2 { |
| 153 | + formatted.push('/'); |
| 154 | + } |
| 155 | + |
| 156 | + formatted.push(ch); |
| 157 | + } |
| 158 | + |
| 159 | + formatted |
| 160 | +} |
| 161 | + |
| 162 | +/// Converts a cursor position in raw digit text to the corresponding position |
| 163 | +/// in the formatted display text. |
| 164 | +/// |
| 165 | +/// `group_size` is the number of digits between each periodic separator |
| 166 | +/// (e.g., 4 for credit card groups, producing a space every 4 digits). |
| 167 | +/// `separator_at` is an optional one-off separator position |
| 168 | +/// (e.g., `Some(2)` for MM/YY expiry formatting, producing a `/` after 2 digits). |
| 169 | +/// |
| 170 | +/// The function counts how many formatting characters appear *before* the |
| 171 | +/// cursor position in the raw string and adds that many to the cursor position. |
| 172 | +/// |
| 173 | +/// For card numbers (space at positions 4, 8, 12...): |
| 174 | +/// This is `raw_cursor + floor((raw_cursor - 1) / 4)` for `raw_cursor > 0`. |
| 175 | +/// |
| 176 | +/// For expiry (slash at position 2): |
| 177 | +/// This adds 1 once the cursor passes position 2 and there are >= 3 chars. |
| 178 | +fn raw_cursor_to_display( |
| 179 | + raw: &str, |
| 180 | + raw_cursor: usize, |
| 181 | + group_size: usize, |
| 182 | + separator_at: Option<usize>, |
| 183 | +) -> usize { |
| 184 | + let mut display_cursor = raw_cursor; |
| 185 | + |
| 186 | + // Add periodic separators (space every `group_size` digits). |
| 187 | + // A separator is inserted before a digit at index `group_size`, `2*group_size`, etc. |
| 188 | + // The separator is only before the cursor if its position < raw_cursor. |
| 189 | + if raw_cursor > 0 { |
| 190 | + display_cursor += (raw_cursor - 1) / group_size; |
| 191 | + } |
| 192 | + |
| 193 | + // Add one-off separator (slash for expiry at position 2). |
| 194 | + // It only appears in the display when there is content at the separator |
| 195 | + // position or beyond (i.e., raw.len() > separator_at). |
| 196 | + // And it shifts cursor positions that come after it (raw_cursor > separator_at). |
| 197 | + if let Some(sep) = separator_at { |
| 198 | + if raw_cursor > sep && raw.len() > sep { |
| 199 | + display_cursor += 1; |
| 200 | + } |
| 201 | + } |
| 202 | + |
| 203 | + display_cursor |
| 204 | +} |
0 commit comments