use std::collections::VecDeque;
use ratatui::prelude::*;
use ratatui::widgets::Paragraph;
use crate::app::App;
use crate::theme::hex_to_color;
const MAX_HISTORY: usize = 100;
pub struct SpellCorrection {
pub word_start: usize,
pub word_end: usize,
pub original: String,
pub suggestions: Vec<String>,
pub index: usize,
pub highlight_only: bool,
}
#[expect(
dead_code,
reason = "fields accessed via pub struct in app.rs tab completion"
)]
pub struct TabCompletionState {
pub prefix: String,
pub matches: Vec<String>,
pub index: usize,
pub text_before: String,
pub is_start_of_line: bool,
pub is_command: bool,
}
pub struct InputState {
pub value: String,
pub cursor_pos: usize,
pub tab_state: Option<TabCompletionState>,
pub spell_state: Option<SpellCorrection>,
pub history: VecDeque<String>,
pub history_index: Option<usize>,
pub saved_input: Option<String>,
}
impl InputState {
pub const fn new() -> Self {
Self {
value: String::new(),
cursor_pos: 0,
tab_state: None,
spell_state: None,
history: VecDeque::new(),
history_index: None,
saved_input: None,
}
}
pub fn insert_char(&mut self, c: char) {
if c.is_control() {
return;
}
self.spell_state = None;
self.value.insert(self.cursor_pos, c);
self.cursor_pos += c.len_utf8();
self.tab_state = None;
}
pub fn backspace(&mut self) {
if self.cursor_pos > 0 {
let prev = self.value[..self.cursor_pos]
.char_indices()
.last()
.map_or(0, |(i, _)| i);
self.value.drain(prev..self.cursor_pos);
self.cursor_pos = prev;
}
self.tab_state = None;
}
pub fn delete(&mut self) {
if self.cursor_pos < self.value.len() {
let next = self.value[self.cursor_pos..]
.char_indices()
.nth(1)
.map_or(self.value.len(), |(i, _)| self.cursor_pos + i);
self.value.drain(self.cursor_pos..next);
}
self.tab_state = None;
}
pub fn move_left(&mut self) {
if self.cursor_pos > 0 {
self.cursor_pos = self.value[..self.cursor_pos]
.char_indices()
.last()
.map_or(0, |(i, _)| i);
}
self.tab_state = None;
}
pub fn move_right(&mut self) {
if self.cursor_pos < self.value.len() {
self.cursor_pos = self.value[self.cursor_pos..]
.char_indices()
.nth(1)
.map_or(self.value.len(), |(i, _)| self.cursor_pos + i);
}
self.tab_state = None;
}
pub fn home(&mut self) {
self.cursor_pos = 0;
self.tab_state = None;
}
pub fn end(&mut self) {
self.cursor_pos = self.value.len();
self.tab_state = None;
}
pub fn clear_to_start(&mut self) {
if self.cursor_pos > 0 {
self.value.drain(..self.cursor_pos);
self.cursor_pos = 0;
}
self.tab_state = None;
}
pub fn clear_to_end(&mut self) {
if self.cursor_pos < self.value.len() {
self.value.truncate(self.cursor_pos);
}
self.tab_state = None;
}
pub fn delete_word_back(&mut self) {
if self.cursor_pos == 0 {
return;
}
let before = &self.value[..self.cursor_pos];
let trimmed_end = before.trim_end().len();
let word_start = before[..trimmed_end]
.rfind(char::is_whitespace)
.map_or(0, |i| i + 1);
self.value.drain(word_start..self.cursor_pos);
self.cursor_pos = word_start;
self.tab_state = None;
}
pub fn clear(&mut self) -> String {
self.cursor_pos = 0;
self.tab_state = None;
std::mem::take(&mut self.value)
}
pub fn submit(&mut self) -> String {
let val = self.clear();
if !val.is_empty() {
self.history.push_back(val.clone());
if self.history.len() > MAX_HISTORY {
self.history.pop_front();
}
}
self.history_index = None;
self.saved_input = None;
val
}
pub fn history_up(&mut self) {
if self.history.is_empty() {
return;
}
match self.history_index {
None => {
self.saved_input = Some(self.value.clone());
let idx = self.history.len() - 1;
self.history_index = Some(idx);
self.value.clone_from(&self.history[idx]);
self.cursor_pos = self.value.len();
}
Some(idx) if idx > 0 => {
let new_idx = idx - 1;
self.history_index = Some(new_idx);
self.value.clone_from(&self.history[new_idx]);
self.cursor_pos = self.value.len();
}
_ => {}
}
self.tab_state = None;
}
pub fn history_down(&mut self) {
match self.history_index {
Some(idx) if idx + 1 < self.history.len() => {
let new_idx = idx + 1;
self.history_index = Some(new_idx);
self.value.clone_from(&self.history[new_idx]);
self.cursor_pos = self.value.len();
}
Some(_) => {
self.history_index = None;
if let Some(saved) = self.saved_input.take() {
self.value = saved;
} else {
self.value.clear();
}
self.cursor_pos = self.value.len();
}
None => {}
}
self.tab_state = None;
}
pub fn dismiss_spell(&mut self) {
if let Some(spell) = self.spell_state.take() {
if spell.highlight_only {
return;
}
let current_word_len = spell.word_end - spell.word_start;
let original_len = spell.original.len();
self.value
.replace_range(spell.word_start..spell.word_end, &spell.original);
if self.cursor_pos > spell.word_start {
self.cursor_pos = (self.cursor_pos + original_len).saturating_sub(current_word_len);
}
}
}
pub fn apply_spell_suggestion(&mut self, index: usize) {
let Some(spell) = self.spell_state.as_mut() else {
return;
};
if index >= spell.suggestions.len() {
return;
}
let replacement = spell.suggestions[index].clone();
let old_len = spell.word_end - spell.word_start;
let new_len = replacement.len();
self.value
.replace_range(spell.word_start..spell.word_end, &replacement);
spell.word_end = spell.word_start + new_len;
if self.cursor_pos > spell.word_start {
self.cursor_pos = (self.cursor_pos + new_len).saturating_sub(old_len);
}
spell.index = index;
}
pub fn cycle_spell_suggestion(&mut self) -> bool {
let Some(spell) = self.spell_state.as_ref() else {
return false;
};
if spell.suggestions.is_empty() {
return false;
}
let next = (spell.index + 1) % spell.suggestions.len();
self.apply_spell_suggestion(next);
true
}
pub fn accept_spell_with_punctuation(&mut self, punct: char) {
let Some(spell) = self.spell_state.take() else {
return;
};
let after_word = spell.word_end;
if after_word < self.value.len() {
let next_end = self.value[after_word..]
.char_indices()
.nth(1)
.map_or(self.value.len(), |(i, _)| after_word + i);
self.value.drain(after_word..next_end);
if self.cursor_pos > after_word {
self.cursor_pos -= next_end - after_word;
}
}
self.value.insert(after_word, punct);
self.cursor_pos = after_word + punct.len_utf8();
}
pub fn is_command(&self) -> bool {
self.value.starts_with('/')
}
pub fn last_completed_word(&self) -> Option<(usize, usize, String)> {
let before_cursor = &self.value[..self.cursor_pos];
let trimmed = before_cursor.trim_end();
if trimmed.is_empty() {
return None;
}
let word_end = trimmed.len();
let word_start = trimmed.rfind(char::is_whitespace).map_or(0, |i| {
trimmed[i..]
.char_indices()
.nth(1)
.map_or(trimmed.len(), |(offset, _)| i + offset)
});
let word = trimmed[word_start..word_end].to_string();
if word.is_empty() {
return None;
}
Some((word_start, word_end, word))
}
pub fn tab_complete(
&mut self,
nicks: &[String],
last_speakers: &[String],
commands: &[&str],
setting_paths: &[String],
) {
if let Some(ref mut tab) = self.tab_state {
if tab.matches.is_empty() {
return;
}
tab.index = (tab.index + 1) % tab.matches.len();
let completion = &tab.matches[tab.index];
let suffix = if tab.is_command {
" ".to_string()
} else if tab.is_start_of_line {
": ".to_string()
} else {
" ".to_string()
};
self.value = format!("{}{completion}{suffix}", tab.text_before);
self.cursor_pos = self.value.len();
} else {
let text = self.value[..self.cursor_pos].to_string();
let (text_before, word) = match text.rfind(' ') {
Some(pos) => (text[..=pos].to_string(), text[pos + 1..].to_string()),
None => (String::new(), text),
};
if word.is_empty() {
return;
}
let is_start_of_line = text_before.is_empty();
let is_command = is_start_of_line && word.starts_with('/');
let subcommand_ctx = detect_subcommand_context(&text_before);
let prefix = word;
let matches: Vec<String> = match subcommand_ctx {
Some(SubcommandContext::Help) => {
let mut m: Vec<String> = commands
.iter()
.filter(|c| c.to_lowercase().starts_with(&prefix.to_lowercase()))
.map(ToString::to_string)
.collect();
m.sort_by_key(|a| a.to_lowercase());
m
}
Some(SubcommandContext::Set) => {
let mut m: Vec<String> = setting_paths
.iter()
.filter(|p| p.to_lowercase().starts_with(&prefix.to_lowercase()))
.cloned()
.collect();
m.sort_by_key(|a| a.to_lowercase());
m
}
Some(SubcommandContext::Subcommand(ref subcmds)) => {
let mut m: Vec<String> = subcmds
.iter()
.filter(|s| s.to_lowercase().starts_with(&prefix.to_lowercase()))
.cloned()
.collect();
m.sort_by_key(|a| a.to_lowercase());
m
}
None if is_command => {
let cmd_prefix = &prefix[1..]; let mut m: Vec<String> = commands
.iter()
.filter(|c| c.to_lowercase().starts_with(&cmd_prefix.to_lowercase()))
.map(|c| format!("/{c}"))
.collect();
m.sort_by_key(|a| a.to_lowercase());
m
}
None => {
nick_completions(nicks, last_speakers, &prefix)
}
};
if matches.is_empty() {
return;
}
let completion = &matches[0];
let suffix = if is_command {
" "
} else if is_start_of_line {
": "
} else {
" "
};
self.value = format!("{text_before}{completion}{suffix}");
self.cursor_pos = self.value.len();
self.tab_state = Some(TabCompletionState {
prefix,
matches,
index: 0,
text_before,
is_start_of_line,
is_command,
});
}
}
}
fn nick_completions(nicks: &[String], last_speakers: &[String], prefix: &str) -> Vec<String> {
let prefix_lower = prefix.to_lowercase();
let mut recent: Vec<String> = last_speakers
.iter()
.filter(|n| n.to_lowercase().starts_with(&prefix_lower))
.cloned()
.collect();
let recent_lower: Vec<String> = recent.iter().map(|n| n.to_lowercase()).collect();
let mut rest: Vec<String> = nicks
.iter()
.filter(|n| {
let lower = n.to_lowercase();
lower.starts_with(&prefix_lower) && !recent_lower.contains(&lower)
})
.cloned()
.collect();
rest.sort_by_key(|a| a.to_lowercase());
recent.extend(rest);
recent
}
enum SubcommandContext {
Help,
Set,
Subcommand(Vec<String>),
}
fn detect_subcommand_context(text_before: &str) -> Option<SubcommandContext> {
let trimmed = text_before.trim();
let lower = trimmed.to_lowercase();
if lower == "/help" || lower == "/?" {
return Some(SubcommandContext::Help);
}
if lower == "/set" {
return Some(SubcommandContext::Set);
}
if let Some(cmd) = lower.strip_prefix('/')
&& !cmd.contains(' ')
{
let names = crate::commands::docs::get_subcommand_names(cmd);
if !names.is_empty() {
return Some(SubcommandContext::Subcommand(
names.into_iter().map(String::from).collect(),
));
}
}
None
}
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
let colors = &app.theme.colors;
let fg_muted = hex_to_color(&colors.fg_muted).unwrap_or(Color::DarkGray);
let fg = hex_to_color(&colors.fg).unwrap_or(Color::Reset);
let active_buf = app.state.active_buffer();
let conn = active_buf.and_then(|b| app.state.connections.get(&b.connection_id));
let server_label = conn.map_or("", |c| c.label.as_str());
let channel_name = active_buf.map_or("", |b| b.name.as_str());
let nick = conn.map_or("", |c| c.nick.as_str());
let prompt = app
.config
.statusbar
.prompt
.replace("$server", server_label)
.replace("$channel", channel_name)
.replace("$nick", nick);
let prompt_width = prompt.chars().count();
let available_width = (area.width as usize).saturating_sub(prompt_width);
let cursor_char_pos = app.input.value[..app.input.cursor_pos].chars().count();
let scroll_offset = if available_width == 0 {
0
} else if cursor_char_pos >= available_width {
cursor_char_pos - available_width + 1
} else {
0
};
let byte_indices: Vec<usize> = app.input.value.char_indices().map(|(i, _)| i).collect();
let total_chars = byte_indices.len();
let string_len = app.input.value.len();
let visible_end = (scroll_offset + available_width).min(total_chars);
let byte_at = |char_idx: usize| -> usize {
if char_idx >= total_chars {
string_len
} else {
byte_indices[char_idx]
}
};
let visible_start_byte = byte_at(scroll_offset);
let cursor_byte = byte_at(cursor_char_pos);
let visible_end_byte = byte_at(visible_end);
let cursor_char = app.input.value[cursor_byte..].chars().next().unwrap_or(' ');
let cursor_end_byte = cursor_byte + cursor_char.len_utf8().min(string_len - cursor_byte);
let cursor_color = hex_to_color(&colors.cursor).unwrap_or(Color::Reset);
let normal_style = Style::default().fg(fg);
let cursor_style = Style::default().fg(Color::Black).bg(cursor_color);
let spell_style = Style::default()
.fg(Color::Red)
.add_modifier(Modifier::UNDERLINED);
let mut spans = vec![Span::styled(prompt, Style::default().fg(fg_muted))];
if let Some(ref spell) = app.input.spell_state {
build_spell_spans(
&mut spans,
&app.input.value,
visible_start_byte,
cursor_byte,
cursor_end_byte,
visible_end_byte,
spell.word_start,
spell.word_end,
normal_style,
cursor_style,
spell_style,
);
} else {
let before_cursor = &app.input.value[visible_start_byte..cursor_byte];
let after_cursor = if cursor_end_byte < visible_end_byte {
&app.input.value[cursor_end_byte..visible_end_byte]
} else {
""
};
spans.push(Span::styled(before_cursor, normal_style));
spans.push(Span::styled(cursor_char.to_string(), cursor_style));
spans.push(Span::styled(after_cursor, normal_style));
}
let line = Line::from(spans);
let paragraph = Paragraph::new(line);
frame.render_widget(paragraph, area);
}
#[expect(
clippy::too_many_arguments,
reason = "span builder needs all byte boundaries"
)]
fn build_spell_spans<'a>(
spans: &mut Vec<Span<'a>>,
text: &'a str,
vis_start: usize,
cursor_byte: usize,
cursor_end: usize,
vis_end: usize,
spell_start: usize,
spell_end: usize,
normal: Style,
cursor: Style,
spell: Style,
) {
let sp_start = spell_start.max(vis_start);
let sp_end = spell_end.min(vis_end);
if sp_start >= sp_end {
spans.push(Span::styled(&text[vis_start..cursor_byte], normal));
spans.push(Span::styled(
text[cursor_byte..cursor_end].to_string(),
cursor,
));
if cursor_end < vis_end {
spans.push(Span::styled(&text[cursor_end..vis_end], normal));
}
return;
}
let mut pos = vis_start;
let regions = [
(vis_start, sp_start, normal),
(sp_start, sp_end, spell),
(sp_end, vis_end, normal),
];
for (region_start, region_end, style) in regions {
if region_start >= region_end || pos >= vis_end {
continue;
}
let start = pos.max(region_start);
let end = region_end;
if cursor_byte >= start && cursor_byte < end {
if start < cursor_byte {
spans.push(Span::styled(&text[start..cursor_byte], style));
}
let combined_cursor = if style == spell {
cursor.add_modifier(Modifier::UNDERLINED)
} else {
cursor
};
spans.push(Span::styled(
text[cursor_byte..cursor_end].to_string(),
combined_cursor,
));
if cursor_end < end {
spans.push(Span::styled(&text[cursor_end..end], style));
}
} else if start < end {
spans.push(Span::styled(&text[start..end], style));
}
pos = end;
}
}
pub fn render_spell_popup(frame: &mut Frame, input_area: Rect, app: &App) {
use ratatui::widgets::{Block, Borders, Clear};
let Some(ref spell) = app.input.spell_state else {
return;
};
if spell.suggestions.is_empty() {
return;
}
let colors = &app.theme.colors;
let bg_alt = hex_to_color(&colors.bg_alt).unwrap_or(Color::DarkGray);
let fg = hex_to_color(&colors.fg).unwrap_or(Color::White);
let accent = hex_to_color(&colors.accent).unwrap_or(Color::Yellow);
let fg_muted = hex_to_color(&colors.fg_muted).unwrap_or(Color::DarkGray);
let mut suggestion_spans: Vec<Span<'_>> = Vec::new();
suggestion_spans.push(Span::styled(" ", Style::default().bg(bg_alt)));
for (i, s) in spell.suggestions.iter().enumerate() {
if i > 0 {
suggestion_spans.push(Span::styled(
" │ ",
Style::default().fg(fg_muted).bg(bg_alt),
));
}
let style = if !spell.highlight_only && i == spell.index {
Style::default().fg(Color::Black).bg(accent)
} else {
Style::default().fg(fg).bg(bg_alt)
};
suggestion_spans.push(Span::styled(s.as_str(), style));
}
suggestion_spans.push(Span::styled(" ", Style::default().bg(bg_alt)));
let content_width: usize = suggestion_spans.iter().map(Span::width).sum();
#[expect(
clippy::cast_possible_truncation,
reason = "clamped to input_area.width which is u16"
)]
let popup_width = (content_width + 2).min(input_area.width as usize) as u16; let popup_height = 3_u16;
let popup_y = input_area.y.saturating_sub(popup_height);
let popup_x = if spell.highlight_only {
(input_area.x + input_area.width).saturating_sub(popup_width)
} else {
input_area.x
};
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
frame.render_widget(Clear, popup_area);
let title_spans = if spell.highlight_only {
vec![
Span::styled(" spell ", Style::default().fg(accent).bg(bg_alt)),
Span::styled(
"any key",
Style::default()
.fg(fg)
.bg(bg_alt)
.add_modifier(Modifier::BOLD),
),
Span::styled("=dismiss ", Style::default().fg(fg_muted).bg(bg_alt)),
]
} else {
vec![
Span::styled(" spell ", Style::default().fg(accent).bg(bg_alt)),
Span::styled(
"Tab",
Style::default()
.fg(fg)
.bg(bg_alt)
.add_modifier(Modifier::BOLD),
),
Span::styled("=cycle ", Style::default().fg(fg_muted).bg(bg_alt)),
Span::styled(
"Esc",
Style::default()
.fg(fg)
.bg(bg_alt)
.add_modifier(Modifier::BOLD),
),
Span::styled("=cancel ", Style::default().fg(fg_muted).bg(bg_alt)),
]
};
let block = Block::default()
.title(Line::from(title_spans))
.borders(Borders::ALL)
.border_style(Style::default().fg(fg_muted).bg(bg_alt))
.style(Style::default().bg(bg_alt));
let inner = block.inner(popup_area);
frame.render_widget(block, popup_area);
let suggestion_line = Line::from(suggestion_spans);
let paragraph = Paragraph::new(suggestion_line);
frame.render_widget(paragraph, inner);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn insert_and_backspace() {
let mut input = InputState::new();
input.insert_char('h');
input.insert_char('i');
assert_eq!(input.value, "hi");
assert_eq!(input.cursor_pos, 2);
input.backspace();
assert_eq!(input.value, "h");
assert_eq!(input.cursor_pos, 1);
}
#[test]
fn move_cursor() {
let mut input = InputState::new();
input.insert_char('a');
input.insert_char('b');
input.insert_char('c');
input.move_left();
assert_eq!(input.cursor_pos, 2);
input.move_left();
assert_eq!(input.cursor_pos, 1);
input.insert_char('X');
assert_eq!(input.value, "aXbc");
}
#[test]
fn home_and_end() {
let mut input = InputState::new();
input.insert_char('a');
input.insert_char('b');
input.home();
assert_eq!(input.cursor_pos, 0);
input.end();
assert_eq!(input.cursor_pos, 2);
}
#[test]
fn delete_at_cursor() {
let mut input = InputState::new();
input.insert_char('a');
input.insert_char('b');
input.insert_char('c');
input.home();
input.delete();
assert_eq!(input.value, "bc");
}
#[test]
fn clear_returns_value() {
let mut input = InputState::new();
input.insert_char('t');
input.insert_char('e');
input.insert_char('s');
input.insert_char('t');
let val = input.clear();
assert_eq!(val, "test");
assert_eq!(input.value, "");
assert_eq!(input.cursor_pos, 0);
}
#[test]
fn history_push_and_navigate() {
let mut input = InputState::new();
input.value = "first".to_string();
input.cursor_pos = 5;
input.submit();
input.value = "second".to_string();
input.cursor_pos = 6;
input.submit();
assert_eq!(
input.history,
VecDeque::from(["first".to_string(), "second".to_string()])
);
input.value = "current".to_string();
input.cursor_pos = 7;
input.history_up();
assert_eq!(input.value, "second");
assert_eq!(input.history_index, Some(1));
input.history_up();
assert_eq!(input.value, "first");
assert_eq!(input.history_index, Some(0));
input.history_up();
assert_eq!(input.value, "first");
assert_eq!(input.history_index, Some(0));
}
#[test]
fn history_saved_input_restoration() {
let mut input = InputState::new();
input.value = "cmd1".to_string();
input.cursor_pos = 4;
input.submit();
input.value = "typing...".to_string();
input.cursor_pos = 9;
input.history_up();
assert_eq!(input.value, "cmd1");
input.history_down();
assert_eq!(input.value, "typing...");
assert!(input.history_index.is_none());
assert!(input.saved_input.is_none());
}
#[test]
fn history_empty_input_not_pushed() {
let mut input = InputState::new();
input.submit();
assert!(input.history.is_empty());
}
#[test]
fn nick_completion_at_start_of_line() {
let mut input = InputState::new();
input.value = "fer".to_string();
input.cursor_pos = 3;
let nicks = vec!["ferris".to_string(), "helper".to_string()];
input.tab_complete(&nicks, &[], &[], &[]);
assert_eq!(input.value, "ferris: ");
assert_eq!(input.cursor_pos, 8);
}
#[test]
fn nick_completion_mid_line() {
let mut input = InputState::new();
input.value = "hey fer".to_string();
input.cursor_pos = 7;
let nicks = vec!["ferris".to_string(), "helper".to_string()];
input.tab_complete(&nicks, &[], &[], &[]);
assert_eq!(input.value, "hey ferris ");
assert_eq!(input.cursor_pos, 11);
}
#[test]
fn nick_completion_cycling() {
let mut input = InputState::new();
input.value = "h".to_string();
input.cursor_pos = 1;
let nicks = vec!["helper".to_string(), "hank".to_string(), "hiro".to_string()];
input.tab_complete(&nicks, &[], &[], &[]);
assert_eq!(input.value, "hank: ");
input.tab_complete(&nicks, &[], &[], &[]);
assert_eq!(input.value, "helper: ");
input.tab_complete(&nicks, &[], &[], &[]);
assert_eq!(input.value, "hiro: ");
input.tab_complete(&nicks, &[], &[], &[]);
assert_eq!(input.value, "hank: ");
}
#[test]
fn command_completion() {
let mut input = InputState::new();
input.value = "/jo".to_string();
input.cursor_pos = 3;
let commands = &["join", "part", "msg", "quit"];
input.tab_complete(&[], &[], commands, &[]);
assert_eq!(input.value, "/join ");
}
#[test]
fn help_subcommand_completion() {
let mut input = InputState::new();
input.value = "/help cl".to_string();
input.cursor_pos = 8;
let commands = &["connect", "close", "clear", "quit"];
input.tab_complete(&[], &[], commands, &[]);
assert_eq!(input.value, "/help clear ");
}
#[test]
fn help_subcommand_cycling() {
let mut input = InputState::new();
input.value = "/help c".to_string();
input.cursor_pos = 7;
let commands = &["connect", "close", "clear"];
input.tab_complete(&[], &[], commands, &[]);
assert_eq!(input.value, "/help clear ");
input.tab_complete(&[], &[], commands, &[]);
assert_eq!(input.value, "/help close ");
input.tab_complete(&[], &[], commands, &[]);
assert_eq!(input.value, "/help connect ");
}
#[test]
fn set_path_completion() {
let mut input = InputState::new();
input.value = "/set general.ni".to_string();
input.cursor_pos = 15;
let settings = vec!["general.nick".to_string(), "general.username".to_string()];
input.tab_complete(&[], &[], &[], &settings);
assert_eq!(input.value, "/set general.nick ");
}
#[test]
fn set_path_completion_section() {
let mut input = InputState::new();
input.value = "/set dis".to_string();
input.cursor_pos = 8;
let settings = vec![
"display.nick_column_width".to_string(),
"display.show_timestamps".to_string(),
];
input.tab_complete(&[], &[], &[], &settings);
assert_eq!(input.value, "/set display.nick_column_width ");
}
#[test]
fn clear_to_start() {
let mut input = InputState::new();
input.value = "hello world".to_string();
input.cursor_pos = 5;
input.clear_to_start();
assert_eq!(input.value, " world");
assert_eq!(input.cursor_pos, 0);
}
#[test]
fn clear_to_start_at_beginning() {
let mut input = InputState::new();
input.value = "hello".to_string();
input.cursor_pos = 0;
input.clear_to_start();
assert_eq!(input.value, "hello");
assert_eq!(input.cursor_pos, 0);
}
#[test]
fn clear_to_end() {
let mut input = InputState::new();
input.value = "hello world".to_string();
input.cursor_pos = 5;
input.clear_to_end();
assert_eq!(input.value, "hello");
assert_eq!(input.cursor_pos, 5);
}
#[test]
fn clear_to_end_at_end() {
let mut input = InputState::new();
input.value = "hello".to_string();
input.cursor_pos = 5;
input.clear_to_end();
assert_eq!(input.value, "hello");
assert_eq!(input.cursor_pos, 5);
}
#[test]
fn delete_word_back_single_word() {
let mut input = InputState::new();
input.value = "hello".to_string();
input.cursor_pos = 5;
input.delete_word_back();
assert_eq!(input.value, "");
assert_eq!(input.cursor_pos, 0);
}
#[test]
fn delete_word_back_multiple_words() {
let mut input = InputState::new();
input.value = "hello world".to_string();
input.cursor_pos = 11;
input.delete_word_back();
assert_eq!(input.value, "hello ");
assert_eq!(input.cursor_pos, 6);
}
#[test]
fn delete_word_back_with_trailing_spaces() {
let mut input = InputState::new();
input.value = "hello ".to_string();
input.cursor_pos = 8;
input.delete_word_back();
assert_eq!(input.value, "");
assert_eq!(input.cursor_pos, 0);
}
#[test]
fn delete_word_back_at_start() {
let mut input = InputState::new();
input.value = "hello".to_string();
input.cursor_pos = 0;
input.delete_word_back();
assert_eq!(input.value, "hello");
assert_eq!(input.cursor_pos, 0);
}
#[test]
fn delete_word_back_mid_line() {
let mut input = InputState::new();
input.value = "one two three".to_string();
input.cursor_pos = 7; input.delete_word_back();
assert_eq!(input.value, "one three");
assert_eq!(input.cursor_pos, 4);
}
#[test]
fn tab_state_reset_on_other_key() {
let mut input = InputState::new();
input.value = "fer".to_string();
input.cursor_pos = 3;
let nicks = vec!["ferris".to_string()];
input.tab_complete(&nicks, &[], &[], &[]);
assert!(input.tab_state.is_some());
input.insert_char('x');
assert!(input.tab_state.is_none());
}
#[test]
fn nick_completion_recent_speakers_first() {
let mut input = InputState::new();
input.value = "h".to_string();
input.cursor_pos = 1;
let nicks = vec!["helper".to_string(), "hank".to_string(), "hiro".to_string()];
let last_speakers = vec!["hiro".to_string(), "hank".to_string()];
input.tab_complete(&nicks, &last_speakers, &[], &[]);
assert_eq!(input.value, "hiro: ");
input.tab_complete(&nicks, &last_speakers, &[], &[]);
assert_eq!(input.value, "hank: ");
input.tab_complete(&nicks, &last_speakers, &[], &[]);
assert_eq!(input.value, "helper: ");
input.tab_complete(&nicks, &last_speakers, &[], &[]);
assert_eq!(input.value, "hiro: ");
}
#[test]
fn nick_completion_recent_speakers_case_insensitive() {
let mut input = InputState::new();
input.value = "h".to_string();
input.cursor_pos = 1;
let nicks = vec!["Helper".to_string(), "Hank".to_string()];
let last_speakers = vec!["hank".to_string()]; input.tab_complete(&nicks, &last_speakers, &[], &[]);
assert_eq!(input.value, "hank: ");
input.tab_complete(&nicks, &last_speakers, &[], &[]);
assert_eq!(input.value, "Helper: "); }
#[test]
fn nick_completion_no_duplicates() {
let mut input = InputState::new();
input.value = "h".to_string();
input.cursor_pos = 1;
let nicks = vec!["hank".to_string(), "hiro".to_string()];
let last_speakers = vec!["hank".to_string()]; input.tab_complete(&nicks, &last_speakers, &[], &[]);
assert_eq!(input.value, "hank: ");
input.tab_complete(&nicks, &last_speakers, &[], &[]);
assert_eq!(input.value, "hiro: ");
input.tab_complete(&nicks, &last_speakers, &[], &[]);
assert_eq!(input.value, "hank: ");
}
#[test]
fn nick_completion_empty_speakers_falls_back_to_alphabetical() {
let mut input = InputState::new();
input.value = "h".to_string();
input.cursor_pos = 1;
let nicks = vec!["hiro".to_string(), "hank".to_string(), "helper".to_string()];
let last_speakers: Vec<String> = vec![];
input.tab_complete(&nicks, &last_speakers, &[], &[]);
assert_eq!(input.value, "hank: "); }
fn make_spell_state(
input: &mut InputState,
start: usize,
end: usize,
original: &str,
suggestions: &[&str],
) {
input.spell_state = Some(SpellCorrection {
word_start: start,
word_end: end,
original: original.to_string(),
suggestions: suggestions.iter().map(ToString::to_string).collect(),
index: 0,
highlight_only: false,
});
input.apply_spell_suggestion(0);
}
#[test]
fn spell_first_suggestion_pre_applied() {
let mut input = InputState::new();
input.value = "hello wrod ".to_string();
input.cursor_pos = 11;
make_spell_state(&mut input, 6, 10, "wrod", &["word", "rod", "wired"]);
assert_eq!(input.value, "hello word ");
assert_eq!(input.cursor_pos, 11);
assert_eq!(input.spell_state.as_ref().unwrap().index, 0);
}
#[test]
fn spell_cycle_advances_to_next() {
let mut input = InputState::new();
input.value = "hello wrod ".to_string();
input.cursor_pos = 11;
make_spell_state(&mut input, 6, 10, "wrod", &["word", "rod", "wired"]);
assert!(input.cycle_spell_suggestion());
assert_eq!(input.value, "hello rod ");
assert_eq!(input.spell_state.as_ref().unwrap().index, 1);
}
#[test]
fn spell_cycle_wraps_around() {
let mut input = InputState::new();
input.value = "hello wrod ".to_string();
input.cursor_pos = 11;
make_spell_state(&mut input, 6, 10, "wrod", &["word", "rod"]);
input.cycle_spell_suggestion(); assert!(input.value.contains("rod"));
input.cycle_spell_suggestion(); assert!(input.value.contains("word"));
}
#[test]
fn spell_dismiss_reverts_to_original() {
let mut input = InputState::new();
input.value = "hello wrod ".to_string();
input.cursor_pos = 11;
make_spell_state(&mut input, 6, 10, "wrod", &["word", "rod"]);
assert!(input.value.contains("word"));
input.dismiss_spell();
assert!(input.value.contains("wrod")); assert!(input.spell_state.is_none());
}
#[test]
fn spell_space_accepts_no_extra_space() {
let mut input = InputState::new();
input.value = "hello wrod ".to_string();
input.cursor_pos = 11;
make_spell_state(&mut input, 6, 10, "wrod", &["word"]);
input.spell_state = None;
assert_eq!(input.value, "hello word ");
assert_eq!(input.cursor_pos, 11);
}
#[test]
fn spell_punctuation_replaces_separator() {
let mut input = InputState::new();
input.value = "hello wrod ".to_string();
input.cursor_pos = 11;
make_spell_state(&mut input, 6, 10, "wrod", &["word"]);
input.accept_spell_with_punctuation('.');
assert_eq!(input.value, "hello word.");
assert_eq!(input.cursor_pos, 11);
}
#[test]
fn spell_punctuation_question_mark() {
let mut input = InputState::new();
input.value = "wrod ".to_string();
input.cursor_pos = 5;
make_spell_state(&mut input, 0, 4, "wrod", &["word"]);
input.accept_spell_with_punctuation('?');
assert_eq!(input.value, "word?");
}
#[test]
fn spell_insert_char_accepts_current() {
let mut input = InputState::new();
input.value = "hello wrod ".to_string();
input.cursor_pos = 11;
make_spell_state(&mut input, 6, 10, "wrod", &["word"]);
input.insert_char('x');
assert!(input.spell_state.is_none());
assert!(input.value.contains("word"));
}
#[test]
fn spell_no_suggestions_returns_false() {
let mut input = InputState::new();
input.value = "hello wrod ".to_string();
input.cursor_pos = 11;
input.spell_state = Some(SpellCorrection {
word_start: 6,
word_end: 10,
original: "wrod".to_string(),
suggestions: vec![],
index: 0,
highlight_only: false,
});
assert!(!input.cycle_spell_suggestion());
}
#[test]
fn highlight_mode_dismiss_does_not_revert() {
let mut input = InputState::new();
input.value = "hello wrod ".to_string();
input.cursor_pos = 11;
input.spell_state = Some(SpellCorrection {
word_start: 6,
word_end: 10,
original: "wrod".to_string(),
suggestions: vec!["word".to_string()],
index: 0,
highlight_only: true,
});
input.dismiss_spell();
assert_eq!(input.value, "hello wrod ");
assert!(input.spell_state.is_none());
}
#[test]
fn highlight_mode_insert_char_clears_spell() {
let mut input = InputState::new();
input.value = "hello wrod ".to_string();
input.cursor_pos = 11;
input.spell_state = Some(SpellCorrection {
word_start: 6,
word_end: 10,
original: "wrod".to_string(),
suggestions: vec!["word".to_string()],
index: 0,
highlight_only: true,
});
input.insert_char('x');
assert!(input.spell_state.is_none());
assert_eq!(input.value, "hello wrod x");
}
#[test]
fn last_completed_word_basic() {
let mut input = InputState::new();
input.value = "hello world ".to_string();
input.cursor_pos = 12;
let result = input.last_completed_word();
assert!(result.is_some());
let (start, end, word) = result.unwrap();
assert_eq!(word, "world");
assert_eq!(start, 6);
assert_eq!(end, 11);
}
#[test]
fn last_completed_word_single() {
let mut input = InputState::new();
input.value = "hello ".to_string();
input.cursor_pos = 6;
let result = input.last_completed_word();
assert!(result.is_some());
let (_, _, word) = result.unwrap();
assert_eq!(word, "hello");
}
#[test]
fn last_completed_word_empty() {
let mut input = InputState::new();
input.value = " ".to_string();
input.cursor_pos = 1;
let result = input.last_completed_word();
assert!(result.is_none());
}
#[test]
fn is_command_detects_slash() {
let mut input = InputState::new();
input.value = "/join #test".to_string();
assert!(input.is_command());
input.value = "hello world".to_string();
assert!(!input.is_command());
}
#[test]
fn spell_different_length_replacement() {
let mut input = InputState::new();
input.value = "hi wrld ".to_string();
input.cursor_pos = 8;
make_spell_state(&mut input, 3, 7, "wrld", &["world"]);
assert_eq!(input.value, "hi world ");
assert_eq!(input.cursor_pos, 9);
}
#[test]
fn spell_multibyte_utf8_replacement() {
let mut input = InputState::new();
input.value = "manana ".to_string();
input.cursor_pos = 7;
make_spell_state(&mut input, 0, 6, "manana", &["mañana"]);
assert_eq!(input.value, "mañana ");
assert_eq!(input.cursor_pos, 8); assert!(input.value.is_char_boundary(input.cursor_pos));
}
#[test]
fn spell_multibyte_dismiss_reverts_correctly() {
let mut input = InputState::new();
input.value = "manana ".to_string();
input.cursor_pos = 7;
make_spell_state(&mut input, 0, 6, "manana", &["mañana"]);
assert_eq!(input.value, "mañana ");
input.dismiss_spell();
assert_eq!(input.value, "manana ");
assert_eq!(input.cursor_pos, 7);
assert!(input.value.is_char_boundary(input.cursor_pos));
}
#[test]
fn spell_multibyte_cycle_preserves_boundaries() {
let mut input = InputState::new();
input.value = "nino ".to_string();
input.cursor_pos = 5;
make_spell_state(&mut input, 0, 4, "nino", &["niño", "nido"]);
assert_eq!(input.value, "niño ");
assert!(input.value.is_char_boundary(input.cursor_pos));
input.cycle_spell_suggestion();
assert_eq!(input.value, "nido ");
assert!(input.value.is_char_boundary(input.cursor_pos));
input.cycle_spell_suggestion();
assert_eq!(input.value, "niño ");
assert!(input.value.is_char_boundary(input.cursor_pos));
}
}