Skip to main content

max / alloy

console: take alloy_tui 1.2, and hand TextField back to it field.rs said in its own header that it lived here only because a widget in the design-system crate is a release, and that the move would be a promotion when the form widgets landed. They have, so it is. alloy_tui 1.2 is committed but not published, so the workspace patches crates.io to the sibling checkout. The dependency already names 1.2.0; publishing means deleting the patch stanza and nothing else.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 18:20 UTC
Signed with PGP, not checked
Commit: 625083d88ce2a3883369629cd399a3a991139946
Parent: 6bced29
6 files changed, +9 insertions, -261 deletions
M Cargo.lock +1 -3
@@ -36,9 +36,7 @@
36 36
37 37 [[package]]
38 38 name = "alloy_tui"
39 - version = "1.1.0"
40 - source = "registry+https://github.com/rust-lang/crates.io-index"
41 - checksum = "a0dce987e054875390687b1f3bbe547095d866d4fdf18dbd313f65a444c45d44"
39 + version = "1.2.0"
42 40 dependencies = [
43 41 "makeover",
44 42 "ratatui",
M Cargo.toml +6
@@ -49,3 +49,9 @@
49 49 match_same_arms = "allow"
50 50 unnecessary_wraps = "allow"
51 51 type_complexity = "allow"
52 +
53 + # alloy_tui 1.2 (the config form widgets) is written and committed but not yet
54 + # published, and the console's `settings` work depends on it. Delete this stanza
55 + # at publish time; the dependency already names 1.2.0, so nothing else changes.
56 + [patch.crates-io]
57 + alloy_tui = { path = "../Libraries/alloy_tui" }
@@ -13,7 +13,7 @@
13 13 path = "src/main.rs"
14 14
15 15 [dependencies]
16 - alloy_tui = "1.1.0"
16 + alloy_tui = "1.2.0"
17 17 anyhow = "1"
18 18 clap = { version = "4", features = ["derive"] }
19 19 ratatui.workspace = true
@@ -69,10 +69,10 @@
69 69 use alloy_tui::{Cursor, FocusRing};
70 70
71 71 use crate::cli::{CommandLog, Invocation, Secret};
72 - use crate::field::TextField;
73 72 use crate::run::{Sequence, Stage};
74 73 use crate::shell::{Confirm, Flow, TICK, View, block_title, truncate};
75 74 use crate::wizard::Steps;
75 + use alloy_tui::TextField;
76 76
77 77 /// The questions, in the order they are asked.
78 78 ///
@@ -9,7 +9,6 @@
9 9 mod audio;
10 10 mod bind;
11 11 mod cli;
12 - mod field;
13 12 mod install;
14 13 mod mesh;
15 14 mod net;
@@ -1,255 +1,0 @@
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(crate) 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(crate) fn new() -> Self {
30 - Self::default()
31 - }
32 -
33 - pub(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) fn left(&mut self) {
94 - self.caret = self.caret.saturating_sub(1);
95 - }
96 -
97 - pub(crate) fn right(&mut self) {
98 - if self.caret < self.chars() {
99 - self.caret += 1;
100 - }
101 - }
102 -
103 - pub(crate) fn home(&mut self) {
104 - self.caret = 0;
105 - }
106 -
107 - pub(crate) 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(crate) 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(crate) 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 - }