use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
pub(crate) const MAX_PROMPT_VISIBLE_ROWS: u16 = 5;
pub(crate) const PROMPT_CURSOR_GLYPH: char = '█';
pub(crate) struct PromptEdit<'a> {
pub(crate) input: &'a mut String,
pub(crate) cursor: &'a mut usize,
pub(crate) target_column: &'a mut Option<usize>,
pub(crate) scroll: &'a mut usize,
pub(crate) cursor_visible: &'a mut bool,
}
pub(crate) fn prompt_text(
input: &str,
prompt_focused: bool,
prompt_cursor: usize,
prompt_cursor_visible: bool,
) -> String {
let mut prompt = input.to_string();
if prompt_focused && prompt_cursor_visible {
let cursor = clamp_char_boundary(&prompt, prompt_cursor);
prompt.insert(cursor, PROMPT_CURSOR_GLYPH);
}
prompt
}
pub(crate) fn clamp_cursor(input: &str, cursor: &mut usize) {
*cursor = clamp_char_boundary(input, *cursor);
}
pub(crate) fn move_cursor_left(
input: &str,
cursor: &mut usize,
target_column: &mut Option<usize>,
scroll: &mut usize,
cursor_visible: &mut bool,
visible_rows: u16,
wrap_width: u16,
) {
clamp_cursor(input, cursor);
if *cursor > 0 {
*cursor = input[..*cursor]
.char_indices()
.last()
.map(|(index, _)| index)
.unwrap_or(0);
*target_column = None;
ensure_cursor_visible(input, *cursor, scroll, visible_rows, wrap_width);
}
*cursor_visible = true;
}
pub(crate) fn move_cursor_right(
input: &str,
cursor: &mut usize,
target_column: &mut Option<usize>,
scroll: &mut usize,
cursor_visible: &mut bool,
visible_rows: u16,
wrap_width: u16,
) {
clamp_cursor(input, cursor);
if *cursor < input.len() {
let ch = input[*cursor..].chars().next().unwrap();
*cursor += ch.len_utf8();
*target_column = None;
ensure_cursor_visible(input, *cursor, scroll, visible_rows, wrap_width);
}
*cursor_visible = true;
}
pub(crate) fn move_cursor_vertical(
edit: &mut PromptEdit<'_>,
delta: isize,
visible_rows: u16,
wrap_width: u16,
) {
clamp_cursor(edit.input, edit.cursor);
let (row, column) = prompt_cursor_visual_position(edit.input, *edit.cursor, wrap_width);
let selected_column = edit.target_column.unwrap_or(column);
let target_row = if delta.is_negative() {
row.saturating_sub(delta.unsigned_abs())
} else {
row.saturating_add(delta as usize)
};
let max_row = prompt_input_visual_rows(edit.input, wrap_width).saturating_sub(1);
let clamped_row = target_row.min(max_row);
if clamped_row != row {
*edit.cursor = prompt_byte_index_for_visual_position(
edit.input,
clamped_row,
selected_column,
wrap_width,
);
}
*edit.target_column = Some(selected_column);
ensure_cursor_visible(
edit.input,
*edit.cursor,
edit.scroll,
visible_rows,
wrap_width,
);
*edit.cursor_visible = true;
}
pub(crate) fn insert_char(edit: &mut PromptEdit<'_>, ch: char, visible_rows: u16, wrap_width: u16) {
clamp_cursor(edit.input, edit.cursor);
edit.input.insert(*edit.cursor, ch);
*edit.cursor += ch.len_utf8();
*edit.target_column = None;
ensure_cursor_visible(
edit.input,
*edit.cursor,
edit.scroll,
visible_rows,
wrap_width,
);
*edit.cursor_visible = true;
}
pub(crate) fn insert_str(
edit: &mut PromptEdit<'_>,
text: &str,
visible_rows: u16,
wrap_width: u16,
) {
if text.is_empty() {
return;
}
clamp_cursor(edit.input, edit.cursor);
edit.input.insert_str(*edit.cursor, text);
*edit.cursor += text.len();
*edit.target_column = None;
ensure_cursor_visible(
edit.input,
*edit.cursor,
edit.scroll,
visible_rows,
wrap_width,
);
*edit.cursor_visible = true;
}
pub(crate) fn backspace_char(
input: &mut String,
cursor: &mut usize,
target_column: &mut Option<usize>,
scroll: &mut usize,
cursor_visible: &mut bool,
visible_rows: u16,
wrap_width: u16,
) {
clamp_cursor(input, cursor);
if *cursor > 0 {
let previous = input[..*cursor]
.char_indices()
.last()
.map(|(index, _)| index)
.unwrap_or(0);
input.drain(previous..*cursor);
*cursor = previous;
*target_column = None;
}
clamp_scroll(
prompt_text(input, true, *cursor, *cursor_visible).as_str(),
scroll,
visible_rows,
wrap_width,
);
ensure_cursor_visible(input, *cursor, scroll, visible_rows, wrap_width);
*cursor_visible = true;
}
pub(crate) fn delete_char(
input: &mut String,
cursor: &mut usize,
target_column: &mut Option<usize>,
scroll: &mut usize,
cursor_visible: &mut bool,
visible_rows: u16,
wrap_width: u16,
) {
clamp_cursor(input, cursor);
if *cursor < input.len() {
let next = *cursor + input[*cursor..].chars().next().unwrap().len_utf8();
input.drain(*cursor..next);
*target_column = None;
}
clamp_scroll(
prompt_text(input, true, *cursor, *cursor_visible).as_str(),
scroll,
visible_rows,
wrap_width,
);
ensure_cursor_visible(input, *cursor, scroll, visible_rows, wrap_width);
*cursor_visible = true;
}
pub(crate) fn reset_input(
input: &mut String,
cursor: &mut usize,
scroll: &mut usize,
target_column: &mut Option<usize>,
cursor_visible: &mut bool,
) {
input.clear();
*cursor = 0;
*scroll = 0;
*target_column = None;
*cursor_visible = true;
}
pub(crate) fn take_for_submit(
input: &mut String,
cursor: &mut usize,
scroll: &mut usize,
target_column: &mut Option<usize>,
cursor_visible: &mut bool,
) -> String {
let prompt = std::mem::take(input);
*cursor = 0;
*scroll = 0;
*target_column = None;
*cursor_visible = true;
prompt
}
pub(crate) fn scroll_up_by(
prompt_text: &str,
scroll: &mut usize,
rows: u16,
visible_rows: u16,
wrap_width: u16,
) -> bool {
let before = render_scroll(prompt_text, *scroll, visible_rows, wrap_width);
*scroll = scroll.saturating_sub(usize::from(rows));
clamp_scroll(prompt_text, scroll, visible_rows, wrap_width);
render_scroll(prompt_text, *scroll, visible_rows, wrap_width) != before
}
pub(crate) fn scroll_down_by(
prompt_text: &str,
scroll: &mut usize,
rows: u16,
visible_rows: u16,
wrap_width: u16,
) -> bool {
let before = render_scroll(prompt_text, *scroll, visible_rows, wrap_width);
let overflow = scroll_overflow(prompt_text, visible_rows, wrap_width);
*scroll = scroll.saturating_add(usize::from(rows)).min(overflow);
render_scroll(prompt_text, *scroll, visible_rows, wrap_width) != before
}
pub(crate) fn scroll_overflow(prompt_text: &str, visible_rows: u16, wrap_width: u16) -> usize {
visual_rows(prompt_text, wrap_width).saturating_sub(usize::from(visible_rows.max(1)))
}
pub(crate) fn ensure_cursor_visible(
input: &str,
cursor: usize,
scroll: &mut usize,
visible_rows: u16,
wrap_width: u16,
) {
let (cursor_row, _) = prompt_cursor_visual_position(input, cursor, wrap_width);
let visible_rows = usize::from(visible_rows.max(1));
let top = *scroll;
let new_top = if cursor_row < top {
cursor_row
} else if cursor_row >= top.saturating_add(visible_rows) {
cursor_row.saturating_sub(visible_rows.saturating_sub(1))
} else {
top
};
*scroll = new_top;
let visible_rows = visible_rows as u16;
let text = prompt_text(input, true, cursor, true);
clamp_scroll(&text, scroll, visible_rows, wrap_width);
}
fn clamp_scroll(prompt_text: &str, scroll: &mut usize, visible_rows: u16, wrap_width: u16) {
*scroll = (*scroll).min(scroll_overflow(prompt_text, visible_rows, wrap_width));
}
pub(crate) fn visible_rows(prompt_text: &str, wrap_width: u16) -> u16 {
usize_to_u16_saturating(visual_rows(prompt_text, wrap_width)).clamp(1, MAX_PROMPT_VISIBLE_ROWS)
}
pub(crate) fn render_scroll(
prompt_text: &str,
scroll: usize,
visible_rows: u16,
wrap_width: u16,
) -> u16 {
usize_to_u16_saturating(scroll.min(scroll_overflow(prompt_text, visible_rows, wrap_width)))
}
pub(crate) fn visual_rows(prompt_text: &str, wrap_width: u16) -> usize {
wrapped_visual_rows(prompt_text, wrap_width)
}
pub(crate) fn clamp_char_boundary(text: &str, index: usize) -> usize {
let index = index.min(text.len());
if text.is_char_boundary(index) {
return index;
}
text.char_indices()
.map(|(byte, _)| byte)
.take_while(|byte| *byte < index)
.last()
.unwrap_or(0)
}
#[derive(Debug, Clone, Copy)]
struct PromptVisualRow {
start: usize,
end: usize,
columns: usize,
}
fn prompt_input_visual_rows(input: &str, wrap_width: u16) -> usize {
prompt_visual_row_ranges(input, wrap_width).len()
}
fn prompt_cursor_visual_position(input: &str, cursor: usize, wrap_width: u16) -> (usize, usize) {
let cursor = clamp_char_boundary(input, cursor);
let rows = prompt_visual_row_ranges(input, wrap_width);
for (row_index, row) in rows.iter().enumerate() {
if cursor >= row.start && cursor <= row.end {
let column = display_width(&input[row.start..cursor]).min(row.columns);
return (row_index, column);
}
}
rows.last()
.map(|row| (rows.len().saturating_sub(1), row.columns))
.unwrap_or((0, 0))
}
pub(crate) fn prompt_byte_index_for_visual_position(
input: &str,
row: usize,
column: usize,
wrap_width: u16,
) -> usize {
byte_index_for_visual_position(input, row, column, wrap_width)
}
pub(crate) fn prompt_byte_index_for_visual_position_with_cursor(
input: &str,
row: usize,
column: usize,
wrap_width: u16,
cursor: Option<usize>,
) -> usize {
let Some(cursor) = cursor.map(|cursor| clamp_char_boundary(input, cursor)) else {
return prompt_byte_index_for_visual_position(input, row, column, wrap_width);
};
let mut rendered = String::with_capacity(input.len() + PROMPT_CURSOR_GLYPH.len_utf8());
rendered.push_str(&input[..cursor]);
rendered.push(PROMPT_CURSOR_GLYPH);
rendered.push_str(&input[cursor..]);
let rendered_byte = byte_index_for_visual_position(&rendered, row, column, wrap_width);
let cursor_end = cursor + PROMPT_CURSOR_GLYPH.len_utf8();
if rendered_byte <= cursor {
rendered_byte
} else if rendered_byte <= cursor_end {
cursor
} else {
clamp_char_boundary(
input,
rendered_byte.saturating_sub(PROMPT_CURSOR_GLYPH.len_utf8()),
)
}
}
fn byte_index_for_visual_position(
input: &str,
row: usize,
column: usize,
wrap_width: u16,
) -> usize {
let rows = prompt_visual_row_ranges(input, wrap_width);
let selected = rows
.get(row)
.or_else(|| rows.last())
.copied()
.unwrap_or(PromptVisualRow {
start: 0,
end: 0,
columns: 0,
});
let target = column.min(selected.columns);
if target == 0 {
return selected.start;
}
let mut width = 0usize;
for (offset, ch) in input[selected.start..selected.end].char_indices() {
let next_width = width.saturating_add(char_display_width(ch));
if next_width > target {
return selected.start + offset;
}
width = next_width;
if width == target {
return selected.start + offset + ch.len_utf8();
}
}
selected.end
}
fn prompt_visual_row_ranges(input: &str, wrap_width: u16) -> Vec<PromptVisualRow> {
let wrap_width = usize::from(wrap_width.max(1));
let mut rows = Vec::new();
let mut line_start = 0usize;
let mut saw_segment = false;
for segment in input.split_inclusive('\n') {
saw_segment = true;
let line = segment.strip_suffix('\n').unwrap_or(segment);
push_prompt_line_rows(
input,
line_start,
line_start + line.len(),
wrap_width,
&mut rows,
);
line_start += segment.len();
if segment.ends_with('\n') && line_start == input.len() {
rows.push(PromptVisualRow {
start: line_start,
end: line_start,
columns: 0,
});
}
}
if !saw_segment {
rows.push(PromptVisualRow {
start: 0,
end: 0,
columns: 0,
});
} else if line_start < input.len() {
push_prompt_line_rows(input, line_start, input.len(), wrap_width, &mut rows);
}
rows
}
fn push_prompt_line_rows(
input: &str,
line_start: usize,
line_end: usize,
wrap_width: usize,
rows: &mut Vec<PromptVisualRow>,
) {
if line_start == line_end {
rows.push(PromptVisualRow {
start: line_start,
end: line_end,
columns: 0,
});
return;
}
let mut row_start = line_start;
let mut row_width = 0usize;
for (offset, ch) in input[line_start..line_end].char_indices() {
let byte = line_start + offset;
let ch_width = char_display_width(ch);
if row_width > 0 && row_width.saturating_add(ch_width) > wrap_width {
rows.push(PromptVisualRow {
start: row_start,
end: byte,
columns: row_width,
});
row_start = byte;
row_width = 0;
}
row_width = row_width.saturating_add(ch_width).min(wrap_width);
}
rows.push(PromptVisualRow {
start: row_start,
end: line_end,
columns: row_width,
});
}
fn wrapped_visual_rows(text: &str, wrap_width: u16) -> usize {
if text.is_empty() {
return 0;
}
let wrap_width = usize::from(wrap_width.max(1));
text.split('\n')
.map(|line| display_width(line).div_ceil(wrap_width).max(1))
.sum()
}
pub(crate) fn clamped_byte_index(input: &str, byte: usize) -> usize {
clamp_char_boundary(input, byte)
}
fn display_width(text: &str) -> usize {
UnicodeWidthStr::width(text)
}
fn char_display_width(ch: char) -> usize {
UnicodeWidthChar::width(ch).unwrap_or(0)
}
fn usize_to_u16_saturating(value: usize) -> u16 {
u16::try_from(value).unwrap_or(u16::MAX)
}