| 1 | //! Package Input: a form, an ordered set of fields with inline validation. |
| 2 | //! |
| 3 | //! ```text |
| 4 | //! Name |
| 5 | //! › nightly-report▏ |
| 6 | //! Project |
| 7 | //! — |
| 8 | //! ✕ Choose a project. |
| 9 | //! Notify me |
| 10 | //! ○ Off |
| 11 | //! ``` |
| 12 | //! |
| 13 | //! A [`FormState`] holds the fields, which one has focus and what each one's |
| 14 | //! check last said. A [`Form`] paints it. The host owns what a submit means: |
| 15 | //! [`FormState::handle_key`] only says [`FormOutcome::Submitted`] when every |
| 16 | //! field passes, so the host never sees a submit of invalid data. |
| 17 | //! |
| 18 | //! Three kinds of field ([`FormFieldKind`]): a text field (one |
| 19 | //! [`TextInputState`], or a secret), a checkbox, and a read-only row. The |
| 20 | //! last is shown but never takes focus. |
| 21 | //! |
| 22 | //! Validation is a plain function the caller supplies, |
| 23 | //! `fn(&str) -> Result<(), Cow<'static, str>>` ([`FormValidator`]); the error |
| 24 | //! is the words shown, so the kit owns none of them. When it runs: |
| 25 | //! |
| 26 | //! - **On blur** (focus leaves the field) and **on submit** (every field): the |
| 27 | //! result is shown. An error appears as `✕` and its words under the field, |
| 28 | //! never as color alone. |
| 29 | //! - **On every edit**, quietly: an error already shown clears the moment the |
| 30 | //! text becomes valid (or changes to the new reason), but a field that was |
| 31 | //! not showing an error does not start showing one while the person is |
| 32 | //! still typing. |
| 33 | //! - A submit that fails clears nothing, moves focus to the first invalid |
| 34 | //! field and answers [`FormOutcome::Blocked`] with its index. |
| 35 | //! |
| 36 | //! Keys: `Tab`, `↓` and `Shift+Tab`, `↑` move focus (wrapping, skipping |
| 37 | //! read-only rows), `Space` toggles a checkbox, `Enter` submits and `Esc` |
| 38 | //! cancels. Everything else goes to the focused text field |
| 39 | //! ([`TextInputState::handle_key`]). |
| 40 | |
| 41 | use std::borrow::Cow; |
| 42 | |
| 43 | use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; |
| 44 | use ratatui::{ |
| 45 | buffer::Buffer, |
| 46 | layout::{Position, Rect}, |
| 47 | style::Modifier, |
| 48 | }; |
| 49 | |
| 50 | use super::text_input::{FieldChrome, FieldNote, KeyEffect, paint_input_value}; |
| 51 | use crate::{Paint, Role, Theme, glyphs, text}; |
| 52 | |
| 53 | /// A caller's check of a text field: `Ok`, or the words that say what is |
| 54 | /// wrong. Plain function so a field stays `Clone` and `Debug`. |
| 55 | pub type FormValidator = fn(&str) -> Result<(), Cow<'static, str>>; |
| 56 | |
| 57 | /// What a field's check last said. |
| 58 | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
| 59 | pub enum FormStatus { |
| 60 | /// Not checked yet, or edited since and not worth saying: nothing shows. |
| 61 | #[default] |
| 62 | Untouched, |
| 63 | /// Checked and passed. Nothing shows: a field with no complaint is fine. |
| 64 | Valid, |
| 65 | /// Checked and failed: `✕` and these words show under the field. |
| 66 | Invalid(Cow<'static, str>), |
| 67 | } |
| 68 | |
| 69 | /// What kind of field, and its value. |
| 70 | #[derive(Clone, Debug)] |
| 71 | pub enum FormFieldKind { |
| 72 | Text(crate::TextInputState), |
| 73 | Check(bool), |
| 74 | ReadOnly(Cow<'static, str>), |
| 75 | } |
| 76 | |
| 77 | /// One field of a [`FormState`], built with [`FormField::text`], |
| 78 | /// [`FormField::secret`], [`FormField::check`] or [`FormField::read_only`]. |
| 79 | #[derive(Clone, Debug)] |
| 80 | pub struct FormField { |
| 81 | label: Cow<'static, str>, |
| 82 | help: Option<Cow<'static, str>>, |
| 83 | placeholder: Option<Cow<'static, str>>, |
| 84 | kind: FormFieldKind, |
| 85 | validate: Option<FormValidator>, |
| 86 | /// A checkbox that must be on, and the words when it is not. |
| 87 | required: Option<Cow<'static, str>>, |
| 88 | status: FormStatus, |
| 89 | initial_text: String, |
| 90 | initial_checked: bool, |
| 91 | } |
| 92 | |
| 93 | impl FormField { |
| 94 | fn new(label: impl Into<Cow<'static, str>>, kind: FormFieldKind) -> Self { |
| 95 | Self { |
| 96 | label: label.into(), |
| 97 | help: None, |
| 98 | placeholder: None, |
| 99 | kind, |
| 100 | validate: None, |
| 101 | required: None, |
| 102 | status: FormStatus::Untouched, |
| 103 | initial_text: String::new(), |
| 104 | initial_checked: false, |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | /// A single-line text field. |
| 109 | #[must_use] |
| 110 | pub fn text(label: impl Into<Cow<'static, str>>) -> Self { |
| 111 | Self::new(label, FormFieldKind::Text(crate::TextInputState::new())) |
| 112 | } |
| 113 | |
| 114 | /// A secret: painted as a mask, never as its text. |
| 115 | #[must_use] |
| 116 | pub fn secret(label: impl Into<Cow<'static, str>>) -> Self { |
| 117 | Self::new(label, FormFieldKind::Text(crate::TextInputState::secret())) |
| 118 | } |
| 119 | |
| 120 | /// A checkbox. |
| 121 | #[must_use] |
| 122 | pub fn check(label: impl Into<Cow<'static, str>>, checked: bool) -> Self { |
| 123 | let mut field = Self::new(label, FormFieldKind::Check(checked)); |
| 124 | field.initial_checked = checked; |
| 125 | field |
| 126 | } |
| 127 | |
| 128 | /// A row that shows a value and takes no focus. |
| 129 | #[must_use] |
| 130 | pub fn read_only( |
| 131 | label: impl Into<Cow<'static, str>>, |
| 132 | value: impl Into<Cow<'static, str>>, |
| 133 | ) -> Self { |
| 134 | Self::new(label, FormFieldKind::ReadOnly(value.into())) |
| 135 | } |
| 136 | |
| 137 | /// The starting text of a text field (what [`FormState::is_dirty`] |
| 138 | /// compares against). |
| 139 | #[must_use] |
| 140 | pub fn value(mut self, text: &str) -> Self { |
| 141 | if let FormFieldKind::Text(input) = &mut self.kind { |
| 142 | input.set_text(text); |
| 143 | self.initial_text = input.text().to_owned(); |
| 144 | } |
| 145 | self |
| 146 | } |
| 147 | |
| 148 | #[must_use] |
| 149 | pub fn placeholder(mut self, placeholder: impl Into<Cow<'static, str>>) -> Self { |
| 150 | self.placeholder = Some(placeholder.into()); |
| 151 | self |
| 152 | } |
| 153 | |
| 154 | /// Muted guidance under the field, shown while it has no error. |
| 155 | #[must_use] |
| 156 | pub fn help(mut self, help: impl Into<Cow<'static, str>>) -> Self { |
| 157 | self.help = Some(help.into()); |
| 158 | self |
| 159 | } |
| 160 | |
| 161 | /// The check a text field runs on blur and on submit. |
| 162 | #[must_use] |
| 163 | pub fn validate(mut self, validate: FormValidator) -> Self { |
| 164 | self.validate = Some(validate); |
| 165 | self |
| 166 | } |
| 167 | |
| 168 | /// A checkbox that blocks submit until it is on, with the words to show. |
| 169 | #[must_use] |
| 170 | pub fn require(mut self, message: impl Into<Cow<'static, str>>) -> Self { |
| 171 | self.required = Some(message.into()); |
| 172 | self |
| 173 | } |
| 174 | |
| 175 | #[must_use] |
| 176 | pub fn label(&self) -> &str { |
| 177 | &self.label |
| 178 | } |
| 179 | |
| 180 | #[must_use] |
| 181 | pub const fn kind(&self) -> &FormFieldKind { |
| 182 | &self.kind |
| 183 | } |
| 184 | |
| 185 | #[must_use] |
| 186 | pub const fn status(&self) -> &FormStatus { |
| 187 | &self.status |
| 188 | } |
| 189 | |
| 190 | /// The text of a text field. |
| 191 | #[must_use] |
| 192 | pub fn text_value(&self) -> Option<&str> { |
| 193 | match &self.kind { |
| 194 | FormFieldKind::Text(input) => Some(input.text()), |
| 195 | _ => None, |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | /// The state of a checkbox. |
| 200 | #[must_use] |
| 201 | pub const fn checked(&self) -> Option<bool> { |
| 202 | match self.kind { |
| 203 | FormFieldKind::Check(on) => Some(on), |
| 204 | _ => None, |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | const fn focusable(&self) -> bool { |
| 209 | !matches!(self.kind, FormFieldKind::ReadOnly(_)) |
| 210 | } |
| 211 | |
| 212 | const fn has_rule(&self) -> bool { |
| 213 | self.validate.is_some() || self.required.is_some() |
| 214 | } |
| 215 | |
| 216 | fn check_now(&self) -> Result<(), Cow<'static, str>> { |
| 217 | match &self.kind { |
| 218 | FormFieldKind::Text(input) => self.validate.map_or(Ok(()), |check| check(input.text())), |
| 219 | FormFieldKind::Check(on) => match (&self.required, on) { |
| 220 | (Some(message), false) => Err(message.clone()), |
| 221 | _ => Ok(()), |
| 222 | }, |
| 223 | FormFieldKind::ReadOnly(_) => Ok(()), |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | fn is_dirty(&self) -> bool { |
| 228 | match &self.kind { |
| 229 | FormFieldKind::Text(input) => input.text() != self.initial_text, |
| 230 | FormFieldKind::Check(on) => *on != self.initial_checked, |
| 231 | FormFieldKind::ReadOnly(_) => false, |
| 232 | } |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | /// What a key did to a [`FormState`]: the message a host reacts to. |
| 237 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 238 | pub enum FormOutcome { |
| 239 | /// The key means nothing to the form; the host may use it. |
| 240 | Ignored, |
| 241 | /// Something changed (text, a cursor, a checkbox, focus, an error |
| 242 | /// shown or cleared); repaint. |
| 243 | Changed, |
| 244 | /// Enter, and every field passes. |
| 245 | Submitted, |
| 246 | /// Enter, but field `n` is invalid. Its error is showing and it has |
| 247 | /// focus; nothing was cleared. |
| 248 | Blocked(usize), |
| 249 | /// Esc. [`FormState::is_dirty`] says whether there is anything to lose. |
| 250 | Cancelled, |
| 251 | } |
| 252 | |
| 253 | /// The fields of a form, which one has focus and what each check said. |
| 254 | #[derive(Clone, Debug, Default)] |
| 255 | pub struct FormState { |
| 256 | fields: Vec<FormField>, |
| 257 | focus: usize, |
| 258 | } |
| 259 | |
| 260 | impl FormState { |
| 261 | /// A form over `fields`, focus on the first one that takes it. |
| 262 | #[must_use] |
| 263 | pub fn new(fields: Vec<FormField>) -> Self { |
| 264 | let focus = fields.iter().position(FormField::focusable).unwrap_or(0); |
| 265 | Self { fields, focus } |
| 266 | } |
| 267 | |
| 268 | #[must_use] |
| 269 | pub fn fields(&self) -> &[FormField] { |
| 270 | &self.fields |
| 271 | } |
| 272 | |
| 273 | #[must_use] |
| 274 | pub fn len(&self) -> usize { |
| 275 | self.fields.len() |
| 276 | } |
| 277 | |
| 278 | #[must_use] |
| 279 | pub fn is_empty(&self) -> bool { |
| 280 | self.fields.is_empty() |
| 281 | } |
| 282 | |
| 283 | #[must_use] |
| 284 | pub fn field(&self, index: usize) -> Option<&FormField> { |
| 285 | self.fields.get(index) |
| 286 | } |
| 287 | |
| 288 | /// The index of the focused field. |
| 289 | #[must_use] |
| 290 | pub const fn focus(&self) -> usize { |
| 291 | self.focus |
| 292 | } |
| 293 | |
| 294 | /// The text of field `index`, if it is a text field. |
| 295 | #[must_use] |
| 296 | pub fn text(&self, index: usize) -> Option<&str> { |
| 297 | self.fields.get(index)?.text_value() |
| 298 | } |
| 299 | |
| 300 | /// The state of field `index`, if it is a checkbox. |
| 301 | #[must_use] |
| 302 | pub fn checked(&self, index: usize) -> Option<bool> { |
| 303 | self.fields.get(index)?.checked() |
| 304 | } |
| 305 | |
| 306 | #[must_use] |
| 307 | pub fn status(&self, index: usize) -> Option<&FormStatus> { |
| 308 | self.fields.get(index).map(FormField::status) |
| 309 | } |
| 310 | |
| 311 | /// Whether any field differs from what it started as. |
| 312 | #[must_use] |
| 313 | pub fn is_dirty(&self) -> bool { |
| 314 | self.fields.iter().any(FormField::is_dirty) |
| 315 | } |
| 316 | |
| 317 | /// Show `status` on field `index`: how a host reports what only it can |
| 318 | /// know (the name is taken, the project is gone). |
| 319 | pub fn set_status(&mut self, index: usize, status: FormStatus) { |
| 320 | if let Some(field) = self.fields.get_mut(index) { |
| 321 | field.status = status; |
| 322 | } |
| 323 | } |
| 324 | |
| 325 | /// Move focus to `index` (ignored for a read-only row or an index past |
| 326 | /// the end). The field it leaves is checked, as on any blur. |
| 327 | pub fn set_focus(&mut self, index: usize) { |
| 328 | if self.fields.get(index).is_some_and(FormField::focusable) && index != self.focus { |
| 329 | self.blur(); |
| 330 | self.focus = index; |
| 331 | } |
| 332 | } |
| 333 | |
| 334 | /// Run the checks of every field now; the index of the first invalid one. |
| 335 | pub fn validate_all(&mut self) -> Option<usize> { |
| 336 | let mut first = None; |
| 337 | for (i, field) in self.fields.iter_mut().enumerate() { |
| 338 | if !field.has_rule() { |
| 339 | continue; |
| 340 | } |
| 341 | field.status = match field.check_now() { |
| 342 | Ok(()) => FormStatus::Valid, |
| 343 | Err(reason) => { |
| 344 | first.get_or_insert(i); |
| 345 | FormStatus::Invalid(reason) |
| 346 | } |
| 347 | }; |
| 348 | } |
| 349 | first |
| 350 | } |
| 351 | |
| 352 | /// Whether every field passes its check right now. Shows nothing. |
| 353 | #[must_use] |
| 354 | pub fn is_valid(&self) -> bool { |
| 355 | self.fields.iter().all(|f| f.check_now().is_ok()) |
| 356 | } |
| 357 | |
| 358 | /// Submit: check every field, and either answer |
| 359 | /// [`FormOutcome::Submitted`] or put focus on the first invalid field |
| 360 | /// and answer [`FormOutcome::Blocked`]. |
| 361 | pub fn submit(&mut self) -> FormOutcome { |
| 362 | match self.validate_all() { |
| 363 | None => FormOutcome::Submitted, |
| 364 | Some(first) => { |
| 365 | self.focus = first; |
| 366 | FormOutcome::Blocked(first) |
| 367 | } |
| 368 | } |
| 369 | } |
| 370 | |
| 371 | /// Insert pasted text into the focused text field. |
| 372 | pub fn paste(&mut self, text: &str) -> FormOutcome { |
| 373 | let Some(FormFieldKind::Text(input)) = self.fields.get_mut(self.focus).map(|f| &mut f.kind) |
| 374 | else { |
| 375 | return FormOutcome::Ignored; |
| 376 | }; |
| 377 | if input.paste(text) == crate::TextInputOutcome::Ignored { |
| 378 | return FormOutcome::Ignored; |
| 379 | } |
| 380 | self.after_edit(); |
| 381 | FormOutcome::Changed |
| 382 | } |
| 383 | |
| 384 | /// Apply a key press and say what happened (see the module's key list). |
| 385 | /// Navigation and editing accept held-key repeats. Submit, cancel and |
| 386 | /// checkbox toggles require an initial press; modified navigation and |
| 387 | /// activation keys belong to the host. Releases are ignored. |
| 388 | pub fn handle_key(&mut self, key: KeyEvent) -> FormOutcome { |
| 389 | if key.kind == KeyEventKind::Release || self.fields.is_empty() { |
| 390 | return FormOutcome::Ignored; |
| 391 | } |
| 392 | let shift = key.modifiers.contains(KeyModifiers::SHIFT); |
| 393 | match key.code { |
| 394 | KeyCode::Tab | KeyCode::BackTab |
| 395 | if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => |
| 396 | { |
| 397 | self.step(key.code == KeyCode::Tab && !shift) |
| 398 | } |
| 399 | KeyCode::Down if key.modifiers.is_empty() => self.step(true), |
| 400 | KeyCode::Up if key.modifiers.is_empty() => self.step(false), |
| 401 | KeyCode::Enter if key.kind == KeyEventKind::Press && key.modifiers.is_empty() => { |
| 402 | self.submit() |
| 403 | } |
| 404 | KeyCode::Esc if key.kind == KeyEventKind::Press && key.modifiers.is_empty() => { |
| 405 | FormOutcome::Cancelled |
| 406 | } |
| 407 | _ => self.edit(key), |
| 408 | } |
| 409 | } |
| 410 | |
| 411 | fn edit(&mut self, key: KeyEvent) -> FormOutcome { |
| 412 | match self.fields.get_mut(self.focus).map(|f| &mut f.kind) { |
| 413 | Some(FormFieldKind::Text(input)) => match input.apply(key) { |
| 414 | KeyEffect::Nothing => FormOutcome::Ignored, |
| 415 | KeyEffect::Moved => FormOutcome::Changed, |
| 416 | // Enter and Esc never reach here: the form took them. |
| 417 | KeyEffect::Submit | KeyEffect::Cancel => FormOutcome::Ignored, |
| 418 | KeyEffect::Edited => { |
| 419 | self.after_edit(); |
| 420 | FormOutcome::Changed |
| 421 | } |
| 422 | }, |
| 423 | Some(FormFieldKind::Check(on)) |
| 424 | if key.code == KeyCode::Char(' ') |
| 425 | && key.kind == KeyEventKind::Press |
| 426 | && key.modifiers.is_empty() => |
| 427 | { |
| 428 | *on = !*on; |
| 429 | self.after_edit(); |
| 430 | FormOutcome::Changed |
| 431 | } |
| 432 | _ => FormOutcome::Ignored, |
| 433 | } |
| 434 | } |
| 435 | |
| 436 | /// Move focus one field, wrapping and skipping read-only rows. Ignored |
| 437 | /// when there is nowhere to go, so the host can use the key. |
| 438 | fn step(&mut self, forward: bool) -> FormOutcome { |
| 439 | let n = self.fields.len(); |
| 440 | let target = (1..n) |
| 441 | .map(|d| { |
| 442 | if forward { |
| 443 | (self.focus + d) % n |
| 444 | } else { |
| 445 | (self.focus + n - d) % n |
| 446 | } |
| 447 | }) |
| 448 | .find(|i| self.fields[*i].focusable()); |
| 449 | match target { |
| 450 | Some(to) => { |
| 451 | self.blur(); |
| 452 | self.focus = to; |
| 453 | FormOutcome::Changed |
| 454 | } |
| 455 | None => FormOutcome::Ignored, |
| 456 | } |
| 457 | } |
| 458 | |
| 459 | /// Focus is leaving the focused field: show what its check says. |
| 460 | fn blur(&mut self) { |
| 461 | if let Some(field) = self.fields.get_mut(self.focus) |
| 462 | && field.has_rule() |
| 463 | { |
| 464 | field.status = match field.check_now() { |
| 465 | Ok(()) => FormStatus::Valid, |
| 466 | Err(reason) => FormStatus::Invalid(reason), |
| 467 | }; |
| 468 | } |
| 469 | } |
| 470 | |
| 471 | /// The focused field was edited: re-run its check without announcing a |
| 472 | /// new error. See the module's note on when errors show. |
| 473 | fn after_edit(&mut self) { |
| 474 | let Some(field) = self.fields.get_mut(self.focus) else { |
| 475 | return; |
| 476 | }; |
| 477 | if !field.has_rule() { |
| 478 | return; |
| 479 | } |
| 480 | let result = field.check_now(); |
| 481 | field.status = match (std::mem::take(&mut field.status), result) { |
| 482 | (FormStatus::Invalid(_), Err(reason)) => FormStatus::Invalid(reason), |
| 483 | (FormStatus::Invalid(_) | FormStatus::Valid, Ok(())) => FormStatus::Valid, |
| 484 | _ => FormStatus::Untouched, |
| 485 | }; |
| 486 | } |
| 487 | } |
| 488 | |
| 489 | /// The words a [`Form`] shows itself. English by default. |
| 490 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 491 | pub struct FormWords { |
| 492 | /// A checkbox that is on. |
| 493 | pub on: Cow<'static, str>, |
| 494 | /// A checkbox that is off. |
| 495 | pub off: Cow<'static, str>, |
| 496 | /// Under a read-only row. |
| 497 | pub read_only: Cow<'static, str>, |
| 498 | } |
| 499 | |
| 500 | impl Default for FormWords { |
| 501 | fn default() -> Self { |
| 502 | Self { |
| 503 | on: Cow::Borrowed("On"), |
| 504 | off: Cow::Borrowed("Off"), |
| 505 | read_only: Cow::Borrowed("Read only"), |
| 506 | } |
| 507 | } |
| 508 | } |
| 509 | |
| 510 | /// Paints a [`FormState`]: each field as a label, its input and, under it, |
| 511 | /// an error (`✕` and words), help, or what a read-only row is. Fields stack |
| 512 | /// without gaps; when the form is taller than its area it scrolls to keep |
| 513 | /// the focused field in view. |
| 514 | #[derive(Clone, Debug)] |
| 515 | pub struct Form<'a> { |
| 516 | state: &'a FormState, |
| 517 | focused: bool, |
| 518 | words: FormWords, |
| 519 | } |
| 520 | |
| 521 | impl<'a> Form<'a> { |
| 522 | #[must_use] |
| 523 | pub fn new(state: &'a FormState) -> Self { |
| 524 | Self { |
| 525 | state, |
| 526 | focused: true, |
| 527 | words: FormWords::default(), |
| 528 | } |
| 529 | } |
| 530 | |
| 531 | /// Whether the form has the host's focus. An unfocused form paints no |
| 532 | /// prompt and no cursor; its state still remembers which field is next. |
| 533 | #[must_use] |
| 534 | pub const fn focused(mut self, focused: bool) -> Self { |
| 535 | self.focused = focused; |
| 536 | self |
| 537 | } |
| 538 | |
| 539 | #[must_use] |
| 540 | pub fn words(mut self, words: &FormWords) -> Self { |
| 541 | self.words = words.clone(); |
| 542 | self |
| 543 | } |
| 544 | |
| 545 | fn chrome<'f>(&'f self, index: usize, field: &'f FormField) -> FieldChrome<'f> { |
| 546 | let note = match (&field.status, &field.kind) { |
| 547 | (FormStatus::Invalid(reason), _) => Some(FieldNote::error(reason)), |
| 548 | (_, FormFieldKind::ReadOnly(_)) => Some(FieldNote::locked(&self.words.read_only)), |
| 549 | _ => field.help.as_deref().map(FieldNote::help), |
| 550 | }; |
| 551 | FieldChrome { |
| 552 | label: Some(&field.label), |
| 553 | focused: self.focused && index == self.state.focus && field.focusable(), |
| 554 | dimmed: matches!(field.kind, FormFieldKind::ReadOnly(_)), |
| 555 | note, |
| 556 | } |
| 557 | } |
| 558 | |
| 559 | /// Each visible field and the rows it gets in `area`. A form taller than |
| 560 | /// `area` starts at the first field from which the focused one still |
| 561 | /// fits. |
| 562 | fn layout(&self, area: Rect) -> Vec<(usize, Rect)> { |
| 563 | let heights: Vec<u16> = self |
| 564 | .state |
| 565 | .fields |
| 566 | .iter() |
| 567 | .enumerate() |
| 568 | .map(|(i, f)| self.chrome(i, f).height(area.width)) |
| 569 | .collect(); |
| 570 | let focus = self.state.focus.min(heights.len().saturating_sub(1)); |
| 571 | let mut first = focus; |
| 572 | let mut used = heights.get(focus).copied().unwrap_or(0); |
| 573 | while first > 0 && used.saturating_add(heights[first - 1]) <= area.height { |
| 574 | first -= 1; |
| 575 | used = used.saturating_add(heights[first]); |
| 576 | } |
| 577 | let mut y = area.y; |
| 578 | let mut placed = Vec::new(); |
| 579 | for (i, height) in heights.iter().enumerate().skip(first) { |
| 580 | let room = area.bottom().saturating_sub(y); |
| 581 | if room == 0 { |
| 582 | break; |
| 583 | } |
| 584 | let height = (*height).min(room); |
| 585 | placed.push((i, Rect { y, height, ..area })); |
| 586 | y += height; |
| 587 | } |
| 588 | placed |
| 589 | } |
| 590 | |
| 591 | /// Where the terminal's own cursor goes when the form is painted in |
| 592 | /// `area`: the cursor cell of the focused text field. |
| 593 | #[must_use] |
| 594 | pub fn cursor_position(&self, area: Rect) -> Option<Position> { |
| 595 | let (index, rect) = self |
| 596 | .layout(area) |
| 597 | .into_iter() |
| 598 | .find(|(i, _)| *i == self.state.focus)?; |
| 599 | let field = &self.state.fields[index]; |
| 600 | let FormFieldKind::Text(input) = &field.kind else { |
| 601 | return None; |
| 602 | }; |
| 603 | let placed = self.chrome(index, field).place(rect)?; |
| 604 | let col = if input.is_empty() { |
| 605 | 0 |
| 606 | } else { |
| 607 | input.window(usize::from(placed.value.width)).cursor_col? |
| 608 | }; |
| 609 | let x = placed |
| 610 | .value |
| 611 | .x |
| 612 | .saturating_add(u16::try_from(col).unwrap_or(u16::MAX)); |
| 613 | (self.focused && placed.value.width > 0 && x < placed.value.right()) |
| 614 | .then_some(Position::new(x, placed.value.y)) |
| 615 | } |
| 616 | } |
| 617 | |
| 618 | impl Paint for Form<'_> { |
| 619 | fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 620 | let area = area.intersection(buf.area); |
| 621 | for (index, rect) in self.layout(area) { |
| 622 | let field = &self.state.fields[index]; |
| 623 | let chrome = self.chrome(index, field); |
| 624 | let (focused, dimmed) = (chrome.focused, chrome.dimmed); |
| 625 | chrome.paint(rect, buf, theme, |value, buf| match &field.kind { |
| 626 | FormFieldKind::Text(input) => paint_input_value( |
| 627 | input, |
| 628 | field.placeholder.as_deref(), |
| 629 | focused, |
| 630 | dimmed, |
| 631 | value, |
| 632 | buf, |
| 633 | theme, |
| 634 | ), |
| 635 | FormFieldKind::Check(on) => self.paint_check(*on, focused, value, buf, theme), |
| 636 | FormFieldKind::ReadOnly(shown) => { |
| 637 | let safe = text::display_safe(shown); |
| 638 | let shown = text::truncate(&safe, usize::from(value.width), theme.ascii()); |
| 639 | buf.set_stringn( |
| 640 | value.x, |
| 641 | value.y, |
| 642 | shown, |
| 643 | usize::from(value.width), |
| 644 | theme.fg(Role::Muted).add_modifier(Modifier::DIM), |
| 645 | ); |
| 646 | } |
| 647 | }); |
| 648 | } |
| 649 | } |
| 650 | |
| 651 | fn height(&self, width: u16, _theme: &Theme) -> u16 { |
| 652 | self.state |
| 653 | .fields |
| 654 | .iter() |
| 655 | .enumerate() |
| 656 | .map(|(i, f)| self.chrome(i, f).height(width)) |
| 657 | .fold(0, u16::saturating_add) |
| 658 | } |
| 659 | } |
| 660 | |
| 661 | impl Form<'_> { |
| 662 | /// `● On` / `○ Off` (`[x]` / `[ ]` in ASCII, as the picker draws them): |
| 663 | /// a shape and a word, and bold when focused. |
| 664 | fn paint_check(&self, on: bool, focused: bool, area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 665 | let ascii = theme.ascii(); |
| 666 | let (mark, word, role) = match (on, ascii) { |
| 667 | (true, false) => (glyphs::CURRENT, &self.words.on, Role::Foreground), |
| 668 | (false, false) => (glyphs::AVAILABLE, &self.words.off, Role::Muted), |
| 669 | (true, true) => ("[x]", &self.words.on, Role::Foreground), |
| 670 | (false, true) => ("[ ]", &self.words.off, Role::Muted), |
| 671 | }; |
| 672 | let mut style = theme.fg(role); |
| 673 | if focused { |
| 674 | style = style.add_modifier(Modifier::BOLD); |
| 675 | } |
| 676 | let line = format!("{mark} {}", text::display_safe(word)); |
| 677 | let line = text::truncate(&line, usize::from(area.width), ascii); |
| 678 | buf.set_stringn(area.x, area.y, line, usize::from(area.width), style); |
| 679 | } |
| 680 | } |
| 681 |