Skip to content

Commit c3f71fb

Browse files
committed
Add Formatter trait for validating & formatting text
This is a mechanism for handling conversions back and forth between values and their textual representations. Its major use case is managing user input in text fields when the input is intended to represent some other value type. This patch includes the trait, a simple implementation based on `FromStr` (intended as a replacement for the `Parse` widget) and a new ValueTextBox widget that uses some Formatter to manage a TextBox. The behaviour of the ValueTextBox differs from the normal TextBox in one very important way; it does not attempt to update the data until editing has 'completed'. Editing is completed when the user presses <return> or attempts to navigate away from the textbox; the user can also manually cancel editing by pressing <esc>, which discards the edit. These interactions (especially the hard-wired key handling) feels a bit brittle, and I'd like to try and find a more systematic approach. This is still a draft; there are a number of rough edges, and other things I'm not totally happy with, including: - it uses String everywhere; I might prefer Arc<String> at least - there is no good mechanism for actually reporting errors back up the tree - the error type itself is a bit too fancy, and should be simplified - I'd like to add a general purpose NumberFormatter - I'd like to look into incorporating this into the existing TextBox, including the idea of 'on_completion' and 'on_cancel' handling. In any case, I'm happy with the progress here; at the very least this is a much more robust and flexible alternative to the current Parse widget. I have updated `examples/parse.rs` to show off some of what is possible with this API; that example should probably be renamed.
1 parent d8da4b6 commit c3f71fb

File tree

7 files changed

+822
-21
lines changed

7 files changed

+822
-21
lines changed

druid/examples/parse.rs

Lines changed: 362 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,29 +12,375 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
use druid::widget::{Align, Flex, Label, Parse, TextBox};
16-
use druid::{AppLauncher, LocalizedString, Widget, WindowDesc};
15+
//! Demonstrates how to use value formatters to constrain the contents
16+
//! of a text box.
17+
18+
use druid::text::format::{Formatter, Validation, ValidationError};
19+
use druid::text::Selection;
20+
use druid::widget::{Flex, Label, TextBox};
21+
use druid::{AppLauncher, Data, Lens, Widget, WidgetExt, WindowDesc};
22+
23+
/// Various values that we are going to use with formatters.
24+
#[derive(Debug, Clone, Data, Lens)]
25+
struct AppData {
26+
dollars: f64,
27+
euros: f64,
28+
pounds: f64,
29+
postal_code: PostalCode,
30+
dont_type_cat: String,
31+
}
1732

1833
pub fn main() {
19-
let main_window = WindowDesc::new(ui_builder).title(
20-
LocalizedString::new("parse-demo-window-title").with_placeholder("Number Parsing Demo"),
21-
);
22-
let data = Some(0);
34+
let main_window = WindowDesc::new(ui_builder).title("Formatting and Validation");
35+
36+
let data = AppData {
37+
dollars: 12.2,
38+
euros: -20.0,
39+
pounds: 1337.,
40+
postal_code: PostalCode::new("H0H0H0").unwrap(),
41+
dont_type_cat: String::new(),
42+
};
43+
2344
AppLauncher::with_window(main_window)
2445
.use_simple_logger()
2546
.launch(data)
2647
.expect("launch failed");
2748
}
2849

