|
| 1 | +use std::process::exit; |
| 2 | +use std::thread; |
| 3 | +use std::time::Duration; |
| 4 | + |
| 5 | +use cushy::figures::units::Lp; |
| 6 | +use cushy::reactive::channel; |
| 7 | +use cushy::reactive::value::{Destination, Dynamic, MapEach, Source, Switchable, Validations}; |
| 8 | +use cushy::widget::MakeWidget; |
| 9 | +use cushy::widgets::input::{InputValue, MaskedString}; |
| 10 | +use cushy::widgets::layers::{Modal, OverlayLayer}; |
| 11 | +use cushy::widgets::{Expand, ProgressBar, Validated}; |
| 12 | +use cushy::Run; |
| 13 | +use kempt::Map; |
| 14 | + |
| 15 | +#[derive(Default, PartialEq)] |
| 16 | +enum AppState { |
| 17 | + #[default] |
| 18 | + NewUser, |
| 19 | + LoggedIn { |
| 20 | + username: String, |
| 21 | + }, |
| 22 | +} |
| 23 | + |
| 24 | +fn main() -> cushy::Result { |
| 25 | + let app_state = Dynamic::<AppState>::default(); |
| 26 | + let tooltips = OverlayLayer::default(); |
| 27 | + let modals = Modal::new(); |
| 28 | + |
| 29 | + // This example switches between a new user form and a screen once a user |
| 30 | + // has signed up successfully. The api service is simulated using a |
| 31 | + // background task. |
| 32 | + let api = channel::build().on_receive(fake_service).finish(); |
| 33 | + |
| 34 | + let ui = app_state.switcher({ |
| 35 | + let tooltips = tooltips.clone(); |
| 36 | + let modals = modals.clone(); |
| 37 | + move |current_state, app_state| match current_state { |
| 38 | + AppState::NewUser => signup_form(&tooltips, &modals, app_state, &api).make_widget(), |
| 39 | + AppState::LoggedIn { username } => logged_in(username, app_state).make_widget(), |
| 40 | + } |
| 41 | + }); |
| 42 | + |
| 43 | + ui.and(tooltips).and(modals).into_layers().run() |
| 44 | +} |
| 45 | + |
| 46 | +#[derive(Default, PartialEq)] |
| 47 | +enum NewUserState { |
| 48 | + #[default] |
| 49 | + FormEntry, |
| 50 | + SigningUp, |
| 51 | + Done, |
| 52 | +} |
| 53 | + |
| 54 | +fn signup_form( |
| 55 | + tooltips: &OverlayLayer, |
| 56 | + modals: &Modal, |
| 57 | + app_state: &Dynamic<AppState>, |
| 58 | + api: &channel::Sender<FakeApiRequest>, |
| 59 | +) -> impl MakeWidget { |
| 60 | + let form_state = Dynamic::<NewUserState>::default(); |
| 61 | + let username = Dynamic::<String>::default(); |
| 62 | + let password = Dynamic::<MaskedString>::default(); |
| 63 | + let password_confirmation = Dynamic::<MaskedString>::default(); |
| 64 | + let validations = Validations::default(); |
| 65 | + |
| 66 | + // A network request can take time, so rather than waiting on the API call |
| 67 | + // once we are ready to submit the form, we delegate the login process to a |
| 68 | + // background task using a channel. |
| 69 | + let api_errors = Dynamic::default(); |
| 70 | + let login_handler = channel::build() |
| 71 | + .on_receive({ |
| 72 | + let form_state = form_state.clone(); |
| 73 | + let app_state = app_state.clone(); |
| 74 | + let api = api.clone(); |
| 75 | + let api_errors = api_errors.clone(); |
| 76 | + move |(username, password)| { |
| 77 | + handle_login( |
| 78 | + username, |
| 79 | + password, |
| 80 | + &api, |
| 81 | + &app_state, |
| 82 | + &form_state, |
| 83 | + &api_errors, |
| 84 | + ); |
| 85 | + } |
| 86 | + }) |
| 87 | + .finish(); |
| 88 | + |
| 89 | + // When we are processing a signup request, we should display a modal with a |
| 90 | + // spinner so that the user can't edit the form or click the sign in button |
| 91 | + // again. |
| 92 | + let signup_modal = modals.new_handle(); |
| 93 | + form_state |
| 94 | + .for_each(move |state| match state { |
| 95 | + NewUserState::FormEntry { .. } | NewUserState::Done => signup_modal.dismiss(), |
| 96 | + NewUserState::SigningUp => { |
| 97 | + signup_modal.present( |
| 98 | + "SIgning Up" |
| 99 | + .and(ProgressBar::indeterminant().spinner().centered()) |
| 100 | + .into_rows() |
| 101 | + .pad() |
| 102 | + .centered() |
| 103 | + .contain(), |
| 104 | + ); |
| 105 | + } |
| 106 | + }) |
| 107 | + .persist(); |
| 108 | + |
| 109 | + // We use a helper in this file `validated_field` to combine our validation |
| 110 | + // callback and any error returned from the API for this field. |
| 111 | + let username_field = "Username" |
| 112 | + .and( |
| 113 | + validated_field(SignupField::Username, username |
| 114 | + .to_input() |
| 115 | + .placeholder("Username"), &username, &validations, &api_errors, |username| { |
| 116 | + if username.is_empty() { |
| 117 | + Err(String::from( |
| 118 | + "usernames must contain at least one character", |
| 119 | + )) |
| 120 | + } else if username.chars().any(|ch| !ch.is_ascii_alphanumeric()) { |
| 121 | + Err(String::from("usernames must contain only a-z or 0-9")) |
| 122 | + } else { |
| 123 | + Ok(()) |
| 124 | + } |
| 125 | + }) |
| 126 | + .hint("* required") |
| 127 | + .tooltip( |
| 128 | + tooltips, |
| 129 | + "Your username uniquely identifies your account. It must only contain ascii letters and digits.", |
| 130 | + ), |
| 131 | + ) |
| 132 | + .into_rows(); |
| 133 | + |
| 134 | + let password_field = "Password" |
| 135 | + .and( |
| 136 | + validated_field( |
| 137 | + SignupField::Password, |
| 138 | + password.to_input().placeholder("Password"), |
| 139 | + &password, |
| 140 | + &validations, |
| 141 | + &api_errors, |
| 142 | + |password| { |
| 143 | + if password.len() < 8 { |
| 144 | + Err(String::from("passwords must be at least 8 characters long")) |
| 145 | + } else { |
| 146 | + Ok(()) |
| 147 | + } |
| 148 | + }, |
| 149 | + ) |
| 150 | + .hint("* required, 8 characters min") |
| 151 | + .tooltip(tooltips, "Passwords are always at least 8 bytes long."), |
| 152 | + ) |
| 153 | + .into_rows(); |
| 154 | + |
| 155 | + // The password confirmation validation simply checks that the password and |
| 156 | + // confirm password match. |
| 157 | + let password_confirmation_result = |
| 158 | + (&password, &password_confirmation).map_each(|(password, confirmation)| { |
| 159 | + if password == confirmation { |
| 160 | + Ok(()) |
| 161 | + } else { |
| 162 | + Err("Passwords must match") |
| 163 | + } |
| 164 | + }); |
| 165 | + |
| 166 | + let password_confirmation_field = "Confirm Password" |
| 167 | + .and( |
| 168 | + password_confirmation |
| 169 | + .to_input() |
| 170 | + .placeholder("Password") |
| 171 | + .validation(validations.validate_result(password_confirmation_result)), |
| 172 | + ) |
| 173 | + .into_rows(); |
| 174 | + |
| 175 | + let buttons = "Cancel" |
| 176 | + .into_button() |
| 177 | + .on_click(|_| { |
| 178 | + eprintln!("Sign Up cancelled"); |
| 179 | + exit(0) |
| 180 | + }) |
| 181 | + .into_escape() |
| 182 | + .tooltip(tooltips, "This button quits the program") |
| 183 | + .and(Expand::empty_horizontally()) |
| 184 | + .and( |
| 185 | + "Sign Up" |
| 186 | + .into_button() |
| 187 | + .on_click(validations.when_valid(move |_| { |
| 188 | + // The form is valid and the sign up button was clickeed. |
| 189 | + // Send the request to our login handler background task |
| 190 | + // after setting the state to show the indeterminant |
| 191 | + // progress modal. |
| 192 | + form_state.set(NewUserState::SigningUp); |
| 193 | + login_handler |
| 194 | + .send((username.get(), password.get())) |
| 195 | + .unwrap(); |
| 196 | + })) |
| 197 | + .into_default(), |
| 198 | + ) |
| 199 | + .into_columns(); |
| 200 | + |
| 201 | + username_field |
| 202 | + .and(password_field) |
| 203 | + .and(password_confirmation_field) |
| 204 | + .and(buttons) |
| 205 | + .into_rows() |
| 206 | + .contain() |
| 207 | + .width(Lp::inches(3)..Lp::inches(6)) |
| 208 | + .pad() |
| 209 | + .scroll() |
| 210 | + .centered() |
| 211 | +} |
| 212 | + |
| 213 | +/// Returns `widget` that is validated using `validate` and `api_errors`. |
| 214 | +fn validated_field<T>( |
| 215 | + field: SignupField, |
| 216 | + widget: impl MakeWidget, |
| 217 | + value: &Dynamic<T>, |
| 218 | + validations: &Validations, |
| 219 | + api_errors: &Dynamic<Map<SignupField, String>>, |
| 220 | + mut validate: impl FnMut(&T) -> Result<(), String> + Send + 'static, |
| 221 | +) -> Validated |
| 222 | +where |
| 223 | + T: Send + 'static, |
| 224 | +{ |
| 225 | + // Create a dynamic that contains the error for this field, or None. |
| 226 | + let api_error = api_errors.map_each(move |errors| errors.get(&field).cloned()); |
| 227 | + // When the underlying value has been changed, we should invalidate the API |
| 228 | + // error since the edited value needs to be re-checked by the API. |
| 229 | + value |
| 230 | + .on_change({ |
| 231 | + let api_error = api_error.clone(); |
| 232 | + move || { |
| 233 | + api_error.set(None); |
| 234 | + } |
| 235 | + }) |
| 236 | + .persist(); |
| 237 | + |
| 238 | + // Each time either the value or the api error is updated, we produce a new |
| 239 | + // validation. |
| 240 | + let validation = (value, &api_error).map_each(move |(value, api_error)| { |
| 241 | + validate(value)?; |
| 242 | + |
| 243 | + if let Some(error) = api_error { |
| 244 | + Err(error.clone()) |
| 245 | + } else { |
| 246 | + Ok(()) |
| 247 | + } |
| 248 | + }); |
| 249 | + // Finally we return the widget with the merged validation. |
| 250 | + widget.validation(validations.validate_result(validation)) |
| 251 | +} |
| 252 | + |
| 253 | +fn logged_in(username: &str, app_state: &Dynamic<AppState>) -> impl MakeWidget { |
| 254 | + let app_state = app_state.clone(); |
| 255 | + format!("Welcome {username}!") |
| 256 | + .and("Log Out".into_button().on_click(move |_| { |
| 257 | + app_state.set(AppState::NewUser); |
| 258 | + })) |
| 259 | + .into_rows() |
| 260 | + .centered() |
| 261 | +} |
| 262 | + |
| 263 | +fn handle_login( |
| 264 | + username: String, |
| 265 | + password: MaskedString, |
| 266 | + api: &channel::Sender<FakeApiRequest>, |
| 267 | + app_state: &Dynamic<AppState>, |
| 268 | + form_state: &Dynamic<NewUserState>, |
| 269 | + api_errors: &Dynamic<Map<SignupField, String>>, |
| 270 | +) { |
| 271 | + let response = FakeApiRequestKind::SignUp { |
| 272 | + username: username.clone(), |
| 273 | + password, |
| 274 | + } |
| 275 | + .send_to(api); |
| 276 | + match response { |
| 277 | + FakeApiResponse::SignUpSuccess => { |
| 278 | + app_state.set(AppState::LoggedIn { username }); |
| 279 | + form_state.set(NewUserState::Done); |
| 280 | + } |
| 281 | + FakeApiResponse::SignUpFailure(errors) => { |
| 282 | + form_state.set(NewUserState::FormEntry); |
| 283 | + api_errors.set(errors); |
| 284 | + } |
| 285 | + } |
| 286 | +} |
| 287 | + |
| 288 | +#[derive(Debug)] |
| 289 | +enum FakeApiRequestKind { |
| 290 | + SignUp { |
| 291 | + username: String, |
| 292 | + password: MaskedString, |
| 293 | + }, |
| 294 | +} |
| 295 | + |
| 296 | +impl FakeApiRequestKind { |
| 297 | + fn send_to(self, api: &channel::Sender<FakeApiRequest>) -> FakeApiResponse { |
| 298 | + let (response_sender, response_receiver) = channel::bounded(1); |
| 299 | + api.send(FakeApiRequest { |
| 300 | + kind: self, |
| 301 | + response: response_sender, |
| 302 | + }) |
| 303 | + .expect("service running"); |
| 304 | + response_receiver.receive().expect("service to respond") |
| 305 | + } |
| 306 | +} |
| 307 | + |
| 308 | +#[derive(Debug)] |
| 309 | +struct FakeApiRequest { |
| 310 | + kind: FakeApiRequestKind, |
| 311 | + response: channel::Sender<FakeApiResponse>, |
| 312 | +} |
| 313 | + |
| 314 | +#[derive(Debug)] |
| 315 | +enum FakeApiResponse { |
| 316 | + SignUpFailure(Map<SignupField, String>), |
| 317 | + SignUpSuccess, |
| 318 | +} |
| 319 | + |
| 320 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] |
| 321 | +enum SignupField { |
| 322 | + Username, |
| 323 | + Password, |
| 324 | +} |
| 325 | + |
| 326 | +fn fake_service(request: FakeApiRequest) { |
| 327 | + let response = match request.kind { |
| 328 | + FakeApiRequestKind::SignUp { username, password } => { |
| 329 | + // Simulate this api taking a while |
| 330 | + thread::sleep(Duration::from_secs(1)); |
| 331 | + |
| 332 | + let mut errors = Map::new(); |
| 333 | + if username == "admin" { |
| 334 | + errors.insert( |
| 335 | + SignupField::Username, |
| 336 | + String::from("admin is a reserved username"), |
| 337 | + ); |
| 338 | + } |
| 339 | + if *password == "password" { |
| 340 | + errors.insert( |
| 341 | + SignupField::Password, |
| 342 | + String::from("'password' is not a strong password"), |
| 343 | + ); |
| 344 | + } |
| 345 | + |
| 346 | + if errors.is_empty() { |
| 347 | + FakeApiResponse::SignUpSuccess |
| 348 | + } else { |
| 349 | + FakeApiResponse::SignUpFailure(errors) |
| 350 | + } |
| 351 | + } |
| 352 | + }; |
| 353 | + let _ = request.response.send(response); |
| 354 | +} |
0 commit comments