1use std::borrow::Cow;
2use std::io::Write;
3
4use crate::box_chrome;
5use crate::decode;
6use crate::input_buffer::{self, InputBuffer};
7use crate::layout::{Size, centered_column};
8use crate::prompt::{
9 TextEntry, TextEntryLayout, bottom_border, queue_box_border, queue_box_border_plain,
10 queue_box_content, queue_box_content_with_cursor, text_entry, text_entry_layout, visible_window,
11};
12use crate::{Component, Renderer};
13
14const MIN_PARTIAL_WIDTH: u16 = 2;
15const QUESTION_REVEAL_GATE: f64 = 0.5;
17const BOX_ROW_COUNT: u16 = 3;
19
20#[derive(Debug, Clone)]
21pub struct TextEntryComponent {
22 pub label: String,
23 pub content: String,
24 pub open: f64,
25 pub label_reveal: f64,
26 pub content_reveal: f64,
27 pub visible: bool,
28 input_buf: InputBuffer,
29 cached_layout: Option<TextEntryLayout>,
30}
31
32impl TextEntryComponent {
33 pub fn prompt(label: impl Into<String>, default_value: impl Into<String>) -> Self {
35 Self {
36 label: label.into(),
37 content: default_value.into(),
38 open: 0.0,
39 label_reveal: 0.0,
40 content_reveal: 0.0,
41 visible: true,
42 input_buf: input_buffer::new(),
43 cached_layout: None,
44 }
45 }
46
47 pub fn hidden() -> Self {
49 Self {
50 label: String::new(),
51 content: String::new(),
52 open: 0.0,
53 label_reveal: 0.0,
54 content_reveal: 0.0,
55 visible: false,
56 input_buf: input_buffer::new(),
57 cached_layout: None,
58 }
59 }
60
61 pub fn input_buf(&self) -> &InputBuffer {
62 &self.input_buf
63 }
64
65 pub fn input_buf_mut(&mut self) -> &mut InputBuffer {
66 &mut self.input_buf
67 }
68
69 pub fn reset_input_buf(&mut self) {
70 self.input_buf = input_buffer::new();
71 }
72
73 pub fn compute_layout(&mut self, terminal_size: Size) {
75 let dims = text_entry(&self.label, &self.content, terminal_size.width);
76 self.cached_layout = Some(text_entry_layout(&dims, terminal_size));
77 }
78
79 pub fn layout(&self) -> Option<&TextEntryLayout> {
80 self.cached_layout.as_ref()
81 }
82
83 pub fn cursor_column(&self, terminal_width: u16) -> Option<u16> {
86 if !self.visible || self.open < 1.0 {
87 return None;
88 }
89 let frame = text_entry(&self.label, &self.content, terminal_width);
90 let buf_text = input_buffer::text(&self.input_buf);
91 let cursor_pos = input_buffer::cursor(&self.input_buf);
92 let selection = input_buffer::selection_range(&self.input_buf);
93 let window = visible_window(&frame, buf_text, cursor_pos, selection);
94 Some(window.cursor_col as u16)
95 }
96
97 pub fn tick_decode(&mut self, question_reveal: f64, box_open_speed: f64, reveal_speed: f64) {
99 if !self.visible || question_reveal < QUESTION_REVEAL_GATE {
100 return;
101 }
102 self.open = (self.open + box_open_speed).min(1.0);
103 if self.open >= 1.0 {
104 self.label_reveal = (self.label_reveal + reveal_speed).min(1.0);
105 self.content_reveal = (self.content_reveal + reveal_speed).min(1.0);
106 }
107 }
108
109 pub fn tick_transition_encode(
111 &mut self,
112 next_has_prompt_box: bool,
113 encode_speed: f64,
114 morph_speed: f64,
115 ) {
116 if !self.visible {
117 return;
118 }
119 self.label_reveal = (self.label_reveal - encode_speed).max(0.0);
120 self.content_reveal = (self.content_reveal - encode_speed).max(0.0);
121 let text_hidden = self.label_reveal <= 0.0 && self.content_reveal <= 0.0;
122 if !next_has_prompt_box && text_hidden {
123 self.open = (self.open - morph_speed).max(0.0);
124 }
125 }
126
127 pub fn tick_transition_decode(&mut self, encode_speed: f64, morph_speed: f64) {
129 if !self.visible {
130 return;
131 }
132 if self.open < 1.0 {
133 self.open = (self.open + morph_speed).min(1.0);
134 } else {
135 self.label_reveal = (self.label_reveal + encode_speed).min(1.0);
136 self.content_reveal = (self.content_reveal + encode_speed).min(1.0);
137 }
138 }
139
140 pub fn is_fully_ready(&self) -> bool {
142 self.visible && self.open >= 1.0 && self.label_reveal >= 1.0 && self.content_reveal >= 1.0
143 }
144
145 pub fn tick_exit_close(&mut self, encode_speed: f64, box_close_speed: f64) {
147 if !self.visible {
148 return;
149 }
150 self.label_reveal = (self.label_reveal - encode_speed).max(0.0);
151 self.content_reveal = (self.content_reveal - encode_speed).max(0.0);
152 if self.label_reveal <= 0.0 && self.content_reveal <= 0.0 {
153 self.open = (self.open - box_close_speed).max(0.0);
154 }
155 }
156
157 #[cfg(not(tarpaulin_include))]
158 fn render_partial_open<W: Write>(
159 &self,
160 writer: &mut W,
161 full_width: u16,
162 top_row: u16,
163 terminal_width: u16,
164 ) -> std::io::Result<u16> {
165 let raw = ((full_width as f64) * self.open).round() as u16;
166 let current_width = box_chrome::snap_even(raw, MIN_PARTIAL_WIDTH).min(full_width);
167
168 if current_width == 0 {
169 return Ok(0);
170 }
171
172 let outer_width = current_width + box_chrome::CHROME_WIDTH;
173 let col = centered_column(terminal_width, outer_width);
174 let empty_content = " ".repeat(current_width as usize);
175 let bottom = bottom_border(current_width);
176
177 queue_box_border(writer, "", current_width, 0, col, top_row)?;
178 queue_box_content(writer, &empty_content, false, col, top_row + 1)?;
179 queue_box_border_plain(writer, &bottom, col, top_row + 2)?;
180 Ok(BOX_ROW_COUNT)
181 }
182}
183
184#[cfg(not(tarpaulin_include))]
185impl TextEntryComponent {
186 fn decode_text<'a, R: rand::Rng>(text: &'a str, reveal: f64, rng: &mut R) -> Cow<'a, str> {
187 if reveal >= 1.0 {
188 Cow::Borrowed(text)
189 } else {
190 let revealed = (text.chars().count() as f64 * reveal).round() as usize;
191 Cow::Owned(decode::decode_frame(text, revealed, rng))
192 }
193 }
194
195 fn render_fully_open<W: Write, R: rand::Rng>(
196 &self,
197 writer: &mut W,
198 panel: crate::LayoutPanel,
199 rng: &mut R,
200 ) -> std::io::Result<u16> {
201 let label_display = Self::decode_text(&self.label, self.label_reveal, rng);
202 let content_display = Self::decode_text(&self.content, self.content_reveal, rng);
203
204 let frame = TextEntry {
205 label: String::new(),
206 hint: content_display.into_owned(),
207 inner_width: panel.width,
208 };
209 let label_width = label_display.chars().count() as u16;
210 let buf_text = input_buffer::text(&self.input_buf);
211 let cursor_pos = input_buffer::cursor(&self.input_buf);
212 let selection = input_buffer::selection_range(&self.input_buf);
213 let window = visible_window(&frame, buf_text, cursor_pos, selection);
214 let is_hint = buf_text.is_empty();
215 let bottom = bottom_border(panel.width);
216
217 queue_box_border(
218 writer,
219 &label_display,
220 panel.width,
221 label_width,
222 panel.column,
223 panel.row,
224 )?;
225 queue_box_content_with_cursor(writer, &window, is_hint, panel.column, panel.row + 1)?;
226 queue_box_border_plain(writer, &bottom, panel.column, panel.row + 2)?;
227 Ok(BOX_ROW_COUNT)
228 }
229}
230
231#[cfg(not(tarpaulin_include))]
232impl Component for TextEntryComponent {
233 fn render<W: Write>(&self, renderer: &mut Renderer<W>) -> std::io::Result<u16> {
234 if !self.visible || self.open <= 0.0 {
235 return Ok(0);
236 }
237 let terminal_width = renderer.ctx().terminal_size.width;
238
239 renderer.with_panel(|writer, panel, rng| {
240 if self.open < 1.0 {
241 return self.render_partial_open(writer, panel.width, panel.row, terminal_width);
242 }
243 self.render_fully_open(writer, panel, rng)
244 })
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use crate::layout::Size;
252
253 #[test]
254 fn hidden_is_invisible_with_zero_progress() {
255 let te = TextEntryComponent::hidden();
256 assert!(!te.visible);
257 assert_eq!(te.open, 0.0);
258 assert_eq!(te.label_reveal, 0.0);
259 assert_eq!(te.content_reveal, 0.0);
260 assert!(te.label.is_empty());
261 assert!(te.content.is_empty());
262 }
263
264 #[test]
265 fn prompt_starts_visible_with_zero_progress() {
266 let te = TextEntryComponent::prompt("NAME", "default");
267 assert!(te.visible);
268 assert_eq!(te.open, 0.0);
269 assert_eq!(te.label, "NAME");
270 assert_eq!(te.content, "default");
271 }
272
273 #[test]
274 fn tick_decode_noop_when_invisible() {
275 let mut te = TextEntryComponent::hidden();
276 te.tick_decode(1.0, 0.1, 0.1);
277 assert_eq!(te.open, 0.0);
278 }
279
280 #[test]
281 fn tick_decode_noop_below_question_gate() {
282 let mut te = TextEntryComponent::prompt("X", "");
283 te.tick_decode(0.49, 0.1, 0.1);
284 assert_eq!(te.open, 0.0);
285 }
286
287 #[test]
288 fn tick_decode_opens_then_reveals() {
289 let mut te = TextEntryComponent::prompt("X", "val");
290 te.tick_decode(0.6, 0.5, 0.3);
292 assert!(te.open > 0.0);
293 assert_eq!(
294 te.label_reveal, 0.0,
295 "label shouldn't reveal until fully open"
296 );
297
298 te.open = 1.0;
300 te.tick_decode(0.6, 0.5, 0.3);
301 assert!(te.label_reveal > 0.0);
302 assert!(te.content_reveal > 0.0);
303 }
304
305 #[test]
306 fn tick_decode_clamps_at_one() {
307 let mut te = TextEntryComponent::prompt("X", "");
308 te.open = 1.0;
309 te.tick_decode(1.0, 1.0, 2.0);
310 assert_eq!(te.open, 1.0);
311 assert_eq!(te.label_reveal, 1.0);
312 assert_eq!(te.content_reveal, 1.0);
313 }
314
315 #[test]
316 fn tick_transition_encode_noop_when_invisible() {
317 let mut te = TextEntryComponent::hidden();
318 te.tick_transition_encode(true, 0.1, 0.1);
319 assert_eq!(te.label_reveal, 0.0);
320 }
321
322 #[test]
323 fn tick_transition_encode_fades_text_then_closes() {
324 let mut te = TextEntryComponent::prompt("X", "v");
325 te.open = 1.0;
326 te.label_reveal = 1.0;
327 te.content_reveal = 1.0;
328
329 te.tick_transition_encode(false, 0.5, 0.0);
331 assert!(te.label_reveal < 1.0);
332 assert!(te.content_reveal < 1.0);
333
334 te.label_reveal = 0.0;
336 te.content_reveal = 0.0;
337
338 te.tick_transition_encode(false, 0.5, 0.4);
340 assert!(te.open < 1.0);
341 }
342
343 #[test]
344 fn tick_transition_encode_keeps_box_when_next_has_prompt() {
345 let mut te = TextEntryComponent::prompt("X", "");
346 te.open = 1.0;
347 te.label_reveal = 0.0;
348 te.content_reveal = 0.0;
349
350 te.tick_transition_encode(true, 0.5, 0.5);
351 assert_eq!(
352 te.open, 1.0,
353 "box stays open when next step has a prompt box"
354 );
355 }
356
357 #[test]
358 fn tick_transition_decode_noop_when_invisible() {
359 let mut te = TextEntryComponent::hidden();
360 te.tick_transition_decode(0.1, 0.1);
361 assert_eq!(te.open, 0.0);
362 }
363
364 #[test]
365 fn tick_transition_decode_opens_then_reveals() {
366 let mut te = TextEntryComponent::prompt("X", "v");
367 te.open = 0.0;
368
369 te.tick_transition_decode(0.3, 0.5);
371 assert!(te.open > 0.0);
372 assert_eq!(te.label_reveal, 0.0);
373
374 te.open = 1.0;
376 te.tick_transition_decode(0.3, 0.5);
377 assert!(te.label_reveal > 0.0);
378 assert!(te.content_reveal > 0.0);
379 }
380
381 #[test]
382 fn tick_exit_close_noop_when_invisible() {
383 let mut te = TextEntryComponent::hidden();
384 te.tick_exit_close(0.1, 0.1);
385 assert_eq!(te.open, 0.0);
386 }
387
388 #[test]
389 fn tick_exit_close_fades_text_then_closes_box() {
390 let mut te = TextEntryComponent::prompt("X", "v");
391 te.open = 1.0;
392 te.label_reveal = 1.0;
393 te.content_reveal = 1.0;
394
395 te.tick_exit_close(0.5, 0.5);
396 assert!(te.label_reveal < 1.0);
397 assert_eq!(te.open, 1.0, "box stays while text is visible");
398
399 te.label_reveal = 0.0;
401 te.content_reveal = 0.0;
402 te.tick_exit_close(0.5, 0.5);
403 assert!(te.open < 1.0);
404 }
405
406 #[test]
407 fn is_fully_ready_requires_all_conditions() {
408 let mut te = TextEntryComponent::prompt("X", "v");
409 assert!(!te.is_fully_ready());
410
411 te.open = 1.0;
412 te.label_reveal = 1.0;
413 te.content_reveal = 1.0;
414 assert!(te.is_fully_ready());
415
416 te.visible = false;
417 assert!(!te.is_fully_ready());
418 }
419
420 #[test]
421 fn cursor_column_none_when_not_ready() {
422 let te = TextEntryComponent::prompt("X", "v");
423 assert!(te.cursor_column(80).is_none());
424 }
425
426 #[test]
427 fn cursor_column_some_when_fully_ready() {
428 let mut te = TextEntryComponent::prompt("X", "v");
429 te.open = 1.0;
430 te.label_reveal = 1.0;
431 te.content_reveal = 1.0;
432 assert!(te.cursor_column(80).is_some());
433 }
434
435 #[test]
436 fn compute_layout_caches_and_layout_retrieves() {
437 let mut te = TextEntryComponent::prompt("X", "v");
438 assert!(te.layout().is_none());
439
440 let size = Size {
441 width: 80,
442 height: 24,
443 };
444 te.compute_layout(size);
445 let layout = te.layout().expect("layout should be cached");
446 assert!(layout.inner_width > 0);
447 }
448
449 #[test]
450 fn input_buf_accessors() {
451 let mut te = TextEntryComponent::prompt("X", "");
452 assert!(input_buffer::text(te.input_buf()).is_empty());
453 input_buffer::insert_char(te.input_buf_mut(), 'a');
454 assert_eq!(input_buffer::text(te.input_buf()), "a");
455 te.reset_input_buf();
456 assert!(input_buffer::text(te.input_buf()).is_empty());
457 }
458}