Skip to content

Commit 993cafd

Browse files
committed
Add cursor_at function to the TextInput and a masked input example
1 parent 228c077 commit 993cafd

4 files changed

Lines changed: 244 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/masked_input/Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[package]
2+
name = "masked_input"
3+
version = "0.1.0"
4+
edition = "2024"
5+
publish = false
6+
7+
[dependencies]
8+
iced.workspace = true

examples/masked_input/src/main.rs

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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+
}

widget/src/text_input.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ where
114114
icon: Option<Icon<Renderer::Font>>,
115115
class: Theme::Class<'a>,
116116
last_status: Option<Status>,
117+
cursor_at: Option<usize>,
117118
}
118119

119120
/// The default [`Padding`] of a [`TextInput`].
@@ -145,6 +146,7 @@ where
145146
icon: None,
146147
class: Theme::default(),
147148
last_status: None,
149+
cursor_at: None,
148150
}
149151
}
150152

@@ -160,6 +162,18 @@ where
160162
self
161163
}
162164

165+
/// Sets the cursor position of the [`TextInput`].
166+
///
167+
/// This can be used to correct the cursor position after changing the
168+
/// displayed value (e.g., adding formatting characters to a masked input).
169+
///
170+
/// The position is clamped to the value length. Use [`usize::MAX`] to
171+
/// move the cursor to the end.
172+
pub fn cursor_at(mut self, position: usize) -> Self {
173+
self.cursor_at = Some(position);
174+
self
175+
}
176+
163177
/// Sets the message that should be produced when some text is typed into
164178
/// the [`TextInput`].
165179
///
@@ -610,6 +624,17 @@ where
610624
if self.on_input.is_none() {
611625
state.is_pasting = None;
612626
}
627+
628+
// Apply externally requested cursor position
629+
if let Some(position) = self.cursor_at.take() {
630+
let text_len = self.value.len();
631+
632+
if position >= text_len {
633+
state.move_cursor_to_end();
634+
} else {
635+
state.move_cursor_to(position);
636+
}
637+
}
613638
}
614639

615640
fn size(&self) -> Size<Length> {

0 commit comments

Comments
 (0)