|
12 | 12 | // See the License for the specific language governing permissions and |
13 | 13 | // limitations under the License. |
14 | 14 |
|
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 | +} |
17 | 32 |
|
18 | 33 | 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 | + |
23 | 44 | AppLauncher::with_window(main_window) |
24 | 45 | .use_simple_logger() |
25 | 46 | .launch(data) |
26 | 47 | .expect("launch failed"); |
27 | 48 | } |
28 | 49 |
|
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 | + } |
34 | 378 |
|
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 | + } |
40 | 386 | } |
0 commit comments