use std::io::Write;
use crossterm::{
cursor::{MoveTo, SetCursorStyle, Show},
queue,
style::{Attribute, Color, Print, ResetColor, SetAttribute, SetForegroundColor},
terminal::{Clear, ClearType},
};
use crate::box_chrome;
use crate::layout::{Position, Size, bottom_third, centered_block_origin, centered_column, size};
const MIN_INNER_WIDTH: u16 = 20;
const MAX_INNER_WIDTH: u16 = 60;
const BOX_HEIGHT: u16 = 6;
const INNER_WIDTH_PERCENT: u16 = 30;
#[derive(Debug, Clone)]
pub struct TextEntry {
pub label: String,
pub hint: String,
pub inner_width: u16,
}
#[derive(Debug, Clone)]
pub struct TextEntryLayout {
pub question_row: u16,
pub box_top_row: u16,
pub content_row: u16,
pub box_bottom_row: u16,
pub hint_row: u16,
pub box_column: u16,
pub inner_width: u16,
}
pub fn text_entry(label: &str, hint: &str, terminal_width: u16) -> TextEntry {
let proportion = terminal_width.saturating_mul(INNER_WIDTH_PERCENT) / 100;
let inner = proportion.clamp(MIN_INNER_WIDTH, MAX_INNER_WIDTH);
TextEntry {
label: label.to_string(),
hint: hint.to_string(),
inner_width: inner,
}
}
pub fn text_entry_layout(entry: &TextEntry, terminal_size: Size) -> TextEntryLayout {
let region = bottom_third(terminal_size);
let outer_width = entry.inner_width.saturating_add(box_chrome::CHROME_WIDTH);
let block_size = size(outer_width, BOX_HEIGHT);
let origin = centered_block_origin(region, block_size);
TextEntryLayout {
question_row: origin.row,
box_top_row: origin.row.saturating_add(1),
content_row: origin.row.saturating_add(2),
box_bottom_row: origin.row.saturating_add(3),
hint_row: origin.row.saturating_add(4),
box_column: origin.column,
inner_width: entry.inner_width,
}
}
pub fn visible_content(entry: &TextEntry, value: &str) -> String {
let width = entry.inner_width as usize;
if value.is_empty() {
return truncate_or_pad(&entry.hint, width);
}
if value.len() <= width {
return pad_right(value, width);
}
let visible = &value[value.len() - (width - 1)..];
format!("\u{2026}{}", pad_right(visible, width - 1))
}
pub struct VisibleWindow {
pub text: String,
pub cursor_col: usize,
pub selection: Option<(usize, usize)>,
}
pub fn visible_window(
entry: &TextEntry,
value: &str,
cursor: usize,
selection: Option<(usize, usize)>,
) -> VisibleWindow {
let width = entry.inner_width as usize;
let chars: Vec<char> = value.chars().collect();
let len = chars.len();
if value.is_empty() {
return VisibleWindow {
text: truncate_or_pad(&entry.hint, width),
cursor_col: 0,
selection: None,
};
}
if len <= width {
return VisibleWindow {
text: pad_right(value, width),
cursor_col: cursor.min(len),
selection: clamp_selection(selection, 0, len),
};
}
let (start, end) = compute_window(cursor, len, width);
let window_text: String = chars[start..end].iter().collect();
let display = if start > 0 {
let trimmed: String = chars[start + 1..end].iter().collect();
format!("\u{2026}{}", pad_right(&trimmed, width - 1))
} else {
pad_right(&window_text, width)
};
let cursor_col = cursor.saturating_sub(start);
let sel = selection.map(|(s, e)| {
let ws = s.saturating_sub(start).min(width);
let we = e.saturating_sub(start).min(width);
(ws, we)
});
VisibleWindow {
text: display,
cursor_col,
selection: sel.filter(|(s, e)| s != e),
}
}
fn compute_window(cursor: usize, len: usize, width: usize) -> (usize, usize) {
if cursor <= width / 2 {
(0, width.min(len))
} else if cursor >= len.saturating_sub(width / 2) {
let start = len.saturating_sub(width);
(start, len)
} else {
let start = cursor.saturating_sub(width / 2);
(start, (start + width).min(len))
}
}
fn clamp_selection(sel: Option<(usize, usize)>, min: usize, max: usize) -> Option<(usize, usize)> {
sel
.map(|(s, e)| (s.max(min).min(max), e.max(min).min(max)))
.filter(|(s, e)| s != e)
}
pub fn top_border(entry: &TextEntry) -> String {
let label_display = format!(" {} ", entry.label);
let label_chars = label_display.chars().count();
let fill_count = (entry.inner_width as usize)
.saturating_add(box_chrome::INNER_PADDING)
.saturating_sub(label_chars);
let fill: String = box_chrome::HORIZONTAL.repeat(fill_count);
format!(
"{}{label_display}{fill}{}",
box_chrome::CORNER_TOP_LEFT,
box_chrome::CORNER_TOP_RIGHT
)
}
pub fn bottom_border(inner_width: u16) -> String {
box_chrome::bottom_border(inner_width)
}
pub fn content_line(visible: &str) -> String {
format!(
"{} {visible} {}",
box_chrome::VERTICAL,
box_chrome::VERTICAL
)
}
fn truncate_or_pad(text: &str, width: usize) -> String {
if text.len() <= width {
return pad_right(text, width);
}
let visible = &text[..width];
visible.to_string()
}
fn pad_right(text: &str, width: usize) -> String {
format!("{:<width$}", text, width = width)
}
#[cfg(not(tarpaulin_include))]
pub fn queue_question<W: Write>(writer: &mut W, text: &str, pos: Position) -> std::io::Result<()> {
queue!(
writer,
MoveTo(pos.column, pos.row),
Clear(ClearType::CurrentLine),
SetForegroundColor(Color::White),
SetAttribute(Attribute::Bold),
Print(text),
SetAttribute(Attribute::Reset),
ResetColor
)
}
#[cfg(not(tarpaulin_include))]
pub fn queue_text_entry<W: Write>(
writer: &mut W,
layout: &TextEntryLayout,
entry: &TextEntry,
value: &str,
label_width: u16,
) -> std::io::Result<()> {
let visible = visible_content(entry, value);
let is_hint = value.is_empty();
let bottom = bottom_border(layout.inner_width);
queue_box_border(
writer,
&entry.label,
layout.inner_width,
label_width,
layout.box_column,
layout.box_top_row,
)?;
queue_box_content(
writer,
&visible,
is_hint,
layout.box_column,
layout.content_row,
)?;
queue_box_border_plain(writer, &bottom, layout.box_column, layout.box_bottom_row)
}
#[cfg(not(tarpaulin_include))]
pub fn queue_cursor<W: Write>(
writer: &mut W,
layout: &TextEntryLayout,
cursor_col: u16,
) -> std::io::Result<()> {
let abs_col = layout.box_column + box_chrome::CONTENT_OFFSET + cursor_col;
queue!(
writer,
MoveTo(abs_col, layout.content_row),
Show,
SetCursorStyle::BlinkingBlock
)
}
#[cfg(not(tarpaulin_include))]
#[allow(clippy::too_many_arguments)]
pub fn queue_text_entry_with_cursor<W: Write>(
writer: &mut W,
layout: &TextEntryLayout,
entry: &TextEntry,
value: &str,
label_width: u16,
cursor: usize,
selection: Option<(usize, usize)>,
) -> std::io::Result<u16> {
let window = visible_window(entry, value, cursor, selection);
let cursor_col = window.cursor_col as u16;
let is_hint = value.is_empty();
let bottom = bottom_border(layout.inner_width);
queue_box_border(
writer,
&entry.label,
layout.inner_width,
label_width,
layout.box_column,
layout.box_top_row,
)?;
queue_box_content_with_cursor(
writer,
&window,
is_hint,
layout.box_column,
layout.content_row,
)?;
queue_box_border_plain(writer, &bottom, layout.box_column, layout.box_bottom_row)?;
Ok(cursor_col)
}
#[cfg(not(tarpaulin_include))]
pub(crate) fn queue_box_content_with_cursor<W: Write>(
writer: &mut W,
window: &VisibleWindow,
is_hint: bool,
column: u16,
row: u16,
) -> std::io::Result<()> {
queue!(
writer,
MoveTo(column, row),
Clear(ClearType::CurrentLine),
SetForegroundColor(Color::DarkGrey),
Print(format!("{} ", box_chrome::VERTICAL))
)?;
if let Some((sel_start, sel_end)) = window.selection {
let chars: Vec<char> = window.text.chars().collect();
let before: String = chars[..sel_start].iter().collect();
let selected: String = chars[sel_start..sel_end].iter().collect();
let after: String = chars[sel_end..].iter().collect();
let content_color = if is_hint {
Color::DarkGrey
} else {
Color::White
};
queue!(
writer,
SetForegroundColor(content_color),
Print(&before),
SetAttribute(Attribute::Reverse),
Print(&selected),
SetAttribute(Attribute::Reset),
SetForegroundColor(content_color),
Print(&after)
)?;
} else {
let content_color = if is_hint {
Color::DarkGrey
} else {
Color::White
};
queue!(
writer,
SetForegroundColor(content_color),
Print(&window.text)
)?;
}
queue!(
writer,
SetForegroundColor(Color::DarkGrey),
Print(" │"),
ResetColor
)
}
#[cfg(not(tarpaulin_include))]
pub(crate) fn queue_box_border<W: Write>(
writer: &mut W,
label: &str,
inner_width: u16,
label_width: u16,
column: u16,
row: u16,
) -> std::io::Result<()> {
if label.is_empty() {
queue_box_border_plain(writer, &box_chrome::top_border(inner_width), column, row)
} else {
queue_labeled_border(writer, label, inner_width, label_width, column, row)
}
}
#[cfg(not(tarpaulin_include))]
fn queue_labeled_border<W: Write>(
writer: &mut W,
label: &str,
inner_width: u16,
label_width: u16,
column: u16,
row: u16,
) -> std::io::Result<()> {
let total_fill = inner_width as usize + box_chrome::INNER_PADDING;
let label_display = format!(" {label} ");
let label_display_chars = label_display.chars().count();
let left_fill_count =
(label_width as usize + box_chrome::INNER_PADDING).saturating_sub(label_display_chars);
let left_fill: String = box_chrome::HORIZONTAL.repeat(left_fill_count);
let right_fill_count = total_fill.saturating_sub(left_fill_count + label_display_chars);
let right_fill: String = box_chrome::HORIZONTAL.repeat(right_fill_count);
queue!(
writer,
MoveTo(column, row),
Clear(ClearType::CurrentLine),
SetForegroundColor(Color::DarkGrey),
Print(box_chrome::CORNER_TOP_LEFT),
Print(&left_fill),
SetForegroundColor(Color::Cyan),
SetAttribute(Attribute::Bold),
Print(&label_display),
SetAttribute(Attribute::Reset),
SetForegroundColor(Color::DarkGrey),
Print(&right_fill),
Print(box_chrome::CORNER_TOP_RIGHT),
ResetColor
)
}
#[cfg(not(tarpaulin_include))]
pub(crate) fn queue_box_content<W: Write>(
writer: &mut W,
visible: &str,
is_hint: bool,
column: u16,
row: u16,
) -> std::io::Result<()> {
let content_color = if is_hint {
Color::DarkGrey
} else {
Color::White
};
queue!(
writer,
MoveTo(column, row),
Clear(ClearType::CurrentLine),
SetForegroundColor(Color::DarkGrey),
Print(format!("{} ", box_chrome::VERTICAL)),
SetForegroundColor(content_color),
Print(visible),
SetForegroundColor(Color::DarkGrey),
Print(format!(" {}", box_chrome::VERTICAL)),
ResetColor
)
}
#[cfg(not(tarpaulin_include))]
pub(crate) fn queue_box_border_plain<W: Write>(
writer: &mut W,
border: &str,
column: u16,
row: u16,
) -> std::io::Result<()> {
queue!(
writer,
MoveTo(column, row),
Clear(ClearType::CurrentLine),
SetForegroundColor(Color::DarkGrey),
Print(border),
ResetColor
)
}
#[cfg(not(tarpaulin_include))]
pub fn queue_hint_line<W: Write>(
writer: &mut W,
text: &str,
terminal_width: u16,
row: u16,
color: Color,
) -> std::io::Result<()> {
let col = centered_column(terminal_width, text.len() as u16);
queue!(
writer,
MoveTo(col, row),
Clear(ClearType::CurrentLine),
SetForegroundColor(color),
Print(text),
ResetColor
)
}
#[cfg(not(tarpaulin_include))]
pub fn queue_clear_prompt<W: Write>(
writer: &mut W,
layout: &TextEntryLayout,
) -> std::io::Result<()> {
queue!(
writer,
MoveTo(0, layout.question_row),
Clear(ClearType::CurrentLine),
MoveTo(0, layout.box_top_row),
Clear(ClearType::CurrentLine),
MoveTo(0, layout.content_row),
Clear(ClearType::CurrentLine),
MoveTo(0, layout.box_bottom_row),
Clear(ClearType::CurrentLine),
MoveTo(0, layout.hint_row),
Clear(ClearType::CurrentLine)
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_entry_clamps_inner_width() {
let narrow = text_entry("X", "hint", 30);
assert!(narrow.inner_width >= MIN_INNER_WIDTH);
let wide = text_entry("X", "hint", 200);
assert!(wide.inner_width <= MAX_INNER_WIDTH);
}
#[test]
fn text_entry_layout_places_rows_sequentially() {
let ib = text_entry("VAR", "default", 80);
let layout = text_entry_layout(&ib, size(80, 30));
assert_eq!(layout.box_top_row, layout.question_row + 1);
assert_eq!(layout.content_row, layout.box_top_row + 1);
assert_eq!(layout.box_bottom_row, layout.content_row + 1);
assert_eq!(layout.hint_row, layout.box_bottom_row + 1);
}
#[test]
fn text_entry_layout_centers_horizontally() {
let ib = text_entry("VAR", "default", 120);
let layout = text_entry_layout(&ib, size(120, 30));
assert!(layout.box_column > 0);
}
#[test]
fn visible_content_shows_hint_when_empty() {
let ib = text_entry("X", "~/.config/blizz", 80);
let content = visible_content(&ib, "");
assert!(content.starts_with("~/.config/blizz"));
}
#[test]
fn visible_content_shows_value_when_present() {
let ib = text_entry("X", "~/.config/blizz", 80);
let content = visible_content(&ib, "/home");
assert!(content.starts_with("/home"));
}
#[test]
fn visible_content_truncates_long_input_with_ellipsis() {
let ib = TextEntry {
label: "X".to_string(),
hint: "hint".to_string(),
inner_width: 10,
};
let content = visible_content(&ib, "abcdefghijklmnop");
assert!(content.starts_with('\u{2026}'));
assert_eq!(content.chars().count(), 10);
assert!(content.contains("hijklmnop"));
}
#[test]
fn visible_content_pads_short_input() {
let ib = TextEntry {
label: "X".to_string(),
hint: "hint".to_string(),
inner_width: 20,
};
let content = visible_content(&ib, "hi");
assert_eq!(content.len(), 20);
assert!(content.starts_with("hi"));
}
#[test]
fn top_border_includes_label_and_corners() {
let ib = text_entry("BLIZZ_HOME", "hint", 80);
let border = top_border(&ib);
assert!(border.starts_with("╭"));
assert!(border.ends_with("╮"));
assert!(border.contains("BLIZZ_HOME"));
}
#[test]
fn bottom_border_has_correct_width() {
let border = bottom_border(20);
assert!(border.starts_with("╰"));
assert!(border.ends_with("╯"));
}
#[test]
fn content_line_wraps_with_box_chars() {
let line = content_line("hello world ");
assert!(line.starts_with("│ "));
assert!(line.ends_with(" │"));
}
#[test]
fn visible_content_uses_hint_as_placeholder() {
let ib = TextEntry {
label: "X".to_string(),
hint: "type here".to_string(),
inner_width: 20,
};
let content = visible_content(&ib, "");
assert!(content.contains("type here"));
assert_eq!(content.len(), 20);
}
#[test]
fn top_border_with_empty_label_still_has_corners() {
let ib = text_entry("", "hint", 80);
let border = top_border(&ib);
assert!(border.starts_with("╭"));
assert!(border.ends_with("╮"));
}
fn ib(width: u16) -> TextEntry {
TextEntry {
label: "X".into(),
hint: "hint".into(),
inner_width: width,
}
}
#[test]
fn visible_window_empty_input_shows_hint() {
let w = visible_window(&ib(20), "", 0, None);
assert!(w.text.contains("hint"));
assert_eq!(w.cursor_col, 0);
assert!(w.selection.is_none());
}
#[test]
fn visible_window_short_input_fits_fully() {
let w = visible_window(&ib(20), "hello", 5, None);
assert!(w.text.starts_with("hello"));
assert_eq!(w.text.chars().count(), 20);
assert_eq!(w.cursor_col, 5);
assert!(w.selection.is_none());
}
#[test]
fn visible_window_short_input_with_selection() {
let w = visible_window(&ib(20), "hello", 3, Some((1, 4)));
assert_eq!(w.selection, Some((1, 4)));
}
#[test]
fn visible_window_short_input_clamps_cursor() {
let w = visible_window(&ib(20), "hi", 99, None);
assert_eq!(w.cursor_col, 2);
}
#[test]
fn visible_window_long_input_cursor_near_start() {
let input = "abcdefghijklmnopqrstuvwxyz";
let w = visible_window(&ib(10), input, 2, None);
assert_eq!(w.text.chars().count(), 10);
assert_eq!(w.cursor_col, 2);
}
#[test]
fn visible_window_long_input_cursor_near_end() {
let input = "abcdefghijklmnopqrstuvwxyz";
let w = visible_window(&ib(10), input, 24, None);
assert_eq!(w.text.chars().count(), 10);
}
#[test]
fn visible_window_long_input_cursor_in_middle_shows_ellipsis() {
let input = "abcdefghijklmnopqrstuvwxyz";
let w = visible_window(&ib(10), input, 13, None);
assert_eq!(w.text.chars().count(), 10);
assert!(w.text.starts_with('\u{2026}'));
}
#[test]
fn visible_window_long_input_with_selection() {
let input = "abcdefghijklmnopqrstuvwxyz";
let w = visible_window(&ib(10), input, 13, Some((10, 16)));
assert!(w.selection.is_some());
let (s, e) = w.selection.unwrap();
assert!(s < e);
}
#[test]
fn visible_window_selection_collapsed_is_none() {
let input = "abcdefghijklmnopqrstuvwxyz";
let w = visible_window(&ib(10), input, 2, Some((5, 5)));
assert!(w.selection.is_none());
}
#[test]
fn compute_window_cursor_at_start() {
let (start, end) = compute_window(2, 30, 10);
assert_eq!(start, 0);
assert_eq!(end, 10);
}
#[test]
fn compute_window_cursor_at_end() {
let (start, end) = compute_window(28, 30, 10);
assert_eq!(start, 20);
assert_eq!(end, 30);
}
#[test]
fn compute_window_cursor_in_middle() {
let (start, end) = compute_window(15, 30, 10);
assert_eq!(start, 10);
assert_eq!(end, 20);
}
#[test]
fn clamp_selection_within_range() {
assert_eq!(clamp_selection(Some((2, 5)), 0, 10), Some((2, 5)));
}
#[test]
fn clamp_selection_clamps_to_bounds() {
assert_eq!(clamp_selection(Some((0, 15)), 2, 10), Some((2, 10)));
}
#[test]
fn clamp_selection_collapsed_is_none() {
assert_eq!(clamp_selection(Some((5, 5)), 0, 10), None);
}
#[test]
fn clamp_selection_none_input() {
assert_eq!(clamp_selection(None, 0, 10), None);
}
#[cfg(not(tarpaulin_include))]
#[test]
fn queue_question_writes_text() {
let mut buffer = Vec::new();
let pos = Position { column: 0, row: 0 };
queue_question(&mut buffer, "Where to install?", pos).unwrap();
let output = String::from_utf8(buffer).unwrap();
assert!(output.contains("Where to install?"));
}
#[cfg(not(tarpaulin_include))]
#[test]
fn queue_text_entry_writes_all_parts() {
let mut buffer = Vec::new();
let ib = text_entry("VAR", "default", 80);
let layout = text_entry_layout(&ib, size(80, 30));
queue_text_entry(
&mut buffer,
&layout,
&ib,
"",
ib.label.chars().count() as u16,
)
.unwrap();
let output = String::from_utf8(buffer).unwrap();
assert!(output.contains("VAR"));
}
#[cfg(not(tarpaulin_include))]
#[test]
fn queue_clear_prompt_clears_all_rows() {
let mut buffer = Vec::new();
let ib = text_entry("VAR", "default", 80);
let layout = text_entry_layout(&ib, size(80, 30));
queue_clear_prompt(&mut buffer, &layout).unwrap();
assert!(!buffer.is_empty());
}
}