29-
fn ui_builder() -> impl Widget<Option<u32>> {
30-
let label = Label::new(|data: &Option<u32>, _env: &_| {
31-
data.map_or_else(|| "Invalid input".into(), |x| x.to_string())
32-
});
33-
let input = Parse::new(TextBox::new());
50+
fn ui_builder() -> impl Widget<AppData> {
51+
Flex::column()
52+
.cross_axis_alignment(druid::widget::CrossAxisAlignment::End)
53+
.with_child(
54+
Flex::row()
55+
.with_child(Label::new("Dollars:"))
56+
.with_default_spacer()
57+
.with_child(TextBox::new().with_formatter(NaiveCurrencyFormatter::DOLLARS))
58+
.lens(AppData::dollars),
59+
)
60+
.with_default_spacer()
61+
.with_child(
62+
Flex::row()
63+
.with_child(Label::new("Euros, often:"))
64+
.with_default_spacer()
65+
.with_child(TextBox::new().with_formatter(NaiveCurrencyFormatter::EUROS))
66+
.lens(AppData::euros),
67+
)
68+
.with_default_spacer()
69+
.with_child(
70+
Flex::row()
71+
.with_child(Label::new("Sterling Quidpence:"))
72+
.with_default_spacer()
73+
.with_child(TextBox::new().with_formatter(NaiveCurrencyFormatter::GBP))
74+
.lens(AppData::pounds),
75+
)
76+
.with_default_spacer()
77+
.with_child(
78+
Flex::row()
79+
.with_child(Label::new("Postal Code:"))
80+
.with_default_spacer()
81+
.with_child(
82+
TextBox::new()
83+
.with_placeholder("H1M 0M0")
84+
.with_formatter(CanadianPostalCodeFormatter),
85+
)
86+
.lens(AppData::postal_code),
87+
)
88+
.with_default_spacer()
89+
.with_child(
90+
Flex::row()
91+
.with_child(Label::new("Cat selector:"))
92+
.with_default_spacer()
93+
.with_child(
94+
TextBox::new()
95+
.with_placeholder("Don't type 'cat'")
96+
.with_formatter(CatSelectingFormatter)
97+
.fix_width(140.0),
98+
)
99+
.lens(AppData::dont_type_cat),
100+
)
101+
.center()
102+
}
103+
104+
/// A formatter that can display currency values.
105+
struct NaiveCurrencyFormatter {
106+
currency_symbol: char,
107+
thousands_separator: char,
108+
decimal_separator: char,
109+
}
110+
111+
/// A `Formatter` for postal codes, which are the format A0A 0A0 where 'A' is
112+
/// any uppercase ascii character, and '0' is any numeral.
113+
///
114+
/// This formatter will accept lowercase characters as input, but will replace
115+
/// them with uppercase characters.
116+
struct CanadianPostalCodeFormatter;
117+
118+
/// A Canadian postal code, in the format 'A0A0A0'.
119+
#[derive(Debug, Clone, Copy, Data)]
120+
struct PostalCode {
121+
chars: [u8; 6],
122+
}
123+
124+
/// A formatter that sets the selection to the first occurance of the word 'cat'
125+
/// in an input string, if it is found.
126+
struct CatSelectingFormatter;
127+
128+
impl NaiveCurrencyFormatter {
129+
const DOLLARS: NaiveCurrencyFormatter = NaiveCurrencyFormatter {
130+
currency_symbol: '$',
131+
thousands_separator: ',',
132+
decimal_separator: '.',
133+
};
134+
135+
const EUROS: NaiveCurrencyFormatter = NaiveCurrencyFormatter {
136+
currency_symbol: '€',
137+
thousands_separator: '.',
138+
decimal_separator: ',',
139+
};
140+
141+
const GBP: NaiveCurrencyFormatter = NaiveCurrencyFormatter {
142+
currency_symbol: '£',
143+
thousands_separator: '.',
144+
decimal_separator: ',',
145+
};
146+
}
147+
148+
impl Formatter<f64> for NaiveCurrencyFormatter {
149+
fn format(&self, value: &f64) -> String {
150+
if !value.is_normal() {
151+
return format!("{}0{}00", self.currency_symbol, self.decimal_separator);
152+
}
153+
154+
let mut components = Vec::new();
155+
let mut major_part = value.abs().trunc() as usize;
156+
let minor_part = (value.abs().fract() * 100.0).round() as usize;
157+
158+
let bonus_rounding_dollar = minor_part / 100;
159+
160+
components.push(format!("{}{:02}", self.decimal_separator, minor_part % 100));
161+
if major_part == 0 {
162+
components.push('0'.to_string());
163+
}
164+
165+
while major_part > 0 {
166+
let remain = major_part % 1000;
167+
major_part /= 1000;
168+
if major_part > 0 {
169+
components.push(format!("{}{:03}", self.thousands_separator, remain));
170+
} else {
171+
components.push((remain + bonus_rounding_dollar).to_string());
172+
}
173+
}
174+
if value.is_sign_negative() {
175+
components.push(format!("-{}", self.currency_symbol));
176+
} else {
177+
components.push(self.currency_symbol.to_string());
178+
}
179+
180+
components.iter().rev().flat_map(|s| s.chars()).collect()
181+
}
182+
183+
fn format_for_editing(&self, value: &f64) -> String {
184+
self.format(value)
185+
.chars()
186+
.filter(|c| *c != self.currency_symbol)
187+
.collect()
188+
}
189+
190+
fn value(&self, input: &str) -> Result<f64, ValidationError> {
191+
// we need to convert from our naive localized representation back into
192+
// rust's float representation
193+
let decimal_pos = input
194+
.bytes()
195+
.rposition(|b| b as char == self.decimal_separator);
196+
let (major, minor) = input.split_at(decimal_pos.unwrap_or_else(|| input.len()));
197+
let canonical: String = major
198+
.chars()
199+
.filter(|c| *c != self.thousands_separator)
200+
.chain(Some('.'))
201+
.chain(minor.chars().skip(1))
202+
.collect();
203+
canonical.parse().map_err(ValidationError::from_err)
204+
}
205+
206+
fn validate_partial_input(&self, input: &str, _sel: &Selection) -> Validation {
207+
if input.is_empty() {
208+
return Validation::success();
209+
}
210+
211+
let mut char_iter = input.chars();
212+
if !matches!(char_iter.next(), Some(c) if c.is_ascii_digit() || c == '-') {
213+
return Validation::failure_with_message("invalid leading characters");
214+
}
215+
let mut char_iter =
216+
char_iter.skip_while(|c| c.is_ascii_digit() || *c == self.thousands_separator);
217+
match char_iter.next() {
218+
None => return Validation::success(),
219+
Some(c) if c == self.decimal_separator => (),
220+
Some(c) => return Validation::failure_with_message(format!("unexpected char '{}'", c)),
221+
};
222+
223+
// we're after the decimal, allow up to 2 digits
224+
let (d1, d2, d3) = (char_iter.next(), char_iter.next(), char_iter.next());
225+
match (d1, d2, d3) {
226+
(_, _, Some(_)) => Validation::failure_with_message(""),
227+
(Some(c), None, _) if c.is_ascii_digit() => Validation::success(),
228+
(None, None, _) => Validation::success(),
229+
(Some(c1), Some(c2), None) if c1.is_ascii_digit() && c2.is_ascii_digit() => {
230+
Validation::success()
231+
}
232+
_ => Validation::failure_with_message("invalid trailing characters"),
233+
}
234+
}
235+
}
236+
237+
//TODO: never write parsing code like this again
238+
impl Formatter<PostalCode> for CanadianPostalCodeFormatter {
239+
fn format(&self, value: &PostalCode) -> String {
240+
value.to_string()
241+
}
242+
243+
fn validate_partial_input(&self, input: &str, sel: &Selection) -> Validation {
244+
let mut chars = input.chars();
245+
let mut valid = true;
246+
let mut has_space = false;
247+
if matches!(chars.next(), Some(c) if !c.is_ascii_alphabetic()) {
248+
valid = false;
249+
}
250+
if matches!(chars.next(), Some(c) if !c.is_ascii_digit()) {
251+
valid = false;
252+
}
253+
if matches!(chars.next(), Some(c) if !c.is_ascii_alphabetic()) {
254+
valid = false;
255+
}
256+
match chars.next() {
257+
Some(' ') => {
258+
has_space = true;
259+
if matches!(chars.next(), Some(c) if !c.is_ascii_digit()) {
260+
valid = false;
261+
}
262+
}
263+
Some(other) if !other.is_ascii_digit() => valid = false,
264+
_ => (),
265+
}
266+
267+
if matches!(chars.next(), Some(c) if !c.is_ascii_alphabetic()) {
268+
valid = false;
269+
}
270+
271+
if matches!(chars.next(), Some(c) if !c.is_ascii_digit()) {
272+
valid = false;
273+
}
274+
275+
if chars.next().is_some() {
276+
valid = false;
277+
}
278+
279+
if valid {
280+
// if valid we convert to canonical format; h1h2h2 becomes H!H 2H2
281+
let (replacement_text, sel) = if input.len() < 4 || has_space {
282+
(input.to_uppercase(), None)
283+
} else {
284+
//let split_at = 3.min(input.len().saturating_sub(1));
285+
let (first, second) = input.split_at(3);
286+
let insert_space = if second.bytes().next() == Some(b' ') {
287+
None
288+
} else {
289+
Some(' ')
290+
};
291+
let sel = if insert_space.is_some() && sel.is_caret() {
292+
Some(Selection::caret(sel.min() + 1))
293+
} else {
294+
None
295+
};
296+
(
297+
first
298+
.chars()
299+
.map(|c| c.to_ascii_uppercase())
300+
.chain(insert_space)
301+
.chain(second.chars().map(|c| c.to_ascii_uppercase()))
302+
.collect(),
303+
sel,
304+
)
305+
};
306+
307+
if let Some(replacement_sel) = sel {
308+
Validation::success()
309+
.change_text(replacement_text)
310+
.change_selection(replacement_sel)
311+
} else {
312+
Validation::success().change_text(replacement_text)
313+
}
314+
} else {
315+
Validation::failure_with_message("this api sucks")
316+
}
317+
}
318+
319+
#[allow(clippy::clippy::many_single_char_names, clippy::clippy::match_ref_pats)]
320+
fn value(&self, input: &str) -> Result<PostalCode, ValidationError> {
321+
match input.as_bytes() {
322+
&[a, b, c, d, e, f] => PostalCode::from_bytes([a, b, c, d, e, f]),
323+
&[a, b, c, b' ', d, e, f] => PostalCode::from_bytes([a, b, c, d, e, f]),
324+
_ => Err(ValidationError::with_message(
325+
"wrong number of things".into(),
326+
)),
327+
}
328+
}
329+
}
330+
331+
impl PostalCode {
332+
fn new(s: &str) -> Result<Self, ValidationError> {
333+
if s.as_bytes().len() != 6 {
334+
Err(ValidationError::with_message(
335+
"wrong number of things".into(),
336+
))
337+
} else {
338+
let b = s.as_bytes();
339+
Self::from_bytes([b[0], b[1], b[2], b[3], b[4], b[5]])
340+
}
341+
}
342+
343+
fn from_bytes(bytes: [u8; 6]) -> Result<Self, ValidationError> {
344+
if [bytes[0], bytes[2], bytes[4]]
345+
.iter()
346+
.all(|b| (*b as char).is_ascii_uppercase())
347+
&& [bytes[1], bytes[3], bytes[5]]
348+
.iter()
349+
.all(|b| (*b as char).is_ascii_digit())
350+
{
351+
Ok(PostalCode { chars: bytes })
352+
} else {
353+
Err(ValidationError::with_message("not a postal code".into()))
354+
}
355+
}
356+
}
357+
358+
#[allow(clippy::clippy::many_single_char_names)]
359+
impl std::fmt::Display for PostalCode {
360+
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
361+
let [a, b, c, d, e, g] = self.chars;
362+
write!(
363+
f,
364+
"{}{}{} {}{}{}",
365+
a as char, b as char, c as char, d as char, e as char, g as char
366+
)
367+
}
368+
}
369+
370+
impl Formatter<String> for CatSelectingFormatter {
371+
fn format(&self, value: &String) -> String {
372+
value.to_owned()
373+
}
374+
375+
fn value(&self, input: &str) -> Result<String, ValidationError> {
376+
Ok(input.to_owned())
377+
}
34378

35-
let mut col = Flex::column();
36-
col.add_child(label);
37-
col.add_default_spacer();
38-
col.add_child(input);
39-
Align::centered(col)
379+
fn validate_partial_input(&self, input: &str, _sel: &Selection) -> Validation {
380+
if let Some(idx) = input.find("cat") {
381+
Validation::success().change_selection(Selection::new(idx, idx + 3))
382+
} else {
383+
Validation::success()
384+
}
385+
}
40386
}

0 commit comments

Comments
 (0)