| 1 | //! Package Input: a single-line text input. |
| 2 | //! |
| 3 | //! Three parts, each usable alone: |
| 4 | //! |
| 5 | //! - [`LineBuffer`]: the text model. One line, grapheme-aware (a combining |
| 6 | //! sequence, a CJK character or an emoji ZWJ sequence is one step for the |
| 7 | //! cursor and one delete), with display widths from `unicode-width`, the |
| 8 | //! editing operations a terminal field needs, and the horizontal scroll |
| 9 | //! window that keeps the cursor visible in a narrow field. |
| 10 | //! - [`TextInputState`]: a buffer plus whether it is a secret, and |
| 11 | //! [`TextInputState::handle_key`], which turns a key into a |
| 12 | //! [`TextInputOutcome`] the host reacts to. |
| 13 | //! - [`TextInput`]: paints a state: label, placeholder, a cursor cell, clip |
| 14 | //! marks where the text runs past the field, and a note underneath (an |
| 15 | //! error, a reason it is disabled, or help). |
| 16 | //! |
| 17 | //! Editing keys follow the Codewhale engine's composer and its automation |
| 18 | //! editor field (`crates/tui/src/tui/app/composer.rs`, `views/automations/ |
| 19 | //! editor.rs`, `Hmbown/CodeWhale`): a *word* is a run of non-whitespace, so |
| 20 | //! `Ctrl+W` takes `/usr/local/bin` whole, as a shell's `unix-word-rubout` |
| 21 | //! does. |
| 22 | //! |
| 23 | //! | Key | Does | |
| 24 | //! |---|---| |
| 25 | //! | characters | insert (on Windows an AltGr chord inserts; `Alt+<non-ASCII>` inserts, as macOS Option does) | |
| 26 | //! | `Backspace`, `Ctrl+H` / `Delete` | delete the grapheme before / after the cursor | |
| 27 | //! | `Ctrl+W`, `Alt+Backspace`, `Ctrl+Backspace` | delete the word before the cursor | |
| 28 | //! | `Alt+D`, `Alt+Delete`, `Ctrl+Delete` | delete the word after the cursor | |
| 29 | //! | `Ctrl+U` / `Ctrl+K` | delete to the start / end of the line | |
| 30 | //! | `←` `→`, `Ctrl+B` `Ctrl+F` | move by grapheme | |
| 31 | //! | `Alt+←` `Alt+→`, `Ctrl+←` `Ctrl+→`, `Alt+B` `Alt+F` | move by word | |
| 32 | //! | `Home` `End`, `Ctrl+A` `Ctrl+E` | start / end of the line | |
| 33 | //! | `Enter` / `Esc` | [`TextInputOutcome::Submitted`] / [`TextInputOutcome::Cancelled`] | |
| 34 | //! |
| 35 | //! A secret field ([`TextInputState::secret`]) paints one mask cell per |
| 36 | //! grapheme, never the last character typed, and its word keys act on the |
| 37 | //! whole line, so the shape of the secret (where its spaces are) does not |
| 38 | //! leak through the cursor either. |
| 39 | //! |
| 40 | //! The kit paints the cursor itself, as a reversed cell, so a field works |
| 41 | //! with no hardware cursor. A host that wants the terminal's own cursor |
| 42 | //! calls [`TextInput::cursor_position`]. |
| 43 | |
| 44 | use std::{ |
| 45 | borrow::Cow, |
| 46 | fmt, |
| 47 | sync::atomic::{AtomicUsize, Ordering}, |
| 48 | }; |
| 49 | |
| 50 | use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; |
| 51 | use ratatui::{ |
| 52 | buffer::Buffer, |
| 53 | layout::{Position, Rect}, |
| 54 | style::{Modifier, Style}, |
| 55 | }; |
| 56 | use unicode_segmentation::UnicodeSegmentation; |
| 57 | |
| 58 | use crate::{Paint, Role, Theme, glyphs, keys, text}; |
| 59 | |
| 60 | /// The prompt mark of the focused field. Its ASCII form is `>`. |
| 61 | const PROMPT: &str = "›"; |
| 62 | /// Cells the prompt and its space take before the text. |
| 63 | const PROMPT_CELLS: u16 = 2; |
| 64 | /// Shown in an empty, unfocused field that has no placeholder. |
| 65 | const EMPTY: &str = "—"; |
| 66 | /// A secret's mask, one cell per grapheme. ASCII-safe terminals get `*`: the |
| 67 | /// charter's fallback for `•` is a lone `.`, which reads as punctuation. |
| 68 | const MASK: &str = "•"; |
| 69 | const MASK_ASCII: &str = "*"; |
| 70 | /// A field narrower than this has no room for clip marks. |
| 71 | const MIN_CELLS_FOR_MARKS: usize = 4; |
| 72 | /// Longest note (an error, a reason) under a field, in rows. |
| 73 | const MAX_NOTE_ROWS: usize = 3; |
| 74 | |
| 75 | // --------------------------------------------------------------------------- |
| 76 | // LineBuffer |
| 77 | // --------------------------------------------------------------------------- |
| 78 | |
| 79 | /// One line of text and a cursor, edited by grapheme. |
| 80 | /// |
| 81 | /// The cursor is always between graphemes (a combining sequence or an emoji |
| 82 | /// ZWJ sequence is never split). Text that goes in is made safe first |
| 83 | /// ([`LineBuffer::sanitize`]), so the buffer never holds a newline, a control |
| 84 | /// character, a bidi override or an invisible zero-width cluster. |
| 85 | /// |
| 86 | /// Every editing method returns whether it changed anything, so a host (or |
| 87 | /// [`TextInputState::handle_key`]) can tell an edit from a no-op at a |
| 88 | /// boundary. |
| 89 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 90 | pub struct LineBuffer { |
| 91 | text: String, |
| 92 | /// Byte offset, always on a grapheme boundary. |
| 93 | cursor: usize, |
| 94 | /// Most graphemes the buffer will hold. |
| 95 | limit: usize, |
| 96 | } |
| 97 | |
| 98 | impl Default for LineBuffer { |
| 99 | fn default() -> Self { |
| 100 | Self::new() |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | impl LineBuffer { |
| 105 | /// The default cap on a line, in graphemes: generous for a paste, and a |
| 106 | /// guard against one that is not. |
| 107 | pub const DEFAULT_LIMIT: usize = 65_536; |
| 108 | |
| 109 | #[must_use] |
| 110 | pub const fn new() -> Self { |
| 111 | Self { |
| 112 | text: String::new(), |
| 113 | cursor: 0, |
| 114 | limit: Self::DEFAULT_LIMIT, |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | /// A buffer holding `text` (made safe, one line), cursor at the end. |
| 119 | #[must_use] |
| 120 | pub fn from_text(text: &str) -> Self { |
| 121 | let mut buffer = Self::new(); |
| 122 | buffer.insert_str(text); |
| 123 | buffer |
| 124 | } |
| 125 | |
| 126 | /// Cap the line at `graphemes`; text past the cap is cut now and refused |
| 127 | /// later. |
| 128 | #[must_use] |
| 129 | pub fn with_limit(mut self, graphemes: usize) -> Self { |
| 130 | self.limit = graphemes; |
| 131 | if self.len() > graphemes { |
| 132 | let cut = self |
| 133 | .text |
| 134 | .grapheme_indices(true) |
| 135 | .nth(graphemes) |
| 136 | .map_or(self.text.len(), |(at, _)| at); |
| 137 | self.text.truncate(cut); |
| 138 | self.cursor = self.cursor.min(cut); |
| 139 | } |
| 140 | self |
| 141 | } |
| 142 | |
| 143 | /// `text` as a line: no control characters (so no newline, tab or escape), |
| 144 | /// no bidi overrides ([`text::display_safe`]), no line or paragraph |
| 145 | /// separators, and no cluster that draws nothing (a lone zero-width space |
| 146 | /// or joiner would be invisible text in a field). Combining marks and |
| 147 | /// ZWJ sequences that belong to a visible character stay. |
| 148 | #[must_use] |
| 149 | pub fn sanitize(text: &str) -> String { |
| 150 | text::display_safe(text) |
| 151 | .graphemes(true) |
| 152 | .filter(|g| !g.contains(['\u{2028}', '\u{2029}']) && text::width(g) > 0) |
| 153 | .collect() |
| 154 | } |
| 155 | |
| 156 | #[must_use] |
| 157 | pub fn text(&self) -> &str { |
| 158 | &self.text |
| 159 | } |
| 160 | |
| 161 | #[must_use] |
| 162 | pub fn is_empty(&self) -> bool { |
| 163 | self.text.is_empty() |
| 164 | } |
| 165 | |
| 166 | /// Length in graphemes. |
| 167 | #[must_use] |
| 168 | pub fn len(&self) -> usize { |
| 169 | self.text.graphemes(true).count() |
| 170 | } |
| 171 | |
| 172 | /// Display width of the whole line, in cells. |
| 173 | #[must_use] |
| 174 | pub fn width(&self) -> usize { |
| 175 | text::width(&self.text) |
| 176 | } |
| 177 | |
| 178 | /// The cursor, as a grapheme index (`0..=len`). |
| 179 | #[must_use] |
| 180 | pub fn cursor(&self) -> usize { |
| 181 | self.text[..self.cursor].graphemes(true).count() |
| 182 | } |
| 183 | |
| 184 | /// The cursor, as a byte offset into [`LineBuffer::text`]. |
| 185 | #[must_use] |
| 186 | pub const fn cursor_byte(&self) -> usize { |
| 187 | self.cursor |
| 188 | } |
| 189 | |
| 190 | /// Move the cursor to grapheme `index` (clamped to the end). |
| 191 | pub fn set_cursor(&mut self, index: usize) { |
| 192 | self.cursor = self |
| 193 | .text |
| 194 | .grapheme_indices(true) |
| 195 | .nth(index) |
| 196 | .map_or(self.text.len(), |(at, _)| at); |
| 197 | } |
| 198 | |
| 199 | #[must_use] |
| 200 | pub fn before_cursor(&self) -> &str { |
| 201 | &self.text[..self.cursor] |
| 202 | } |
| 203 | |
| 204 | #[must_use] |
| 205 | pub fn after_cursor(&self) -> &str { |
| 206 | &self.text[self.cursor..] |
| 207 | } |
| 208 | |
| 209 | pub fn graphemes(&self) -> impl Iterator<Item = &str> { |
| 210 | self.text.graphemes(true) |
| 211 | } |
| 212 | |
| 213 | /// Display width of each grapheme, or 1 each when `masked`. |
| 214 | #[must_use] |
| 215 | pub fn widths(&self, masked: bool) -> Vec<usize> { |
| 216 | self.graphemes() |
| 217 | .map(|g| if masked { 1 } else { text::width(g) }) |
| 218 | .collect() |
| 219 | } |
| 220 | |
| 221 | /// Replace the line (made safe), cursor at the end. |
| 222 | pub fn set_text(&mut self, text: &str) { |
| 223 | self.text.clear(); |
| 224 | self.cursor = 0; |
| 225 | self.insert_str(text); |
| 226 | } |
| 227 | |
| 228 | pub fn clear(&mut self) { |
| 229 | self.text.clear(); |
| 230 | self.cursor = 0; |
| 231 | } |
| 232 | |
| 233 | // -- inserting ---------------------------------------------------------- |
| 234 | |
| 235 | pub fn insert_char(&mut self, c: char) -> bool { |
| 236 | let mut utf8 = [0; 4]; |
| 237 | self.insert_str(c.encode_utf8(&mut utf8)) |
| 238 | } |
| 239 | |
| 240 | /// Insert `text` at the cursor: a typed character, or a paste. The text |
| 241 | /// is made safe in context ([`LineBuffer::sanitize`]): a typed combining |
| 242 | /// mark or joiner may extend a visible grapheme already in the line. |
| 243 | /// A pasted newline is dropped, not turned into a space, and a paste |
| 244 | /// that would pass the limit is cut without removing existing text. |
| 245 | pub fn insert_str(&mut self, text: &str) -> bool { |
| 246 | let safe = text::display_safe(text); |
| 247 | if safe.is_empty() { |
| 248 | return false; |
| 249 | } |
| 250 | let prefix = &self.text[..self.cursor]; |
| 251 | let clean_prefix = Self::sanitize(&format!("{prefix}{safe}")); |
| 252 | // The stored prefix is already safe. Sanitize input beside it so |
| 253 | // extensions survive while invisible-only clusters do not consume |
| 254 | // the paste budget or hide later visible text. |
| 255 | let clean = &clean_prefix[prefix.len()..]; |
| 256 | // At each insertion boundary a cluster can join its neighbor. Keep |
| 257 | // two extra incoming clusters so extending a full line is possible; |
| 258 | // then enforce the cap on the combined, sanitized result. |
| 259 | let room = self.limit.saturating_sub(self.len()).saturating_add(2); |
| 260 | let mut end = clean |
| 261 | .grapheme_indices(true) |
| 262 | .nth(room) |
| 263 | .map_or(clean.len(), |(at, _)| at); |
| 264 | while end > 0 { |
| 265 | let insertion = &clean[..end]; |
| 266 | let mut candidate = self.text.clone(); |
| 267 | candidate.insert_str(self.cursor, insertion); |
| 268 | let cursor = Self::sanitize(&candidate[..self.cursor + insertion.len()]).len(); |
| 269 | let candidate = Self::sanitize(&candidate); |
| 270 | if candidate.graphemes(true).count() <= self.limit { |
| 271 | if candidate == self.text { |
| 272 | return false; |
| 273 | } |
| 274 | self.text = candidate; |
| 275 | self.cursor = cursor; |
| 276 | self.snap_cursor(); |
| 277 | return true; |
| 278 | } |
| 279 | end = insertion |
| 280 | .grapheme_indices(true) |
| 281 | .next_back() |
| 282 | .map_or(0, |(at, _)| at); |
| 283 | } |
| 284 | false |
| 285 | } |
| 286 | |
| 287 | /// Typed text can join the grapheme after the cursor (`e` before a |
| 288 | /// combining mark): keep the cursor between graphemes. |
| 289 | fn snap_cursor(&mut self) { |
| 290 | let cursor = self.cursor.min(self.text.len()); |
| 291 | self.cursor = self |
| 292 | .text |
| 293 | .grapheme_indices(true) |
| 294 | .map(|(at, _)| at) |
| 295 | .find(|at| *at >= cursor) |
| 296 | .unwrap_or(self.text.len()); |
| 297 | } |
| 298 | |
| 299 | // -- boundaries --------------------------------------------------------- |
| 300 | |
| 301 | fn prev_boundary(&self, from: usize) -> usize { |
| 302 | self.text[..from] |
| 303 | .grapheme_indices(true) |
| 304 | .next_back() |
| 305 | .map_or(0, |(at, _)| at) |
| 306 | } |
| 307 | |
| 308 | fn next_boundary(&self, from: usize) -> usize { |
| 309 | from + self.text[from..].graphemes(true).next().map_or(0, str::len) |
| 310 | } |
| 311 | |
| 312 | /// Start of the word before `from`: skip whitespace, then the word. |
| 313 | fn word_start_before(&self, from: usize) -> usize { |
| 314 | let mut at = from; |
| 315 | let mut back = self.text[..from].grapheme_indices(true).rev().peekable(); |
| 316 | while let Some((start, _)) = back.next_if(|(_, g)| is_space(g)) { |
| 317 | at = start; |
| 318 | } |
| 319 | while let Some((start, _)) = back.next_if(|(_, g)| !is_space(g)) { |
| 320 | at = start; |
| 321 | } |
| 322 | at |
| 323 | } |
| 324 | |
| 325 | /// Start of the next word after `from`: skip the rest of this word, then |
| 326 | /// the whitespace. At the last word this is the end of the line. |
| 327 | fn next_word_start(&self, from: usize) -> usize { |
| 328 | let mut at = from; |
| 329 | let mut ahead = self.text[from..].grapheme_indices(true).peekable(); |
| 330 | while let Some((start, g)) = ahead.next_if(|(_, g)| !is_space(g)) { |
| 331 | at = from + start + g.len(); |
| 332 | } |
| 333 | while let Some((start, g)) = ahead.next_if(|(_, g)| is_space(g)) { |
| 334 | at = from + start + g.len(); |
| 335 | } |
| 336 | at |
| 337 | } |
| 338 | |
| 339 | /// End of the word at or after `from`: skip whitespace, then the word. |
| 340 | fn word_end_after(&self, from: usize) -> usize { |
| 341 | let mut at = from; |
| 342 | let mut ahead = self.text[from..].grapheme_indices(true).peekable(); |
| 343 | while let Some((start, g)) = ahead.next_if(|(_, g)| is_space(g)) { |
| 344 | at = from + start + g.len(); |
| 345 | } |
| 346 | while let Some((start, g)) = ahead.next_if(|(_, g)| !is_space(g)) { |
| 347 | at = from + start + g.len(); |
| 348 | } |
| 349 | at |
| 350 | } |
| 351 | |
| 352 | fn remove(&mut self, from: usize, to: usize) -> bool { |
| 353 | if from >= to { |
| 354 | return false; |
| 355 | } |
| 356 | self.text.replace_range(from..to, ""); |
| 357 | self.cursor = from; |
| 358 | // Removing a separator can join neighboring regional indicators |
| 359 | // into one flag. The old byte boundary may now be inside a grapheme. |
| 360 | self.snap_cursor(); |
| 361 | true |
| 362 | } |
| 363 | |
| 364 | // -- deleting ----------------------------------------------------------- |
| 365 | |
| 366 | /// Delete the grapheme before the cursor (all of a combining or ZWJ |
| 367 | /// sequence). |
| 368 | pub fn backspace(&mut self) -> bool { |
| 369 | let from = self.prev_boundary(self.cursor); |
| 370 | self.remove(from, self.cursor) |
| 371 | } |
| 372 | |
| 373 | /// Delete the grapheme after the cursor. |
| 374 | pub fn delete(&mut self) -> bool { |
| 375 | let to = self.next_boundary(self.cursor); |
| 376 | self.remove(self.cursor, to) |
| 377 | } |
| 378 | |
| 379 | /// `Ctrl+W`, `Alt+Backspace`: delete back to the start of the word. |
| 380 | pub fn delete_word_back(&mut self) -> bool { |
| 381 | let from = self.word_start_before(self.cursor); |
| 382 | self.remove(from, self.cursor) |
| 383 | } |
| 384 | |
| 385 | /// `Alt+D`: delete forward to the end of the next word. |
| 386 | pub fn delete_word_forward(&mut self) -> bool { |
| 387 | let to = self.word_end_after(self.cursor); |
| 388 | self.remove(self.cursor, to) |
| 389 | } |
| 390 | |
| 391 | /// `Ctrl+U`: delete everything before the cursor. |
| 392 | pub fn kill_to_start(&mut self) -> bool { |
| 393 | self.remove(0, self.cursor) |
| 394 | } |
| 395 | |
| 396 | /// `Ctrl+K`: delete everything after the cursor. |
| 397 | pub fn kill_to_end(&mut self) -> bool { |
| 398 | let end = self.text.len(); |
| 399 | self.remove(self.cursor, end) |
| 400 | } |
| 401 | |
| 402 | // -- moving ------------------------------------------------------------- |
| 403 | |
| 404 | fn move_to(&mut self, to: usize) -> bool { |
| 405 | let moved = to != self.cursor; |
| 406 | self.cursor = to; |
| 407 | moved |
| 408 | } |
| 409 | |
| 410 | pub fn move_left(&mut self) -> bool { |
| 411 | let to = self.prev_boundary(self.cursor); |
| 412 | self.move_to(to) |
| 413 | } |
| 414 | |
| 415 | pub fn move_right(&mut self) -> bool { |
| 416 | let to = self.next_boundary(self.cursor); |
| 417 | self.move_to(to) |
| 418 | } |
| 419 | |
| 420 | /// To the start of the word before the cursor. |
| 421 | pub fn word_left(&mut self) -> bool { |
| 422 | let to = self.word_start_before(self.cursor); |
| 423 | self.move_to(to) |
| 424 | } |
| 425 | |
| 426 | /// To the start of the next word (the end of the line after the last). |
| 427 | pub fn word_right(&mut self) -> bool { |
| 428 | let to = self.next_word_start(self.cursor); |
| 429 | self.move_to(to) |
| 430 | } |
| 431 | |
| 432 | pub fn home(&mut self) -> bool { |
| 433 | self.move_to(0) |
| 434 | } |
| 435 | |
| 436 | pub fn end(&mut self) -> bool { |
| 437 | let end = self.text.len(); |
| 438 | self.move_to(end) |
| 439 | } |
| 440 | |
| 441 | // -- scrolling ---------------------------------------------------------- |
| 442 | |
| 443 | /// The slice of the line to show in `avail` cells with the cursor in |
| 444 | /// view, scrolling as little as possible from `prev_start` (the first |
| 445 | /// grapheme shown last time). `masked` counts one cell per grapheme. |
| 446 | #[must_use] |
| 447 | pub fn window(&self, avail: usize, prev_start: usize, masked: bool) -> LineWindow { |
| 448 | line_window_of(&self.widths(masked), Some(self.cursor()), prev_start, avail) |
| 449 | } |
| 450 | |
| 451 | /// The start of the line in `avail` cells, with no cursor: how an |
| 452 | /// unfocused field shows its text. |
| 453 | #[must_use] |
| 454 | pub fn head_window(&self, avail: usize, masked: bool) -> LineWindow { |
| 455 | line_window_of(&self.widths(masked), None, 0, avail) |
| 456 | } |
| 457 | } |
| 458 | |
| 459 | fn is_space(grapheme: &str) -> bool { |
| 460 | grapheme.chars().all(char::is_whitespace) |
| 461 | } |
| 462 | |
| 463 | /// Which graphemes of a line a field shows, and where the cursor lands. |
| 464 | /// |
| 465 | /// All indices are graphemes. `start..end` is what is drawn; when text is |
| 466 | /// cut off on a side a clip mark takes one cell there (none in a field too |
| 467 | /// narrow to spare it: `left_mark` and `right_mark` say which marks are |
| 468 | /// drawn). Whenever the field is wide enough for its cursor, `cursor_col` |
| 469 | /// is `Some`. |
| 470 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 471 | pub struct LineWindow { |
| 472 | pub start: usize, |
| 473 | pub end: usize, |
| 474 | /// Text before `start` is hidden. |
| 475 | pub left_clipped: bool, |
| 476 | /// Text from `end` on is hidden. |
| 477 | pub right_clipped: bool, |
| 478 | /// A clip mark is drawn in the first cell. |
| 479 | pub left_mark: bool, |
| 480 | /// A clip mark is drawn in the last cell. |
| 481 | pub right_mark: bool, |
| 482 | /// The cell, counted from the field's left edge, the cursor sits in; one |
| 483 | /// past the last grapheme when it is at the end of the line. |
| 484 | pub cursor_col: Option<usize>, |
| 485 | } |
| 486 | |
| 487 | /// The window over graphemes of the given display `widths`: the pure core of |
| 488 | /// [`LineBuffer::window`]. With a `cursor` it scrolls as little as possible |
| 489 | /// from `prev_start` to keep it visible, and fills spare room on the left |
| 490 | /// rather than leave a gap after the text; without one it starts at 0. |
| 491 | #[must_use] |
| 492 | pub fn line_window_of( |
| 493 | widths: &[usize], |
| 494 | cursor: Option<usize>, |
| 495 | prev_start: usize, |
| 496 | avail: usize, |
| 497 | ) -> LineWindow { |
| 498 | let n = widths.len(); |
| 499 | let mark = usize::from(avail >= MIN_CELLS_FOR_MARKS); |
| 500 | let span = |a: usize, b: usize| widths[a..b].iter().sum::<usize>(); |
| 501 | let cursor = cursor.map(|c| c.min(n)); |
| 502 | // At the end of the line the cursor is a cell past the last grapheme. |
| 503 | let cursor_cell = usize::from(cursor == Some(n)); |
| 504 | |
| 505 | let mut start = 0; |
| 506 | if let Some(c) = cursor { |
| 507 | // The leftmost start that still shows the cursor: walk back from it |
| 508 | // while it, what follows it up to a right mark, and the left mark fit. |
| 509 | let cursor_w = if c < n { widths[c] } else { 1 }; |
| 510 | let right = if c + 1 < n { mark } else { 0 }; |
| 511 | let mut lowest = c; |
| 512 | while lowest > 0 { |
| 513 | let left = usize::from(lowest - 1 > 0) * mark; |
| 514 | if span(lowest - 1, c) + cursor_w + right + left > avail { |
| 515 | break; |
| 516 | } |
| 517 | lowest -= 1; |
| 518 | } |
| 519 | start = prev_start.clamp(lowest, c); |
| 520 | // Spare room: if the whole tail fits, show more of the head. A cell |
| 521 | // stays reserved for a cursor at the end of the line even while the |
| 522 | // cursor is elsewhere, so the text does not shift by one every time |
| 523 | // the cursor crosses the end. |
| 524 | let tail_fits = |s: usize| span(s, n) + 1 + usize::from(s > 0) * mark <= avail; |
| 525 | if tail_fits(start) { |
| 526 | while start > 0 && tail_fits(start - 1) { |
| 527 | start -= 1; |
| 528 | } |
| 529 | } |
| 530 | } |
| 531 | |
| 532 | let left = usize::from(start > 0) * mark; |
| 533 | let budget = avail.saturating_sub(left); |
| 534 | let (end, right_clipped) = if span(start, n) + cursor_cell <= budget { |
| 535 | (n, false) |
| 536 | } else { |
| 537 | let budget = budget.saturating_sub(mark); |
| 538 | let (mut end, mut used) = (start, 0); |
| 539 | while end < n && used + widths[end] <= budget { |
| 540 | used += widths[end]; |
| 541 | end += 1; |
| 542 | } |
| 543 | (end, end < n) |
| 544 | }; |
| 545 | let cursor_col = cursor.and_then(|c| { |
| 546 | let col = left + span(start, c.min(end).max(start)); |
| 547 | let on_a_drawn_grapheme = c >= start && c < end; |
| 548 | let at_the_end = c == n && end == n && col < avail; |
| 549 | (on_a_drawn_grapheme || at_the_end).then_some(col) |
| 550 | }); |
| 551 | LineWindow { |
| 552 | start, |
| 553 | end, |
| 554 | left_clipped: start > 0, |
| 555 | right_clipped, |
| 556 | left_mark: start > 0 && mark == 1, |
| 557 | right_mark: right_clipped && mark == 1, |
| 558 | cursor_col, |
| 559 | } |
| 560 | } |
| 561 | |
| 562 | // --------------------------------------------------------------------------- |
| 563 | // TextInputState |
| 564 | // --------------------------------------------------------------------------- |
| 565 | |
| 566 | /// What a key did to a [`TextInputState`]: the message a host reacts to. |
| 567 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 568 | pub enum TextInputOutcome { |
| 569 | /// The key means nothing to a field (or did nothing at a boundary: `←` |
| 570 | /// at the start, `Backspace` on an empty line); the host may use it. |
| 571 | Ignored, |
| 572 | /// The text or the cursor changed; repaint. |
| 573 | Changed, |
| 574 | /// Enter. |
| 575 | Submitted, |
| 576 | /// Esc. |
| 577 | Cancelled, |
| 578 | } |
| 579 | |
| 580 | /// What [`TextInputState::apply`] did, a step finer than the outcome: the |
| 581 | /// form needs to tell an edit from a cursor move. |
| 582 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 583 | pub(super) enum KeyEffect { |
| 584 | Nothing, |
| 585 | Moved, |
| 586 | Edited, |
| 587 | Submit, |
| 588 | Cancel, |
| 589 | } |
| 590 | |
| 591 | /// Where the field was last scrolled to, so scrolling is sticky: moving the |
| 592 | /// cursor left inside the window does not shift the text. |
| 593 | /// |
| 594 | /// [`TextInput`] paints from `&self` and records here what it painted. The |
| 595 | /// two cells are atomics so the state stays `Send + Sync`. |
| 596 | #[derive(Debug, Default)] |
| 597 | struct InputView { |
| 598 | start: AtomicUsize, |
| 599 | /// Cells the text area had last time; 0 until the first paint. |
| 600 | avail: AtomicUsize, |
| 601 | } |
| 602 | |
| 603 | impl Clone for InputView { |
| 604 | fn clone(&self) -> Self { |
| 605 | Self { |
| 606 | start: AtomicUsize::new(self.start.load(Ordering::Relaxed)), |
| 607 | avail: AtomicUsize::new(self.avail.load(Ordering::Relaxed)), |
| 608 | } |
| 609 | } |
| 610 | } |
| 611 | |
| 612 | /// The state of one text input: its [`LineBuffer`], whether it is a secret, |
| 613 | /// and where it is scrolled to. Keys go in through |
| 614 | /// [`TextInputState::handle_key`] and [`TextInputState::paste`]. |
| 615 | /// |
| 616 | /// `Debug` never prints a secret's text. |
| 617 | #[derive(Clone, Default)] |
| 618 | pub struct TextInputState { |
| 619 | buffer: LineBuffer, |
| 620 | masked: bool, |
| 621 | view: InputView, |
| 622 | } |
| 623 | |
| 624 | impl fmt::Debug for TextInputState { |
| 625 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 626 | let mut s = f.debug_struct("TextInputState"); |
| 627 | if self.masked { |
| 628 | s.field("text", &"<hidden>"); |
| 629 | } else { |
| 630 | s.field("text", &self.buffer.text()) |
| 631 | .field("cursor", &self.buffer.cursor()); |
| 632 | } |
| 633 | s.field("masked", &self.masked).finish() |
| 634 | } |
| 635 | } |
| 636 | |
| 637 | impl TextInputState { |
| 638 | #[must_use] |
| 639 | pub fn new() -> Self { |
| 640 | Self::default() |
| 641 | } |
| 642 | |
| 643 | /// A field holding `text`, cursor at the end. |
| 644 | #[must_use] |
| 645 | pub fn with_text(text: &str) -> Self { |
| 646 | Self { |
| 647 | buffer: LineBuffer::from_text(text), |
| 648 | ..Self::default() |
| 649 | } |
| 650 | } |
| 651 | |
| 652 | /// A secret: painted as a mask, edited as one block. |
| 653 | #[must_use] |
| 654 | pub fn secret() -> Self { |
| 655 | Self { |
| 656 | masked: true, |
| 657 | ..Self::default() |
| 658 | } |
| 659 | } |
| 660 | |
| 661 | /// Cap the line at `graphemes` ([`LineBuffer::with_limit`]). |
| 662 | #[must_use] |
| 663 | pub fn with_limit(mut self, graphemes: usize) -> Self { |
| 664 | self.buffer = self.buffer.with_limit(graphemes); |
| 665 | self |
| 666 | } |
| 667 | |
| 668 | #[must_use] |
| 669 | pub const fn is_masked(&self) -> bool { |
| 670 | self.masked |
| 671 | } |
| 672 | |
| 673 | #[must_use] |
| 674 | pub fn text(&self) -> &str { |
| 675 | self.buffer.text() |
| 676 | } |
| 677 | |
| 678 | #[must_use] |
| 679 | pub fn is_empty(&self) -> bool { |
| 680 | self.buffer.is_empty() |
| 681 | } |
| 682 | |
| 683 | /// The cursor, as a grapheme index. |
| 684 | #[must_use] |
| 685 | pub fn cursor(&self) -> usize { |
| 686 | self.buffer.cursor() |
| 687 | } |
| 688 | |
| 689 | #[must_use] |
| 690 | pub const fn buffer(&self) -> &LineBuffer { |
| 691 | &self.buffer |
| 692 | } |
| 693 | |
| 694 | /// Edit the buffer directly (a host's own shortcut, a completion). The |
| 695 | /// scroll catches up on the next key or paint. |
| 696 | pub const fn buffer_mut(&mut self) -> &mut LineBuffer { |
| 697 | &mut self.buffer |
| 698 | } |
| 699 | |
| 700 | pub fn set_text(&mut self, text: &str) { |
| 701 | self.buffer.set_text(text); |
| 702 | self.follow(); |
| 703 | } |
| 704 | |
| 705 | pub fn clear(&mut self) { |
| 706 | self.buffer.clear(); |
| 707 | self.view.start.store(0, Ordering::Relaxed); |
| 708 | } |
| 709 | |
| 710 | /// Insert pasted text ([`LineBuffer::insert_str`]): one line, safe. |
| 711 | pub fn paste(&mut self, text: &str) -> TextInputOutcome { |
| 712 | if self.buffer.insert_str(text) { |
| 713 | self.follow(); |
| 714 | TextInputOutcome::Changed |
| 715 | } else { |
| 716 | TextInputOutcome::Ignored |
| 717 | } |
| 718 | } |
| 719 | |
| 720 | /// The window a field `avail` cells wide shows now. |
| 721 | #[must_use] |
| 722 | pub fn window(&self, avail: usize) -> LineWindow { |
| 723 | self.buffer |
| 724 | .window(avail, self.view.start.load(Ordering::Relaxed), self.masked) |
| 725 | } |
| 726 | |
| 727 | /// Tell the state how many cells the text area has, and scroll the |
| 728 | /// cursor into it. [`TextInput`] does this each time it paints; a host |
| 729 | /// calls it only to scroll before the first paint, or after a resize. |
| 730 | pub fn scroll_into_view(&self, avail: usize) { |
| 731 | self.view.avail.store(avail, Ordering::Relaxed); |
| 732 | self.follow(); |
| 733 | } |
| 734 | |
| 735 | fn follow(&self) { |
| 736 | let avail = self.view.avail.load(Ordering::Relaxed); |
| 737 | if avail > 0 { |
| 738 | self.view |
| 739 | .start |
| 740 | .store(self.window(avail).start, Ordering::Relaxed); |
| 741 | } |
| 742 | } |
| 743 | |
| 744 | /// Record what a paint showed, so the next key scrolls from there. |
| 745 | fn remember(&self, start: usize, avail: usize) { |
| 746 | self.view.start.store(start, Ordering::Relaxed); |
| 747 | self.view.avail.store(avail, Ordering::Relaxed); |
| 748 | } |
| 749 | |
| 750 | /// Apply a key press and say what happened (see the module's key table). |
| 751 | /// Editing and movement accept held-key repeats. Enter/Esc require an |
| 752 | /// unmodified initial press. Releases and every key a field has no use |
| 753 | /// for (Tab, the arrows up and down, `Ctrl+C`, `Cmd` chords) are |
| 754 | /// [`TextInputOutcome::Ignored`]. |
| 755 | pub fn handle_key(&mut self, key: KeyEvent) -> TextInputOutcome { |
| 756 | match self.apply(key) { |
| 757 | KeyEffect::Nothing => TextInputOutcome::Ignored, |
| 758 | KeyEffect::Moved | KeyEffect::Edited => TextInputOutcome::Changed, |
| 759 | KeyEffect::Submit => TextInputOutcome::Submitted, |
| 760 | KeyEffect::Cancel => TextInputOutcome::Cancelled, |
| 761 | } |
| 762 | } |
| 763 | |
| 764 | pub(super) fn apply(&mut self, key: KeyEvent) -> KeyEffect { |
| 765 | if key.kind == KeyEventKind::Release { |
| 766 | return KeyEffect::Nothing; |
| 767 | } |
| 768 | let mods = key.modifiers; |
| 769 | // `Cmd` chords belong to the host. |
| 770 | if mods.contains(KeyModifiers::SUPER) { |
| 771 | return KeyEffect::Nothing; |
| 772 | } |
| 773 | let (ctrl, alt) = ( |
| 774 | mods.contains(KeyModifiers::CONTROL), |
| 775 | mods.contains(KeyModifiers::ALT), |
| 776 | ); |
| 777 | let word = ctrl || alt; |
| 778 | let effect = match key.code { |
| 779 | KeyCode::Enter | KeyCode::Esc => { |
| 780 | return if key.kind == KeyEventKind::Press && mods.is_empty() { |
| 781 | if key.code == KeyCode::Enter { |
| 782 | KeyEffect::Submit |
| 783 | } else { |
| 784 | KeyEffect::Cancel |
| 785 | } |
| 786 | } else { |
| 787 | KeyEffect::Nothing |
| 788 | }; |
| 789 | } |
| 790 | KeyCode::Char(c) => { |
| 791 | if keys::is_ctrl_h_backspace(&key) { |
| 792 | self.edit(LineBuffer::backspace) |
| 793 | } else if ctrl && !alt { |
| 794 | match c.to_ascii_lowercase() { |
| 795 | 'w' => self.edit_word_back(), |
| 796 | 'u' => self.edit(LineBuffer::kill_to_start), |
| 797 | 'k' => self.edit(LineBuffer::kill_to_end), |
| 798 | 'a' => self.go(LineBuffer::home), |
| 799 | 'e' => self.go(LineBuffer::end), |
| 800 | 'b' => self.go(LineBuffer::move_left), |
| 801 | 'f' => self.go(LineBuffer::move_right), |
| 802 | _ => KeyEffect::Nothing, |
| 803 | } |
| 804 | } else if alt && !ctrl { |
| 805 | match c { |
| 806 | _ if !c.is_ascii() => self.edit_char(c), |
| 807 | 'b' | 'B' => self.go_word_left(), |
| 808 | 'f' | 'F' => self.go_word_right(), |
| 809 | 'd' | 'D' => self.edit_word_forward(), |
| 810 | _ => KeyEffect::Nothing, |
| 811 | } |
| 812 | } else if ctrl && alt { |
| 813 | // Windows reports AltGr as Ctrl+Alt. |
| 814 | if cfg!(windows) { |
| 815 | self.edit_char(c) |
| 816 | } else { |
| 817 | KeyEffect::Nothing |
| 818 | } |
| 819 | } else { |
| 820 | self.edit_char(c) |
| 821 | } |
| 822 | } |
| 823 | KeyCode::Backspace if word => self.edit_word_back(), |
| 824 | KeyCode::Backspace => self.edit(LineBuffer::backspace), |
| 825 | KeyCode::Delete if word => self.edit_word_forward(), |
| 826 | KeyCode::Delete => self.edit(LineBuffer::delete), |
| 827 | KeyCode::Left if word => self.go_word_left(), |
| 828 | KeyCode::Left => self.go(LineBuffer::move_left), |
| 829 | KeyCode::Right if word => self.go_word_right(), |
| 830 | KeyCode::Right => self.go(LineBuffer::move_right), |
| 831 | KeyCode::Home => self.go(LineBuffer::home), |
| 832 | KeyCode::End => self.go(LineBuffer::end), |
| 833 | _ => KeyEffect::Nothing, |
| 834 | }; |
| 835 | if effect != KeyEffect::Nothing { |
| 836 | self.follow(); |
| 837 | } |
| 838 | effect |
| 839 | } |
| 840 | |
| 841 | fn edit(&mut self, op: fn(&mut LineBuffer) -> bool) -> KeyEffect { |
| 842 | if op(&mut self.buffer) { |
| 843 | KeyEffect::Edited |
| 844 | } else { |
| 845 | KeyEffect::Nothing |
| 846 | } |
| 847 | } |
| 848 | |
| 849 | fn go(&mut self, op: fn(&mut LineBuffer) -> bool) -> KeyEffect { |
| 850 | if op(&mut self.buffer) { |
| 851 | KeyEffect::Moved |
| 852 | } else { |
| 853 | KeyEffect::Nothing |
| 854 | } |
| 855 | } |
| 856 | |
| 857 | fn edit_char(&mut self, c: char) -> KeyEffect { |
| 858 | if self.buffer.insert_char(c) { |
| 859 | KeyEffect::Edited |
| 860 | } else { |
| 861 | KeyEffect::Nothing |
| 862 | } |
| 863 | } |
| 864 | |
| 865 | // A secret has no visible words: its word keys act on the whole line. |
| 866 | fn edit_word_back(&mut self) -> KeyEffect { |
| 867 | self.edit(if self.masked { |
| 868 | LineBuffer::kill_to_start |
| 869 | } else { |
| 870 | LineBuffer::delete_word_back |
| 871 | }) |
| 872 | } |
| 873 | |
| 874 | fn edit_word_forward(&mut self) -> KeyEffect { |
| 875 | self.edit(if self.masked { |
| 876 | LineBuffer::kill_to_end |
| 877 | } else { |
| 878 | LineBuffer::delete_word_forward |
| 879 | }) |
| 880 | } |
| 881 | |
| 882 | fn go_word_left(&mut self) -> KeyEffect { |
| 883 | self.go(if self.masked { |
| 884 | LineBuffer::home |
| 885 | } else { |
| 886 | LineBuffer::word_left |
| 887 | }) |
| 888 | } |
| 889 | |
| 890 | fn go_word_right(&mut self) -> KeyEffect { |
| 891 | self.go(if self.masked { |
| 892 | LineBuffer::end |
| 893 | } else { |
| 894 | LineBuffer::word_right |
| 895 | }) |
| 896 | } |
| 897 | } |
| 898 | |
| 899 | // --------------------------------------------------------------------------- |
| 900 | // Painting: shared by TextInput and Form |
| 901 | // --------------------------------------------------------------------------- |
| 902 | |
| 903 | /// The words a [`TextInput`] shows itself. English by default; the host |
| 904 | /// passes its own. |
| 905 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 906 | pub struct TextInputWords { |
| 907 | /// Shown under a disabled field that gave no reason of its own. |
| 908 | pub disabled: Cow<'static, str>, |
| 909 | } |
| 910 | |
| 911 | impl Default for TextInputWords { |
| 912 | fn default() -> Self { |
| 913 | Self { |
| 914 | disabled: Cow::Borrowed("Disabled"), |
| 915 | } |
| 916 | } |
| 917 | } |
| 918 | |
| 919 | /// A line or more of words under a field, with the mark that gives them |
| 920 | /// their meaning. |
| 921 | pub(super) struct FieldNote { |
| 922 | pub mark: Option<(&'static str, Role)>, |
| 923 | pub text: String, |
| 924 | pub ink: Role, |
| 925 | } |
| 926 | |
| 927 | impl FieldNote { |
| 928 | /// `✕ message`: the mark carries the state, the words stay body ink. |
| 929 | pub fn error(message: &str) -> Self { |
| 930 | Self { |
| 931 | mark: Some((glyphs::FAILED, Role::Danger)), |
| 932 | text: message.to_owned(), |
| 933 | ink: Role::Foreground, |
| 934 | } |
| 935 | } |
| 936 | |
| 937 | /// `· reason`: a field that cannot be edited says why. |
| 938 | pub fn locked(reason: &str) -> Self { |
| 939 | Self { |
| 940 | mark: Some((glyphs::NEUTRAL, Role::Muted)), |
| 941 | text: reason.to_owned(), |
| 942 | ink: Role::Muted, |
| 943 | } |
| 944 | } |
| 945 | |
| 946 | /// Plain muted guidance, aligned with the text. |
| 947 | pub fn help(text: &str) -> Self { |
| 948 | Self { |
| 949 | mark: None, |
| 950 | text: text.to_owned(), |
| 951 | ink: Role::Muted, |
| 952 | } |
| 953 | } |
| 954 | } |
| 955 | |
| 956 | /// Break `text` into rows of at most `width` cells at spaces (a longer word |
| 957 | /// is cut between graphemes). Past `max_rows` the rest is joined onto the |
| 958 | /// last row, which the painter then cuts with `…`. |
| 959 | pub(super) fn wrap_field_note(text: &str, width: usize, max_rows: usize) -> Vec<String> { |
| 960 | let width = width.max(1); |
| 961 | let mut rows: Vec<String> = Vec::new(); |
| 962 | let (mut row, mut used) = (String::new(), 0); |
| 963 | for word in text.split_whitespace() { |
| 964 | let w = text::width(word); |
| 965 | if w > width { |
| 966 | if !row.is_empty() { |
| 967 | rows.push(std::mem::take(&mut row)); |
| 968 | } |
| 969 | used = 0; |
| 970 | for g in word.graphemes(true) { |
| 971 | let gw = text::width(g); |
| 972 | if used + gw > width && !row.is_empty() { |
| 973 | rows.push(std::mem::take(&mut row)); |
| 974 | used = 0; |
| 975 | } |
| 976 | row.push_str(g); |
| 977 | used += gw; |
| 978 | } |
| 979 | } else if row.is_empty() { |
| 980 | row.push_str(word); |
| 981 | used = w; |
| 982 | } else if used + 1 + w <= width { |
| 983 | row.push(' '); |
| 984 | row.push_str(word); |
| 985 | used += 1 + w; |
| 986 | } else { |
| 987 | rows.push(std::mem::replace(&mut row, word.to_owned())); |
| 988 | used = w; |
| 989 | } |
| 990 | } |
| 991 | if !row.is_empty() { |
| 992 | rows.push(row); |
| 993 | } |
| 994 | if rows.len() > max_rows && max_rows > 0 { |
| 995 | let tail = rows.split_off(max_rows - 1).join(" "); |
| 996 | rows.push(tail); |
| 997 | } |
| 998 | rows |
| 999 | } |
| 1000 | |
| 1001 | /// Everything around a field's text: label above, prompt and well, note |
| 1002 | /// below. The body is painted by the caller into the text area. |
| 1003 | pub(super) struct FieldChrome<'a> { |
| 1004 | pub label: Option<&'a str>, |
| 1005 | pub focused: bool, |
| 1006 | /// Disabled or read-only: no well, quiet ink. |
| 1007 | pub dimmed: bool, |
| 1008 | pub note: Option<FieldNote>, |
| 1009 | } |
| 1010 | |
| 1011 | /// Where each part of a [`FieldChrome`] landed. |
| 1012 | pub(super) struct FieldPlaced { |
| 1013 | pub label: Option<Rect>, |
| 1014 | pub input: Rect, |
| 1015 | /// The text area of the input row, after the prompt. |
| 1016 | pub value: Rect, |
| 1017 | pub notes: Vec<(Rect, String)>, |
| 1018 | } |
| 1019 | |
| 1020 | impl FieldChrome<'_> { |
| 1021 | fn note_rows(&self, width: u16) -> Vec<String> { |
| 1022 | self.note.as_ref().map_or_else(Vec::new, |note| { |
| 1023 | let width = usize::from(width.saturating_sub(PROMPT_CELLS)); |
| 1024 | wrap_field_note(&text::display_safe(¬e.text), width, MAX_NOTE_ROWS) |
| 1025 | }) |
| 1026 | } |
| 1027 | |
| 1028 | /// Rows this field wants at `width`: label, input, note. |
| 1029 | pub fn height(&self, width: u16) -> u16 { |
| 1030 | let rows = usize::from(self.label.is_some()) + 1 + self.note_rows(width).len(); |
| 1031 | u16::try_from(rows).unwrap_or(u16::MAX) |
| 1032 | } |
| 1033 | |
| 1034 | /// Lay the parts out in `area`. Too short: the note shrinks to one row |
| 1035 | /// (the first row of an error is the one that matters), then the label |
| 1036 | /// goes, then the note. |
| 1037 | pub fn place(&self, area: Rect) -> Option<FieldPlaced> { |
| 1038 | if area.is_empty() { |
| 1039 | return None; |
| 1040 | } |
| 1041 | let notes = self.note_rows(area.width); |
| 1042 | let rows = usize::from(area.height); |
| 1043 | let mut show_label = self.label.is_some(); |
| 1044 | let mut shown_notes = notes.len(); |
| 1045 | let need = |label: bool, notes: usize| usize::from(label) + 1 + notes; |
| 1046 | while need(show_label, shown_notes) > rows && shown_notes > 1 { |
| 1047 | shown_notes -= 1; |
| 1048 | } |
| 1049 | if need(show_label, shown_notes) > rows { |
| 1050 | show_label = false; |
| 1051 | } |
| 1052 | shown_notes = shown_notes.min(rows - 1); |
| 1053 | |
| 1054 | let mut y = area.y; |
| 1055 | let row = |y: u16| Rect { |
| 1056 | y, |
| 1057 | height: 1, |
| 1058 | ..area |
| 1059 | }; |
| 1060 | let label = show_label.then(|| { |
| 1061 | y += 1; |
| 1062 | row(y - 1) |
| 1063 | }); |
| 1064 | let input = row(y); |
| 1065 | y += 1; |
| 1066 | let mut placed_notes = Vec::new(); |
| 1067 | for text in notes.into_iter().take(shown_notes) { |
| 1068 | placed_notes.push((row(y), text)); |
| 1069 | y += 1; |
| 1070 | } |
| 1071 | let value = Rect { |
| 1072 | x: input.x.saturating_add(PROMPT_CELLS.min(input.width)), |
| 1073 | width: input.width.saturating_sub(PROMPT_CELLS), |
| 1074 | ..input |
| 1075 | }; |
| 1076 | Some(FieldPlaced { |
| 1077 | label, |
| 1078 | input, |
| 1079 | value, |
| 1080 | notes: placed_notes, |
| 1081 | }) |
| 1082 | } |
| 1083 | |
| 1084 | /// Paint everything but the text, then hand the text area to `body`. |
| 1085 | pub fn paint( |
| 1086 | &self, |
| 1087 | area: Rect, |
| 1088 | buf: &mut Buffer, |
| 1089 | theme: &Theme, |
| 1090 | body: impl FnOnce(Rect, &mut Buffer), |
| 1091 | ) { |
| 1092 | let area = area.intersection(buf.area); |
| 1093 | let Some(placed) = self.place(area) else { |
| 1094 | return; |
| 1095 | }; |
| 1096 | let ascii = theme.ascii(); |
| 1097 | |
| 1098 | if let (Some(rect), Some(label)) = (placed.label, self.label) { |
| 1099 | let label = text::display_safe(label); |
| 1100 | let shown = text::truncate(&label, usize::from(rect.width), ascii); |
| 1101 | let style = if self.dimmed { |
| 1102 | theme.fg(Role::Muted).add_modifier(Modifier::DIM) |
| 1103 | } else if self.focused { |
| 1104 | theme.fg(Role::Foreground).add_modifier(Modifier::BOLD) |
| 1105 | } else { |
| 1106 | theme.fg(Role::Foreground) |
| 1107 | }; |
| 1108 | buf.set_stringn(rect.x, rect.y, shown, usize::from(rect.width), style); |
| 1109 | } |
| 1110 | |
| 1111 | // The well: where grounds paint it lifts the field; where they do |
| 1112 | // not, the prompt and the cursor carry focus. |
| 1113 | if !self.dimmed { |
| 1114 | let ground = if self.focused { |
| 1115 | Role::Selected |
| 1116 | } else { |
| 1117 | Role::Surface |
| 1118 | }; |
| 1119 | buf.set_style(placed.input, theme.bg(ground)); |
| 1120 | } |
| 1121 | if self.focused { |
| 1122 | buf.set_stringn( |
| 1123 | placed.input.x, |
| 1124 | placed.input.y, |
| 1125 | glyphs::pick(PROMPT, ascii), |
| 1126 | usize::from(placed.input.width), |
| 1127 | theme.fg(Role::Primary), |
| 1128 | ); |
| 1129 | } |
| 1130 | if placed.value.width > 0 { |
| 1131 | body(placed.value, buf); |
| 1132 | } |
| 1133 | |
| 1134 | if let Some(note) = &self.note { |
| 1135 | let width = usize::from(area.width.saturating_sub(PROMPT_CELLS)); |
| 1136 | for (i, (rect, row)) in placed.notes.iter().enumerate() { |
| 1137 | if i == 0 |
| 1138 | && let Some((mark, role)) = note.mark |
| 1139 | { |
| 1140 | buf.set_stringn( |
| 1141 | rect.x, |
| 1142 | rect.y, |
| 1143 | glyphs::pick(mark, ascii), |
| 1144 | usize::from(rect.width), |
| 1145 | theme.fg(role), |
| 1146 | ); |
| 1147 | } |
| 1148 | let shown = text::truncate(row, width, ascii); |
| 1149 | buf.set_stringn( |
| 1150 | rect.x.saturating_add(PROMPT_CELLS), |
| 1151 | rect.y, |
| 1152 | shown, |
| 1153 | width, |
| 1154 | theme.fg(note.ink), |
| 1155 | ); |
| 1156 | } |
| 1157 | } |
| 1158 | } |
| 1159 | } |
| 1160 | |
| 1161 | /// The clip mark at the edge of a field's text. |
| 1162 | fn clip_mark(left: bool, ascii: bool) -> &'static str { |
| 1163 | match (ascii, left) { |
| 1164 | (false, _) => glyphs::ELLIPSIS, |
| 1165 | // A lone `.` at the edge of editable text reads as typed text. |
| 1166 | (true, true) => "<", |
| 1167 | (true, false) => ">", |
| 1168 | } |
| 1169 | } |
| 1170 | |
| 1171 | /// Paint the text of a field into `area` (one row): the text or the mask, or |
| 1172 | /// the placeholder when empty; clip marks; the cursor cell when focused. |
| 1173 | pub(super) fn paint_input_value( |
| 1174 | state: &TextInputState, |
| 1175 | placeholder: Option<&str>, |
| 1176 | focused: bool, |
| 1177 | dimmed: bool, |
| 1178 | area: Rect, |
| 1179 | buf: &mut Buffer, |
| 1180 | theme: &Theme, |
| 1181 | ) { |
| 1182 | if area.is_empty() { |
| 1183 | return; |
| 1184 | } |
| 1185 | let ascii = theme.ascii(); |
| 1186 | let avail = usize::from(area.width); |
| 1187 | let ink = if dimmed { |
| 1188 | theme.fg(Role::Muted).add_modifier(Modifier::DIM) |
| 1189 | } else { |
| 1190 | theme.fg(Role::Foreground) |
| 1191 | }; |
| 1192 | let cursor_style = Style::default().add_modifier(Modifier::REVERSED); |
| 1193 | let at = |col: usize| { |
| 1194 | area.x |
| 1195 | .saturating_add(u16::try_from(col).unwrap_or(u16::MAX)) |
| 1196 | }; |
| 1197 | |
| 1198 | if state.is_empty() { |
| 1199 | let hint = theme.fg(if dimmed { Role::Dim } else { Role::Hint }); |
| 1200 | let placeholder = text::display_safe(placeholder.unwrap_or_default()); |
| 1201 | // An empty field with nothing to say still shows where it is: a dash |
| 1202 | // (the design's `—`), so it does not read as a gap in the form. |
| 1203 | let placeholder = if placeholder.is_empty() && !focused { |
| 1204 | Cow::Borrowed(glyphs::pick(EMPTY, ascii)) |
| 1205 | } else { |
| 1206 | placeholder |
| 1207 | }; |
| 1208 | let shown = text::truncate(&placeholder, avail, ascii); |
| 1209 | buf.set_stringn(area.x, area.y, &shown, avail, hint); |
| 1210 | if focused { |
| 1211 | // The cursor sits on the placeholder's first cell. |
| 1212 | let first = shown |
| 1213 | .graphemes(true) |
| 1214 | .next() |
| 1215 | .map_or(1, |g| text::width(g).max(1)); |
| 1216 | let cell = Rect { |
| 1217 | width: u16::try_from(first.min(avail)).unwrap_or(1), |
| 1218 | height: 1, |
| 1219 | ..area |
| 1220 | }; |
| 1221 | buf.set_style(cell, cursor_style); |
| 1222 | } |
| 1223 | return; |
| 1224 | } |
| 1225 | |
| 1226 | let masked = state.is_masked(); |
| 1227 | let window = if focused { |
| 1228 | let window = state.window(avail); |
| 1229 | state.remember(window.start, avail); |
| 1230 | window |
| 1231 | } else { |
| 1232 | state.buffer().head_window(avail, masked) |
| 1233 | }; |
| 1234 | let mask = if ascii { MASK_ASCII } else { MASK }; |
| 1235 | let mut col = usize::from(window.left_mark); |
| 1236 | for g in state |
| 1237 | .buffer() |
| 1238 | .graphemes() |
| 1239 | .skip(window.start) |
| 1240 | .take(window.end - window.start) |
| 1241 | { |
| 1242 | let (shown, w) = if masked { |
| 1243 | (mask, 1) |
| 1244 | } else { |
| 1245 | (g, text::width(g)) |
| 1246 | }; |
| 1247 | buf.set_stringn(at(col), area.y, shown, usize::from(area.width), ink); |
| 1248 | col += w; |
| 1249 | } |
| 1250 | let mark_style = theme.fg(Role::Muted); |
| 1251 | if window.left_mark { |
| 1252 | buf.set_stringn(area.x, area.y, clip_mark(true, ascii), 1, mark_style); |
| 1253 | } |
| 1254 | if window.right_mark { |
| 1255 | buf.set_stringn( |
| 1256 | at(avail - 1), |
| 1257 | area.y, |
| 1258 | clip_mark(false, ascii), |
| 1259 | 1, |
| 1260 | mark_style, |
| 1261 | ); |
| 1262 | } |
| 1263 | if focused && let Some(cursor_col) = window.cursor_col { |
| 1264 | let on = state.buffer().cursor(); |
| 1265 | let w = if masked || on >= state.buffer().len() { |
| 1266 | 1 |
| 1267 | } else { |
| 1268 | text::width(state.buffer().graphemes().nth(on).unwrap_or(" ")).max(1) |
| 1269 | }; |
| 1270 | let cell = Rect { |
| 1271 | x: at(cursor_col), |
| 1272 | width: u16::try_from(w.min(avail - cursor_col)).unwrap_or(1), |
| 1273 | height: 1, |
| 1274 | ..area |
| 1275 | }; |
| 1276 | buf.set_style(cell, cursor_style); |
| 1277 | } |
| 1278 | } |
| 1279 | |
| 1280 | // --------------------------------------------------------------------------- |
| 1281 | // TextInput |
| 1282 | // --------------------------------------------------------------------------- |
| 1283 | |
| 1284 | /// Paints a [`TextInputState`]: |
| 1285 | /// |
| 1286 | /// ```text |
| 1287 | /// Port |
| 1288 | /// › 8080▏ |
| 1289 | /// ✕ Enter a port from 1 to 65535. |
| 1290 | /// ``` |
| 1291 | /// |
| 1292 | /// Every state has a mark and a word: focus is the `›` prompt and the cursor |
| 1293 | /// cell; an invalid field is `✕` and the reason; a disabled one is `·` and |
| 1294 | /// its reason (or [`TextInputWords::disabled`]); a secret is its mask. |
| 1295 | /// Where grounds paint, the field sits in a well (`Surface`, `Selected` when |
| 1296 | /// focused). |
| 1297 | #[derive(Clone, Debug)] |
| 1298 | pub struct TextInput<'a> { |
| 1299 | state: &'a TextInputState, |
| 1300 | label: Option<Cow<'static, str>>, |
| 1301 | placeholder: Option<Cow<'static, str>>, |
| 1302 | focused: bool, |
| 1303 | disabled: Option<Cow<'static, str>>, |
| 1304 | error: Option<Cow<'static, str>>, |
| 1305 | help: Option<Cow<'static, str>>, |
| 1306 | words: TextInputWords, |
| 1307 | } |
| 1308 | |
| 1309 | impl<'a> TextInput<'a> { |
| 1310 | #[must_use] |
| 1311 | pub fn new(state: &'a TextInputState) -> Self { |
| 1312 | Self { |
| 1313 | state, |
| 1314 | label: None, |
| 1315 | placeholder: None, |
| 1316 | focused: false, |
| 1317 | disabled: None, |
| 1318 | error: None, |
| 1319 | help: None, |
| 1320 | words: TextInputWords::default(), |
| 1321 | } |
| 1322 | } |
| 1323 | |
| 1324 | /// A line above the field naming it. |
| 1325 | #[must_use] |
| 1326 | pub fn label(mut self, label: impl Into<Cow<'static, str>>) -> Self { |
| 1327 | self.label = Some(label.into()); |
| 1328 | self |
| 1329 | } |
| 1330 | |
| 1331 | /// Hint ink shown while the field is empty. |
| 1332 | #[must_use] |
| 1333 | pub fn placeholder(mut self, placeholder: impl Into<Cow<'static, str>>) -> Self { |
| 1334 | self.placeholder = Some(placeholder.into()); |
| 1335 | self |
| 1336 | } |
| 1337 | |
| 1338 | #[must_use] |
| 1339 | pub const fn focused(mut self, focused: bool) -> Self { |
| 1340 | self.focused = focused; |
| 1341 | self |
| 1342 | } |
| 1343 | |
| 1344 | /// Disable the field and say why. An empty reason shows |
| 1345 | /// [`TextInputWords::disabled`]. |
| 1346 | #[must_use] |
| 1347 | pub fn disabled(mut self, reason: impl Into<Cow<'static, str>>) -> Self { |
| 1348 | self.disabled = Some(reason.into()); |
| 1349 | self |
| 1350 | } |
| 1351 | |
| 1352 | /// Mark the field invalid: `✕` and the reason under it. |
| 1353 | #[must_use] |
| 1354 | pub fn error(mut self, message: impl Into<Cow<'static, str>>) -> Self { |
| 1355 | self.error = Some(message.into()); |
| 1356 | self |
| 1357 | } |
| 1358 | |
| 1359 | /// Muted guidance under the field, shown while there is no error. |
| 1360 | #[must_use] |
| 1361 | pub fn help(mut self, help: impl Into<Cow<'static, str>>) -> Self { |
| 1362 | self.help = Some(help.into()); |
| 1363 | self |
| 1364 | } |
| 1365 | |
| 1366 | #[must_use] |
| 1367 | pub fn words(mut self, words: &TextInputWords) -> Self { |
| 1368 | self.words = words.clone(); |
| 1369 | self |
| 1370 | } |
| 1371 | |
| 1372 | fn chrome(&self) -> FieldChrome<'_> { |
| 1373 | let note = if let Some(error) = &self.error { |
| 1374 | Some(FieldNote::error(error)) |
| 1375 | } else if let Some(reason) = &self.disabled { |
| 1376 | let reason = if reason.trim().is_empty() { |
| 1377 | &self.words.disabled |
| 1378 | } else { |
| 1379 | reason |
| 1380 | }; |
| 1381 | Some(FieldNote::locked(reason)) |
| 1382 | } else { |
| 1383 | self.help.as_deref().map(FieldNote::help) |
| 1384 | }; |
| 1385 | FieldChrome { |
| 1386 | label: self.label.as_deref(), |
| 1387 | focused: self.focused && self.disabled.is_none(), |
| 1388 | dimmed: self.disabled.is_some(), |
| 1389 | note, |
| 1390 | } |
| 1391 | } |
| 1392 | |
| 1393 | /// Where the terminal's own cursor goes when this field is painted in |
| 1394 | /// `area`: the cell the painted cursor is in. `None` unless focused and |
| 1395 | /// the cursor is visible. |
| 1396 | #[must_use] |
| 1397 | pub fn cursor_position(&self, area: Rect) -> Option<Position> { |
| 1398 | let chrome = self.chrome(); |
| 1399 | if !chrome.focused { |
| 1400 | return None; |
| 1401 | } |
| 1402 | let placed = chrome.place(area)?; |
| 1403 | let col = if self.state.is_empty() { |
| 1404 | 0 |
| 1405 | } else { |
| 1406 | self.state |
| 1407 | .window(usize::from(placed.value.width)) |
| 1408 | .cursor_col? |
| 1409 | }; |
| 1410 | let x = placed |
| 1411 | .value |
| 1412 | .x |
| 1413 | .saturating_add(u16::try_from(col).unwrap_or(u16::MAX)); |
| 1414 | (placed.value.width > 0 && x < placed.value.right()) |
| 1415 | .then_some(Position::new(x, placed.value.y)) |
| 1416 | } |
| 1417 | } |
| 1418 | |
| 1419 | impl Paint for TextInput<'_> { |
| 1420 | fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 1421 | let chrome = self.chrome(); |
| 1422 | let (focused, dimmed) = (chrome.focused, chrome.dimmed); |
| 1423 | chrome.paint(area, buf, theme, |value, buf| { |
| 1424 | paint_input_value( |
| 1425 | self.state, |
| 1426 | self.placeholder.as_deref(), |
| 1427 | focused, |
| 1428 | dimmed, |
| 1429 | value, |
| 1430 | buf, |
| 1431 | theme, |
| 1432 | ); |
| 1433 | }); |
| 1434 | } |
| 1435 | |
| 1436 | fn height(&self, width: u16, _theme: &Theme) -> u16 { |
| 1437 | self.chrome().height(width) |
| 1438 | } |
| 1439 | } |
| 1440 |