Skip to main content

blizz_ui/components/
text_entry.rs

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;
15/// Don't start chrome animation until the question is at least this far revealed.
16const QUESTION_REVEAL_GATE: f64 = 0.5;
17/// Rows consumed by a fully rendered text entry box (top border + content + bottom border).
18const 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  /// Text entry chrome for an env-var style prompt (`label` = variable name, `content` = default).
34  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  /// Invisible shell when the step is dialog-only or selector-only (no editable field).
48  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  /// Compute and cache the text-entry layout for the given terminal size.
74  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  /// Compute cursor column for the active input buffer, or `None` if the
84  /// box isn't fully open yet.
85  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  /// Wizard decode phase: grows the chrome and reveals labels after the question is half-shown.
98  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  /// Transition encode: scrub label/content; optionally shrink the border when leaving prompt steps.
110  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  /// Transition decode after `swap_to`: open chrome then fade labels/content in.
128  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  /// True when the chrome is fully open and all text is fully revealed.
141  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  /// Exit phase: encode away text then close chrome.
146  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    // Open phase
291    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    // Finish opening
299    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    // Encode: text fades
330    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    // Finish fading text
335    te.label_reveal = 0.0;
336    te.content_reveal = 0.0;
337
338    // Now box closes since next step has no prompt box
339    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    // Opens first
370    te.tick_transition_decode(0.3, 0.5);
371    assert!(te.open > 0.0);
372    assert_eq!(te.label_reveal, 0.0);
373
374    // Finish opening
375    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    // Finish fading
400    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}