use std::borrow::Cow;
use std::io::Write;
use crate::box_chrome;
use crate::decode;
use crate::input_buffer::{self, InputBuffer};
use crate::layout::{Size, centered_column};
use crate::prompt::{
TextEntry, TextEntryLayout, bottom_border, queue_box_border, queue_box_border_plain,
queue_box_content, queue_box_content_with_cursor, text_entry, text_entry_layout, visible_window,
};
use crate::{Component, Renderer};
const MIN_PARTIAL_WIDTH: u16 = 2;
const QUESTION_REVEAL_GATE: f64 = 0.5;
const BOX_ROW_COUNT: u16 = 3;
#[derive(Debug, Clone)]
pub struct TextEntryComponent {
pub label: String,
pub content: String,
pub open: f64,
pub label_reveal: f64,
pub content_reveal: f64,
pub visible: bool,
input_buf: InputBuffer,
cached_layout: Option<TextEntryLayout>,
}
impl TextEntryComponent {
pub fn prompt(label: impl Into<String>, default_value: impl Into<String>) -> Self {
Self {
label: label.into(),
content: default_value.into(),
open: 0.0,
label_reveal: 0.0,
content_reveal: 0.0,
visible: true,
input_buf: input_buffer::new(),
cached_layout: None,
}
}
pub fn hidden() -> Self {
Self {
label: String::new(),
content: String::new(),
open: 0.0,
label_reveal: 0.0,
content_reveal: 0.0,
visible: false,
input_buf: input_buffer::new(),
cached_layout: None,
}
}
pub fn input_buf(&self) -> &InputBuffer {
&self.input_buf
}
pub fn input_buf_mut(&mut self) -> &mut InputBuffer {
&mut self.input_buf
}
pub fn reset_input_buf(&mut self) {
self.input_buf = input_buffer::new();
}
pub fn compute_layout(&mut self, terminal_size: Size) {
let dims = text_entry(&self.label, &self.content, terminal_size.width);
self.cached_layout = Some(text_entry_layout(&dims, terminal_size));
}
pub fn layout(&self) -> Option<&TextEntryLayout> {
self.cached_layout.as_ref()
}
pub fn cursor_column(&self, terminal_width: u16) -> Option<u16> {
if !self.visible || self.open < 1.0 {
return None;
}
let frame = text_entry(&self.label, &self.content, terminal_width);
let buf_text = input_buffer::text(&self.input_buf);
let cursor_pos = input_buffer::cursor(&self.input_buf);
let selection = input_buffer::selection_range(&self.input_buf);
let window = visible_window(&frame, buf_text, cursor_pos, selection);
Some(window.cursor_col as u16)
}
pub fn tick_decode(&mut self, question_reveal: f64, box_open_speed: f64, reveal_speed: f64) {
if !self.visible || question_reveal < QUESTION_REVEAL_GATE {
return;
}
self.open = (self.open + box_open_speed).min(1.0);
if self.open >= 1.0 {
self.label_reveal = (self.label_reveal + reveal_speed).min(1.0);
self.content_reveal = (self.content_reveal + reveal_speed).min(1.0);
}
}
pub fn tick_transition_encode(
&mut self,
next_has_prompt_box: bool,
encode_speed: f64,
morph_speed: f64,
) {
if !self.visible {
return;
}
self.label_reveal = (self.label_reveal - encode_speed).max(0.0);
self.content_reveal = (self.content_reveal - encode_speed).max(0.0);
let text_hidden = self.label_reveal <= 0.0 && self.content_reveal <= 0.0;
if !next_has_prompt_box && text_hidden {
self.open = (self.open - morph_speed).max(0.0);
}
}
pub fn tick_transition_decode(&mut self, encode_speed: f64, morph_speed: f64) {
if !self.visible {
return;
}
if self.open < 1.0 {
self.open = (self.open + morph_speed).min(1.0);
} else {
self.label_reveal = (self.label_reveal + encode_speed).min(1.0);
self.content_reveal = (self.content_reveal + encode_speed).min(1.0);
}
}
pub fn is_fully_ready(&self) -> bool {
self.visible && self.open >= 1.0 && self.label_reveal >= 1.0 && self.content_reveal >= 1.0
}
pub fn tick_exit_close(&mut self, encode_speed: f64, box_close_speed: f64) {
if !self.visible {
return;
}
self.label_reveal = (self.label_reveal - encode_speed).max(0.0);
self.content_reveal = (self.content_reveal - encode_speed).max(0.0);
if self.label_reveal <= 0.0 && self.content_reveal <= 0.0 {
self.open = (self.open - box_close_speed).max(0.0);
}
}
#[cfg(not(tarpaulin_include))]
fn render_partial_open<W: Write>(
&self,
writer: &mut W,
full_width: u16,
top_row: u16,
terminal_width: u16,
) -> std::io::Result<u16> {
let raw = ((full_width as f64) * self.open).round() as u16;
let current_width = box_chrome::snap_even(raw, MIN_PARTIAL_WIDTH).min(full_width);
if current_width == 0 {
return Ok(0);
}
let outer_width = current_width + box_chrome::CHROME_WIDTH;
let col = centered_column(terminal_width, outer_width);
let empty_content = " ".repeat(current_width as usize);
let bottom = bottom_border(current_width);
queue_box_border(writer, "", current_width, 0, col, top_row)?;
queue_box_content(writer, &empty_content, false, col, top_row + 1)?;
queue_box_border_plain(writer, &bottom, col, top_row + 2)?;
Ok(BOX_ROW_COUNT)
}
}
#[cfg(not(tarpaulin_include))]
impl TextEntryComponent {
fn decode_text<'a, R: rand::Rng>(text: &'a str, reveal: f64, rng: &mut R) -> Cow<'a, str> {
if reveal >= 1.0 {
Cow::Borrowed(text)
} else {
let revealed = (text.chars().count() as f64 * reveal).round() as usize;
Cow::Owned(decode::decode_frame(text, revealed, rng))
}
}
fn render_fully_open<W: Write, R: rand::Rng>(
&self,
writer: &mut W,
panel: crate::LayoutPanel,
rng: &mut R,
) -> std::io::Result<u16> {
let label_display = Self::decode_text(&self.label, self.label_reveal, rng);
let content_display = Self::decode_text(&self.content, self.content_reveal, rng);
let frame = TextEntry {
label: String::new(),
hint: content_display.into_owned(),
inner_width: panel.width,
};
let label_width = label_display.chars().count() as u16;
let buf_text = input_buffer::text(&self.input_buf);
let cursor_pos = input_buffer::cursor(&self.input_buf);
let selection = input_buffer::selection_range(&self.input_buf);
let window = visible_window(&frame, buf_text, cursor_pos, selection);
let is_hint = buf_text.is_empty();
let bottom = bottom_border(panel.width);
queue_box_border(
writer,
&label_display,
panel.width,
label_width,
panel.column,
panel.row,
)?;
queue_box_content_with_cursor(writer, &window, is_hint, panel.column, panel.row + 1)?;
queue_box_border_plain(writer, &bottom, panel.column, panel.row + 2)?;
Ok(BOX_ROW_COUNT)
}
}
#[cfg(not(tarpaulin_include))]
impl Component for TextEntryComponent {
fn render<W: Write>(&self, renderer: &mut Renderer<W>) -> std::io::Result<u16> {
if !self.visible || self.open <= 0.0 {
return Ok(0);
}
let terminal_width = renderer.ctx().terminal_size.width;
renderer.with_panel(|writer, panel, rng| {
if self.open < 1.0 {
return self.render_partial_open(writer, panel.width, panel.row, terminal_width);
}
self.render_fully_open(writer, panel, rng)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout::Size;
#[test]
fn hidden_is_invisible_with_zero_progress() {
let te = TextEntryComponent::hidden();
assert!(!te.visible);
assert_eq!(te.open, 0.0);
assert_eq!(te.label_reveal, 0.0);
assert_eq!(te.content_reveal, 0.0);
assert!(te.label.is_empty());
assert!(te.content.is_empty());
}
#[test]
fn prompt_starts_visible_with_zero_progress() {
let te = TextEntryComponent::prompt("NAME", "default");
assert!(te.visible);
assert_eq!(te.open, 0.0);
assert_eq!(te.label, "NAME");
assert_eq!(te.content, "default");
}
#[test]
fn tick_decode_noop_when_invisible() {
let mut te = TextEntryComponent::hidden();
te.tick_decode(1.0, 0.1, 0.1);
assert_eq!(te.open, 0.0);
}
#[test]
fn tick_decode_noop_below_question_gate() {
let mut te = TextEntryComponent::prompt("X", "");
te.tick_decode(0.49, 0.1, 0.1);
assert_eq!(te.open, 0.0);
}
#[test]
fn tick_decode_opens_then_reveals() {
let mut te = TextEntryComponent::prompt("X", "val");
te.tick_decode(0.6, 0.5, 0.3);
assert!(te.open > 0.0);
assert_eq!(
te.label_reveal, 0.0,
"label shouldn't reveal until fully open"
);
te.open = 1.0;
te.tick_decode(0.6, 0.5, 0.3);
assert!(te.label_reveal > 0.0);
assert!(te.content_reveal > 0.0);
}
#[test]
fn tick_decode_clamps_at_one() {
let mut te = TextEntryComponent::prompt("X", "");
te.open = 1.0;
te.tick_decode(1.0, 1.0, 2.0);
assert_eq!(te.open, 1.0);
assert_eq!(te.label_reveal, 1.0);
assert_eq!(te.content_reveal, 1.0);
}
#[test]
fn tick_transition_encode_noop_when_invisible() {
let mut te = TextEntryComponent::hidden();
te.tick_transition_encode(true, 0.1, 0.1);
assert_eq!(te.label_reveal, 0.0);
}
#[test]
fn tick_transition_encode_fades_text_then_closes() {
let mut te = TextEntryComponent::prompt("X", "v");
te.open = 1.0;
te.label_reveal = 1.0;
te.content_reveal = 1.0;
te.tick_transition_encode(false, 0.5, 0.0);
assert!(te.label_reveal < 1.0);
assert!(te.content_reveal < 1.0);
te.label_reveal = 0.0;
te.content_reveal = 0.0;
te.tick_transition_encode(false, 0.5, 0.4);
assert!(te.open < 1.0);
}
#[test]
fn tick_transition_encode_keeps_box_when_next_has_prompt() {
let mut te = TextEntryComponent::prompt("X", "");
te.open = 1.0;
te.label_reveal = 0.0;
te.content_reveal = 0.0;
te.tick_transition_encode(true, 0.5, 0.5);
assert_eq!(
te.open, 1.0,
"box stays open when next step has a prompt box"
);
}
#[test]
fn tick_transition_decode_noop_when_invisible() {
let mut te = TextEntryComponent::hidden();
te.tick_transition_decode(0.1, 0.1);
assert_eq!(te.open, 0.0);
}
#[test]
fn tick_transition_decode_opens_then_reveals() {
let mut te = TextEntryComponent::prompt("X", "v");
te.open = 0.0;
te.tick_transition_decode(0.3, 0.5);
assert!(te.open > 0.0);
assert_eq!(te.label_reveal, 0.0);
te.open = 1.0;
te.tick_transition_decode(0.3, 0.5);
assert!(te.label_reveal > 0.0);
assert!(te.content_reveal > 0.0);
}
#[test]
fn tick_exit_close_noop_when_invisible() {
let mut te = TextEntryComponent::hidden();
te.tick_exit_close(0.1, 0.1);
assert_eq!(te.open, 0.0);
}
#[test]
fn tick_exit_close_fades_text_then_closes_box() {
let mut te = TextEntryComponent::prompt("X", "v");
te.open = 1.0;
te.label_reveal = 1.0;
te.content_reveal = 1.0;
te.tick_exit_close(0.5, 0.5);
assert!(te.label_reveal < 1.0);
assert_eq!(te.open, 1.0, "box stays while text is visible");
te.label_reveal = 0.0;
te.content_reveal = 0.0;
te.tick_exit_close(0.5, 0.5);
assert!(te.open < 1.0);
}
#[test]
fn is_fully_ready_requires_all_conditions() {
let mut te = TextEntryComponent::prompt("X", "v");
assert!(!te.is_fully_ready());
te.open = 1.0;
te.label_reveal = 1.0;
te.content_reveal = 1.0;
assert!(te.is_fully_ready());
te.visible = false;
assert!(!te.is_fully_ready());
}
#[test]
fn cursor_column_none_when_not_ready() {
let te = TextEntryComponent::prompt("X", "v");
assert!(te.cursor_column(80).is_none());
}
#[test]
fn cursor_column_some_when_fully_ready() {
let mut te = TextEntryComponent::prompt("X", "v");
te.open = 1.0;
te.label_reveal = 1.0;
te.content_reveal = 1.0;
assert!(te.cursor_column(80).is_some());
}
#[test]
fn compute_layout_caches_and_layout_retrieves() {
let mut te = TextEntryComponent::prompt("X", "v");
assert!(te.layout().is_none());
let size = Size {
width: 80,
height: 24,
};
te.compute_layout(size);
let layout = te.layout().expect("layout should be cached");
assert!(layout.inner_width > 0);
}
#[test]
fn input_buf_accessors() {
let mut te = TextEntryComponent::prompt("X", "");
assert!(input_buffer::text(te.input_buf()).is_empty());
input_buffer::insert_char(te.input_buf_mut(), 'a');
assert_eq!(input_buffer::text(te.input_buf()), "a");
te.reset_input_buf();
assert!(input_buffer::text(te.input_buf()).is_empty());
}
}