Skip to main content

max / alloy

install: name the machine The second step, and the first time anything in the console accepts typing. Every view until now was a list over state someone else owned, so Alloy had no caret. field.rs is that primitive. Indices are chars throughout, never bytes: a hostname is ASCII by specification but a username need not be, and String::insert at an offset landing mid-codepoint panics — the same trap that already bit shell::truncate. The multibyte test is there to hold that, not for completeness. It lives in the console binary rather than alloy_tui, for the same reason Steps does. alloy_tui is a separate published repo now, so putting a widget in it is a version bump and a crates.io publish, which is not something to do as a side effect of writing an installer step. docs/CONSOLE.md already lists AlloyForm and the schema-driven field widgets as alloy config's work; when those land this is the primitive underneath them and the move is a promotion, not a rewrite. The step sequence is a Step enum behind a STEPS array rather than a bare index, so render and handle match on the question being asked and adding a step is a compiler error everywhere it needs handling. This is the step that proves View::text_entry, which shipped in the previous commit with no consumer. `q` is the console's quit key on every other screen and a letter here, and the test that types it into the field is the one that would have caught the alternative: a user losing the installer mid-word. Hostname rules are RFC 1123 for a single label. Dots are refused rather than accepted as an FQDN — /etc/hostname holding a dotted name makes `hostname -s` and `hostname -f` disagree, and the installer should not be the thing that arranges that. The field is seeded with `alloy` to match etc/hostname in the image, so the default and the baked-in one cannot drift. Validation runs on Enter, not per keystroke. A half-typed hostname is invalid at nearly every intermediate state, and a field that reddens while you are still typing into it teaches nothing. wizard.rs loses its module-wide allow(dead_code); only `furthest` is still waiting on the step indicator, so the allow narrowed to that one method. Still not verified by eye — the TUI has not been launched, and the caret rendering in particular is unexercised by any test. 184 tests pass, 7 ignored, clippy clean.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-20 00:33 UTC
Signed with PGP, not checked
Commit: 28116b6cae477f921cde6b274659f1eddbf7949f
Parent: 23e2346
4 files changed, +553 insertions, -26 deletions
@@ -16,21 +16,83 @@
16 16 use ratatui::Frame;
17 17 use ratatui::crossterm::event::{KeyCode, KeyEvent};
18 18 use ratatui::layout::Rect;
19 + use ratatui::style::{Modifier, Style};
19 20 use ratatui::text::{Line, Span};
21 + use ratatui::widgets::Paragraph;
20 22 use serde::Deserialize;
21 23
22 24 use alloy_tui::Cursor;
23 25
24 26 use crate::cli::{CommandLog, Invocation};
27 + use crate::field::TextField;
25 28 use crate::shell::{Flow, View, block_title};
26 29 use crate::wizard::Steps;
27 30
28 - /// How many questions the wizard asks.
31 + /// The questions, in the order they are asked.
29 32 ///
30 - /// One while only the disk step exists. Each later step raises this as it
31 - /// lands, which is what keeps [`Steps::is_last`] honest about where Enter runs
32 - /// the install rather than advancing into a screen that is not written yet.
33 - const STEP_COUNT: usize = 1;
33 + /// The disk comes first because it is the one that can be wrong in a way
34 + /// nothing later recovers from, and because a user who cannot see their disk in
35 + /// the list should find that out before typing anything.
36 + const STEPS: [Step; 2] = [Step::Disk, Step::Hostname];
37 +
38 + /// Which question the wizard is on.
39 + ///
40 + /// An enum rather than a bare index so `render` and `handle` match on what is
41 + /// being asked instead of on a number, and so adding a step is a compiler error
42 + /// everywhere it needs to be handled.
43 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
44 + enum Step {
45 + Disk,
46 + Hostname,
47 + }
48 +
49 + impl Step {
50 + const fn label(self) -> &'static str {
51 + match self {
52 + Self::Disk => "select a disk",
53 + Self::Hostname => "name this machine",
54 + }
55 + }
56 + }
57 +
58 + /// The hostname an install gets if the user does not change it.
59 + ///
60 + /// Matches `etc/hostname` in the image, so the installer's default and the
61 + /// baked-in one cannot drift apart.
62 + const DEFAULT_HOSTNAME: &str = "alloy";
63 +
64 + /// Longest single hostname label, per RFC 1123.
65 + const HOSTNAME_MAX: usize = 63;
66 +
67 + /// Check a hostname, returning why it is unacceptable.
68 + ///
69 + /// RFC 1123 rules for a single label, which is what `/etc/hostname` holds:
70 + /// letters, digits and hyphens, not starting or ending with a hyphen, at most
71 + /// 63 characters. Dots are refused rather than accepted as an FQDN, because
72 + /// `/etc/hostname` holding a dotted name makes `hostname -s` and `hostname -f`
73 + /// disagree, and the installer should not be the thing that sets that up.
74 + ///
75 + /// Returns the message shown to the user, so each case says what to do rather
76 + /// than that something is wrong.
77 + fn validate_hostname(name: &str) -> Result<(), String> {
78 + if name.is_empty() {
79 + return Err("a hostname is required".into());
80 + }
81 + if name.chars().count() > HOSTNAME_MAX {
82 + return Err(format!("a hostname is at most {HOSTNAME_MAX} characters"));
83 + }
84 + if name.contains('.') {
85 + return Err("a hostname cannot contain dots; use the short name".into());
86 + }
87 + if name.starts_with('-') || name.ends_with('-') {
88 + return Err("a hostname cannot start or end with a hyphen".into());
89 + }
90 + let allowed = |c: &char| c.is_ascii_alphanumeric() || *c == '-';
91 + if let Some(bad) = name.chars().find(|c| !allowed(c)) {
92 + return Err(format!("'{bad}' is not allowed here; use a-z, 0-9 or -"));
93 + }
94 + Ok(())
95 + }
34 96
35 97 // ---- what the installer has been told ----
36 98
@@ -43,6 +105,8 @@
43 105 pub struct Answers {
44 106 /// Device path of the install target, e.g. `/dev/nvme0n1`.
45 107 pub disk: Option<String>,
108 + /// Written to `/etc/hostname` on the installed system.
109 + pub hostname: Option<String>,
46 110 }
47 111
48 112 // ---- disks ----
@@ -337,17 +401,24 @@
337 401 backend: Box<dyn Backend>,
338 402 disks: Vec<Disk>,
339 403 cursor: Cursor,
404 + hostname: TextField,
340 405 answers: Answers,
341 406 error: Option<String>,
342 407 }
343 408
344 409 impl InstallView {
345 410 pub fn new(log: &mut CommandLog) -> Self {
411 + let mut hostname = TextField::new();
412 + // Seeded rather than blank: the default is what most installs want, and
413 + // a field arriving pre-filled says what shape of answer is expected.
414 + hostname.set(DEFAULT_HOSTNAME);
415 +
346 416 let mut view = Self {
347 - steps: Steps::new(STEP_COUNT),
417 + steps: Steps::new(STEPS.len()),
348 418 backend: detect(),
349 419 disks: Vec::new(),
350 420 cursor: Cursor::new(),
421 + hostname,
351 422 answers: Answers::default(),
352 423 error: None,
353 424 };
@@ -355,6 +426,11 @@
355 426 view
356 427 }
357 428
429 + /// Which question is on screen.
430 + fn step(&self) -> Step {
431 + STEPS[self.steps.current()]
432 + }
433 +
358 434 fn refresh(&mut self, log: &mut CommandLog) {
359 435 match self.backend.list(log) {
360 436 Ok(disks) => {
@@ -394,6 +470,66 @@
394 470 Flow::Continue
395 471 }
396 472
473 + /// Take the hostname as the answer, if it is one.
474 + ///
475 + /// The diagnostic is inline and on Enter rather than on every keystroke:
476 + /// a half-typed name is invalid at almost every intermediate state, and a
477 + /// field that reddens while you are still typing into it teaches nothing.
478 + fn name_machine(&mut self) -> Flow {
479 + match validate_hostname(self.hostname.value()) {
480 + Ok(()) => {
481 + self.answers.hostname = Some(self.hostname.value().to_string());
482 + self.error = None;
483 + self.steps.advance();
484 + }
485 + Err(message) => self.error = Some(message),
486 + }
487 + Flow::Continue
488 + }
489 +
490 + /// Keys for the hostname field.
491 + ///
492 + /// Every printable character is taken literally, which is only correct
493 + /// because [`View::text_entry`] tells the shell to stop claiming `q` while
494 + /// this step is on screen.
495 + fn edit_hostname(&mut self, key: KeyEvent) -> Flow {
496 + match key.code {
497 + KeyCode::Char(c) => self.hostname.insert(c),
498 + KeyCode::Backspace => self.hostname.backspace(),
499 + KeyCode::Delete => self.hostname.delete(),
500 + KeyCode::Left => self.hostname.left(),
501 + KeyCode::Right => self.hostname.right(),
502 + KeyCode::Home => self.hostname.home(),
503 + KeyCode::End => self.hostname.end(),
504 + KeyCode::Enter => return self.name_machine(),
505 + _ => {}
506 + }
507 + Flow::Continue
508 + }
509 +
510 + /// The hostname pane: a prompt, the field with its caret, and what the
511 + /// name is for.
512 + fn render_hostname(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
513 + let (before, under, after) = self.hostname.split();
514 + let caret = under.unwrap_or(' ');
515 +
516 + let lines = vec![
517 + Line::from(text::muted(theme, "This machine's name on the network.")),
518 + Line::default(),
519 + Line::from(vec![
520 + text::secondary(theme, "hostname "),
521 + text::primary(theme, before.to_string()),
522 + Span::styled(
523 + caret.to_string(),
524 + Style::default().add_modifier(Modifier::REVERSED),
525 + ),
526 + text::primary(theme, after.to_string()),
527 + ]),
528 + ];
529 +
530 + frame.render_widget(Paragraph::new(lines), area);
531 + }
532 +
397 533 fn row<'a>(&self, theme: &Theme, disk: &'a Disk) -> Line<'a> {
398 534 let (status, severity) = match disk.blocker() {
399 535 Some(blocked) => (blocked.label(), Severity::Warn),
@@ -415,19 +551,29 @@
415 551 impl View for InstallView {
416 552 fn title(&self) -> String {
417 553 format!(
418 - "install ({}) — step {} of {}: select a disk",
554 + "install ({}) — step {} of {}: {}",
419 555 self.backend.name(),
420 556 self.steps.current() + 1,
421 - self.steps.len()
557 + self.steps.len(),
558 + self.step().label(),
422 559 )
423 560 }
424 561
425 562 fn hints(&self) -> Vec<Hint> {
426 - vec![
427 - hint("j/k", "select"),
428 - hint("enter", "choose"),
429 - hint("r", "refresh"),
430 - ]
563 + // Esc is listed from the second step on, where it means "back". On the
564 + // first it closes the installer, which is the shell's own `q`.
565 + let mut hints = match self.step() {
566 + Step::Disk => vec![
567 + hint("j/k", "select"),
568 + hint("enter", "choose"),
569 + hint("r", "refresh"),
570 + ],
571 + Step::Hostname => vec![hint("enter", "confirm")],
572 + };
573 + if !self.steps.is_first() {
574 + hints.push(hint("esc", "back"));
575 + }
576 + hints
431 577 }
432 578
433 579 fn status(&self) -> Option<(Severity, String)> {
@@ -448,6 +594,11 @@
448 594 let inner = block.inner(area);
449 595 frame.render_widget(block, area);
450 596
597 + if self.step() == Step::Hostname {
598 + self.render_hostname(frame, inner, theme);
599 + return;
600 + }
601 +
451 602 if self.disks.is_empty() {
452 603 frame.render_widget(Line::from(text::muted(theme, "no disks found")), inner);
453 604 return;
@@ -464,7 +615,17 @@
464 615 );
465 616 }
466 617
618 + /// The hostname step types, so the shell must stop reading `q` as quit
619 + /// while it is on screen.
620 + fn text_entry(&self) -> bool {
621 + self.step() == Step::Hostname
622 + }
623 +
467 624 fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow {
625 + if self.step() == Step::Hostname {
626 + return self.edit_hostname(key);
627 + }
628 +
468 629 match key.code {
469 630 KeyCode::Char('j') | KeyCode::Down => self.cursor.next(),
470 631 KeyCode::Char('k') | KeyCode::Up => self.cursor.prev(),
@@ -651,11 +812,14 @@
651 812 /// through [`InstallView::new`] so the tests do not depend on whatever
652 813 /// disks the machine running them happens to have.
653 814 fn view() -> (InstallView, CommandLog) {
815 + let mut hostname = TextField::new();
816 + hostname.set(DEFAULT_HOSTNAME);
654 817 let mut view = InstallView {
655 - steps: Steps::new(STEP_COUNT),
818 + steps: Steps::new(STEPS.len()),
656 819 backend: Box::new(Mock),
657 820 disks: disks(),
658 821 cursor: Cursor::new(),
822 + hostname,
659 823 answers: Answers::default(),
660 824 error: None,
661 825 };
@@ -721,17 +885,126 @@
721 885 assert!(matches!(view.cancel(), Flow::Exit));
722 886 }
723 887
724 - // Until a second step lands there is nothing to advance into, so a chosen
725 - // disk leaves the wizard where it is rather than stepping off the end.
726 888 #[test]
727 - fn choosing_does_not_advance_past_the_last_step() {
889 + fn choosing_a_disk_advances_to_the_hostname_step() {
728 890 let (mut view, mut log) = view();
729 891 select(&mut view, "sda");
730 892
731 893 view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
732 894
733 - assert!(view.steps.is_last());
734 - assert_eq!(view.steps.current(), 0);
895 + assert_eq!(view.step(), Step::Hostname);
896 + }
897 +
898 + // A refused disk must leave the user on the disk step. Advancing off a
899 + // rejected answer is how a wizard ends up installing to nothing.
900 + #[test]
901 + fn a_refused_disk_does_not_advance() {
902 + let (mut view, mut log) = view();
903 + select(&mut view, "nvme0n1");
904 +
905 + view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
906 +
907 + assert_eq!(view.step(), Step::Disk);
908 + }
909 +
910 + // ---- the hostname step ----
911 +
912 + fn at_hostname() -> (InstallView, CommandLog) {
913 + let (mut view, mut log) = view();
914 + select(&mut view, "sda");
915 + view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
916 + assert_eq!(view.step(), Step::Hostname, "fixture stalled on the disk");
917 + (view, log)
918 + }
919 +
920 + fn type_into(view: &mut InstallView, text: &str, log: &mut CommandLog) {
921 + for c in text.chars() {
922 + view.handle(KeyEvent::from(KeyCode::Char(c)), log);
923 + }
924 + }
925 +
926 + #[test]
927 + fn the_hostname_field_starts_on_the_image_default() {
928 + let (view, _log) = at_hostname();
929 + assert_eq!(view.hostname.value(), DEFAULT_HOSTNAME);
930 + }
931 +
932 + #[test]
933 + fn a_valid_hostname_is_recorded() {
934 + let (mut view, mut log) = at_hostname();
935 + for _ in 0..DEFAULT_HOSTNAME.len() {
936 + view.handle(KeyEvent::from(KeyCode::Backspace), &mut log);
937 + }
938 + type_into(&mut view, "workshop", &mut log);
939 + view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
940 +
941 + assert_eq!(view.answers.hostname.as_deref(), Some("workshop"));
942 + assert!(view.error.is_none());
943 + }
944 +
945 + // The whole reason `View::text_entry` exists. `q` is the console's quit key
946 + // on every other screen, and a user typing it into a hostname must keep the
947 + // installer they are halfway through.
948 + #[test]
949 + fn q_is_a_letter_in_the_hostname_field() {
950 + let (mut view, mut log) = at_hostname();
951 + assert!(view.text_entry(), "the shell was not told to release q");
952 +
953 + type_into(&mut view, "q", &mut log);
954 +
955 + assert_eq!(view.hostname.value(), "alloyq");
956 + }
957 +
958 + // ...and the disk step, which has no field, must not release it.
959 + #[test]
960 + fn the_disk_step_leaves_q_to_the_shell() {
961 + let (view, _log) = view();
962 + assert!(!view.text_entry());
963 + }
964 +
965 + #[test]
966 + fn esc_from_the_hostname_step_returns_to_the_disk() {
967 + let (mut view, _log) = at_hostname();
968 +
969 + assert!(matches!(view.cancel(), Flow::Continue));
970 +
971 + assert_eq!(view.step(), Step::Disk);
972 + }
973 +
974 + #[test]
975 + fn a_rejected_hostname_stays_on_the_step_and_says_why() {
976 + let (mut view, mut log) = at_hostname();
977 + type_into(&mut view, "_", &mut log);
978 + view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
979 +
980 + assert_eq!(view.step(), Step::Hostname);
981 + assert_eq!(view.answers.hostname, None);
982 + let error = view.error.expect("the rejection was silent");
983 + assert!(error.contains('_'), "{error}");
984 + }
985 +
986 + #[test]
987 + fn hostnames_are_checked_against_rfc_1123() {
988 + assert!(validate_hostname("alloy").is_ok());
989 + assert!(validate_hostname("fw13-2").is_ok());
990 + assert!(validate_hostname("a").is_ok());
991 +
992 + assert!(validate_hostname("").is_err(), "empty");
993 + assert!(validate_hostname("-alloy").is_err(), "leading hyphen");
994 + assert!(validate_hostname("alloy-").is_err(), "trailing hyphen");
995 + assert!(validate_hostname("al loy").is_err(), "space");
996 + assert!(validate_hostname("al_loy").is_err(), "underscore");
997 + assert!(validate_hostname(&"a".repeat(64)).is_err(), "64 characters");
998 + assert!(validate_hostname(&"a".repeat(63)).is_ok(), "63 allowed");
999 + }
1000 +
1001 + // Refused rather than accepted: /etc/hostname holding a dotted name makes
1002 + // `hostname -s` and `hostname -f` disagree, and the installer should not be
1003 + // the thing that sets that up.
1004 + #[test]
1005 + fn a_dotted_hostname_is_refused_with_advice() {
1006 + let error = validate_hostname("alloy.local").unwrap_err();
1007 + assert!(error.contains("short name"), "{error}");
735 1008 }
736 1009
737 1010 /// Parse whatever this machine's real lsblk reports.
@@ -779,6 +1052,6 @@
779 1052 let (view, _log) = view();
780 1053 let title = view.title();
781 1054 assert!(title.contains("mock"), "{title}");
782 - assert!(title.contains("step 1 of 1"), "{title}");
1055 + assert!(title.contains("step 1 of 2"), "{title}");
783 1056 }
784 1057 }
@@ -8,6 +8,7 @@
8 8
9 9 mod audio;
10 10 mod cli;
11 + mod field;
11 12 mod install;
12 13 mod mesh;
13 14 mod net;
@@ -20,11 +20,6 @@
20 20 //! this one waits until a second consumer wants it, which would be
21 21 //! `alloy config`'s multi-page forms if those land.
22 22
23 - // The navigation model landed before the view that drives it, so that the
24 - // question of what Esc and Enter mean in a wizard could be settled and tested
25 - // on its own. Comes off with `install.rs`, which constructs the first one.
26 - #![allow(dead_code)]
27 -
28 23 /// A position in a fixed sequence of steps.
29 24 ///
30 25 /// Knows nothing about what a step *is*. Whether the current step is answered
@@ -73,7 +68,10 @@
73 68 /// The furthest step the user has reached, current or behind them.
74 69 ///
75 70 /// For the step indicator: steps at or below this are answered, the rest
76 - /// are not yet visited.
71 + /// are not yet visited. Nothing draws that indicator yet — the title says
72 + /// "step 2 of 4" instead — so this is the last of the type still waiting on
73 + /// its consumer, and the allow comes off with it.
74 + #[allow(dead_code)]
77 75 pub const fn furthest(&self) -> usize {
78 76 self.furthest
79 77 }
@@ -1,0 +1,255 @@
1 + //! A single-line text input.
2 + //!
3 + //! The console's views until now have been lists over state someone else owns,
4 + //! so nothing in it ever accepted typing. The installer asks for a hostname and
5 + //! a username, which is the first time Alloy needs a caret.
6 + //!
7 + //! Lives in the console binary rather than in `alloy_tui`, for the same reason
8 + //! [`Steps`](crate::wizard::Steps) does: the design-system crate is a separate
9 + //! published repo, so putting a widget there is a release. docs/CONSOLE.md
10 + //! already lists `AlloyForm` and the schema-driven field widgets as `alloy
11 + //! config`'s work; when those land this is the primitive underneath them, and
12 + //! the move is a promotion rather than a rewrite.
13 + //!
14 + //! Indices are in `char`s throughout, never bytes. A hostname is ASCII by
15 + //! specification but a username need not be, and `String::insert` at a byte
16 + //! offset that lands mid-codepoint panics. The same trap already bit
17 + //! [`truncate`](crate::shell::truncate).
18 +
19 + /// A line of text with a caret in it.
20 + #[derive(Debug, Default, Clone)]
21 + pub struct TextField {
22 + value: String,
23 + /// Caret position, in `char`s from the start. Equal to the char count when
24 + /// the caret is past the last character, which is where typing appends.
25 + caret: usize,
26 + }
27 +
28 + impl TextField {
29 + pub fn new() -> Self {
30 + Self::default()
31 + }
32 +
33 + pub fn value(&self) -> &str {
34 + &self.value
35 + }
36 +
37 + /// Caret position, in `char`s.
38 + ///
39 + /// Nothing renders from this yet — [`split`](Self::split) is what drawing
40 + /// needs — but it is how the tests below say where the caret ended up, and
41 + /// asserting that through `split` would describe the text either side of it
42 + /// rather than the position itself.
43 + #[allow(dead_code)]
44 + pub fn caret(&self) -> usize {
45 + self.caret
46 + }
47 +
48 + fn chars(&self) -> usize {
49 + self.value.chars().count()
50 + }
51 +
52 + /// Byte offset of char index `index`, for the `String` operations.
53 + ///
54 + /// `char_indices` stops at the last character, so an index one past the end
55 + /// (the caret appending at the tail) falls through to the string length
56 + /// rather than off it.
57 + fn byte_of(&self, index: usize) -> usize {
58 + self.value
59 + .char_indices()
60 + .nth(index)
61 + .map_or(self.value.len(), |(byte, _)| byte)
62 + }
63 +
64 + /// Type a character at the caret.
65 + pub fn insert(&mut self, c: char) {
66 + let at = self.byte_of(self.caret);
67 + self.value.insert(at, c);
68 + self.caret += 1;
69 + }
70 +
71 + /// Delete the character before the caret.
72 + pub fn backspace(&mut self) {
73 + if self.caret == 0 {
74 + return;
75 + }
76 + self.caret -= 1;
77 + let at = self.byte_of(self.caret);
78 + self.value.remove(at);
79 + }
80 +
81 + /// Delete the character under the caret.
82 + pub fn delete(&mut self) {
83 + if self.caret >= self.chars() {
84 + return;
85 + }
86 + let at = self.byte_of(self.caret);
87 + self.value.remove(at);
88 + }
89 +
90 + /// Clamped rather than wrapping: a caret that jumps to the far end of the
91 + /// line when you press Left once too often is the kind of thing that gets
92 + /// a character typed into the wrong place.
93 + pub fn left(&mut self) {
94 + self.caret = self.caret.saturating_sub(1);
95 + }
96 +
97 + pub fn right(&mut self) {
98 + if self.caret < self.chars() {
99 + self.caret += 1;
100 + }
101 + }
102 +
103 + pub fn home(&mut self) {
104 + self.caret = 0;
105 + }
106 +
107 + pub fn end(&mut self) {
108 + self.caret = self.chars();
109 + }
110 +
111 + /// Replace the contents, caret to the end.
112 + ///
113 + /// For seeding a field with a default the user is expected to edit rather
114 + /// than retype, which is what the hostname step does.
115 + pub fn set(&mut self, value: impl Into<String>) {
116 + self.value = value.into();
117 + self.caret = self.chars();
118 + }
119 +
120 + /// The line split at the caret: what is before it, the character under it,
121 + /// and what follows.
122 + ///
123 + /// Returned as three pieces rather than rendered here because drawing needs
124 + /// the theme, and this type deliberately knows nothing about one. The
125 + /// middle is `None` when the caret is past the end, where a renderer draws
126 + /// a block on empty space.
127 + pub fn split(&self) -> (&str, Option<char>, &str) {
128 + let at = self.byte_of(self.caret);
129 + let (before, rest) = self.value.split_at(at);
130 + let mut chars = rest.chars();
131 + match chars.next() {
132 + Some(under) => (before, Some(under), chars.as_str()),
133 + None => (before, None, ""),
134 + }
135 + }
136 + }
137 +
138 + #[cfg(test)]
139 + mod tests {
140 + use super::*;
141 +
142 + fn typed(text: &str) -> TextField {
143 + let mut field = TextField::new();
144 + for c in text.chars() {
145 + field.insert(c);
146 + }
147 + field
148 + }
149 +
150 + #[test]
151 + fn typing_appends_and_moves_the_caret() {
152 + let field = typed("alloy");
153 + assert_eq!(field.value(), "alloy");
154 + assert_eq!(field.caret(), 5);
155 + }
156 +
157 + #[test]
158 + fn insert_lands_at_the_caret_not_the_end() {
159 + let mut field = typed("aloy");
160 + field.home();
161 + field.right();
162 + field.insert('l');
163 + assert_eq!(field.value(), "alloy");
164 + assert_eq!(field.caret(), 2);
165 + }
166 +
167 + #[test]
168 + fn backspace_takes_the_character_before_the_caret() {
169 + let mut field = typed("alloyy");
170 + field.backspace();
171 + assert_eq!(field.value(), "alloy");
172 +
173 + field.home();
174 + field.backspace();
175 + assert_eq!(field.value(), "alloy", "backspace at the start is inert");
176 + assert_eq!(field.caret(), 0);
177 + }
178 +
179 + #[test]
180 + fn delete_takes_the_character_under_the_caret() {
181 + let mut field = typed("xalloy");
182 + field.home();
183 + field.delete();
184 + assert_eq!(field.value(), "alloy");
185 +
186 + field.end();
187 + field.delete();
188 + assert_eq!(field.value(), "alloy", "delete at the end is inert");
189 + }
190 +
191 + // Clamped, not wrapping. A caret that leaps to the opposite end on one
192 + // keypress too many puts the next character somewhere the user did not look.
193 + #[test]
194 + fn the_caret_stops_at_both_ends() {
195 + let mut field = typed("ab");
196 + field.home();
197 + field.left();
198 + assert_eq!(field.caret(), 0);
199 + field.end();
200 + field.right();
201 + assert_eq!(field.caret(), 2);
202 + }
203 +
204 + // The reason indices are chars. Byte offsets that land mid-codepoint panic
205 + // in `String::insert` and `String::remove`, and a username is not
206 + // guaranteed ASCII.
207 + #[test]
208 + fn multibyte_text_is_edited_without_panicking() {
209 + let mut field = typed("héllo");
210 + assert_eq!(field.caret(), 5);
211 +
212 + field.home();
213 + field.right();
214 + field.delete();
215 + assert_eq!(field.value(), "hllo", "the two-byte char came out whole");
216 +
217 + field.insert('é');
218 + assert_eq!(field.value(), "héllo");
219 +
220 + field.end();
221 + field.backspace();
222 + assert_eq!(field.value(), "héll");
223 + }
224 +
225 + #[test]
226 + fn split_reports_the_character_under_the_caret() {
227 + let mut field = typed("alloy");
228 + field.home();
229 + assert_eq!(field.split(), ("", Some('a'), "lloy"));
230 +
231 + field.right();
232 + assert_eq!(field.split(), ("a", Some('l'), "loy"));
233 +
234 + field.end();
235 + assert_eq!(
236 + field.split(),
237 + ("alloy", None, ""),
238 + "past the end there is nothing under the caret"
239 + );
240 + }
241 +
242 + #[test]
243 + fn split_handles_an_empty_field() {
244 + assert_eq!(TextField::new().split(), ("", None, ""));
245 + }
246 +
247 + #[test]
248 + fn set_replaces_the_value_and_parks_the_caret_at_the_end() {
249 + let mut field = typed("old");
250 + field.home();
251 + field.set("alloy");
252 + assert_eq!(field.value(), "alloy");
253 + assert_eq!(field.caret(), 5, "ready to edit the tail, not retype it");
254 + }
255 + }