use std::ops::Range;
use crate::{Context, Window};
use super::super::InputState;
const PAIRS: [(char, char); 5] = [('(', ')'), ('[', ']'), ('{', '}'), ('"', '"'), ('\'', '\'')];
pub(crate) fn single_typed_char(text: &str) -> Option<char> {
let mut chars = text.chars();
let first = chars.next()?;
chars.next().is_none().then_some(first)
}
pub(crate) fn auto_close_applies(state: &InputState) -> bool {
if state.disabled || state.read_only || state.masked || !state.mask_pattern.is_none() {
return false;
}
match state.auto_close_pairs {
Some(enabled) => enabled,
None => state.mode.is_multi_line(),
}
}
pub(crate) fn matching_closer(open: char) -> Option<char> {
PAIRS.iter().find(|(o, _)| *o == open).map(|(_, c)| *c)
}
fn matching_opener(close: char) -> Option<char> {
PAIRS.iter().find(|(_, c)| *c == close).map(|(o, _)| *o)
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
pub(crate) fn handle_typed_char(
state: &mut InputState,
typed: char,
window: &mut Window,
cx: &mut Context<InputState>,
) -> bool {
let sel: Range<usize> = state.core.selected_range.into();
let cursor = state.cursor();
let next = state.core.text.slice(cursor..).chars().next();
if !sel.is_empty()
&& let Some(close) = matching_closer(typed)
{
let selected = state.core.text.slice(sel.clone()).to_string();
let replacement = format!("{typed}{selected}{close}");
state.replace_text_in_range_raw(Some(state.range_to_utf16(&sel)), &replacement, window, cx);
let end = sel.start + replacement.len();
state.core.selected_range = (end..end).into();
state.core.selection_reversed = false;
cx.notify();
return true;
}
if sel.is_empty() && matching_opener(typed).is_some() && next == Some(typed) {
let next_len = typed.len_utf8();
state.core.selected_range = (cursor + next_len..cursor + next_len).into();
state.core.selection_reversed = false;
cx.notify();
return true;
}
if sel.is_empty()
&& let Some(close) = matching_closer(typed)
&& !next.is_some_and(is_word_char)
{
let pair = format!("{typed}{close}");
state.replace_text_in_range_raw(
Some(state.range_to_utf16(&(cursor..cursor))),
&pair,
window,
cx,
);
let middle = cursor + typed.len_utf8();
state.core.selected_range = (middle..middle).into();
state.core.selection_reversed = false;
cx.notify();
return true;
}
false
}
pub(crate) fn smart_backspace_range(state: &InputState) -> Option<Range<usize>> {
if !auto_close_applies(state) || !state.core.selected_range.is_empty() {
return None;
}
let cursor = state.cursor();
let prev = state.core.text.slice(..cursor).chars().last()?;
let next = state.core.text.slice(cursor..).chars().next()?;
if matching_closer(prev) == Some(next) {
Some(cursor - prev.len_utf8()..cursor + next.len_utf8())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::AppContext as _;
use crate::{Entity, EntityInputHandler as _};
struct Probe {
state: Entity<InputState>,
}
impl crate::Render for Probe {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> impl crate::IntoElement {
crate::div()
}
}
macro_rules! type_text {
($state:expr, $window_cx:expr, $text:expr) => {
$window_cx.update(|window, cx| {
$state.update(cx, |state, cx| {
state.replace_text_in_range(None, $text, window, cx);
});
});
};
}
#[rgpui::test]
fn typing_opener_inserts_pair(cx: &mut crate::TestAppContext) {
cx.update(crate::input_ui::init);
cx.update(crate::theme::init);
let (probe, cx) = cx.add_window_view(|window, cx| {
let state = cx.new(|cx| InputState::new(window, cx).multi_line(true));
Probe { state }
});
let state = probe.read_with(cx, |probe, _| probe.state.clone());
type_text!(state, cx, "(");
assert_eq!(
state.read_with(cx, |state, _| (state.value().to_string(), state.cursor())),
("()".to_string(), 1)
);
type_text!(state, cx, ")");
assert_eq!(
state.read_with(cx, |state, _| (state.value().to_string(), state.cursor())),
("()".to_string(), 2)
);
}
#[rgpui::test]
fn typing_opener_surrounds_selection(cx: &mut crate::TestAppContext) {
cx.update(crate::input_ui::init);
cx.update(crate::theme::init);
let (probe, cx) = cx.add_window_view(|window, cx| {
let state = cx.new(|cx| InputState::new(window, cx).multi_line(true));
state.update(cx, |state, cx| state.replace("ab", window, cx));
Probe { state }
});
let state = probe.read_with(cx, |probe, _| probe.state.clone());
cx.update(|_, cx| {
state.update(cx, |state, cx| state.set_selected_range(0..2, cx));
});
type_text!(state, cx, "(");
assert_eq!(
state.read_with(cx, |state, _| (state.value().to_string(), state.cursor())),
("(ab)".to_string(), 4)
);
}
#[rgpui::test]
fn backspace_deletes_empty_pair(cx: &mut crate::TestAppContext) {
cx.update(crate::input_ui::init);
cx.update(crate::theme::init);
let (probe, cx) = cx.add_window_view(|window, cx| {
let state = cx.new(|cx| InputState::new(window, cx).multi_line(true));
Probe { state }
});
let state = probe.read_with(cx, |probe, _| probe.state.clone());
type_text!(state, cx, "(");
cx.update(|window, cx| {
state.update(cx, |state, cx| {
state.backspace(&crate::input_ui::Backspace, window, cx)
});
});
assert_eq!(
state.read_with(cx, |state, _| (state.value().to_string(), state.cursor())),
("".to_string(), 0)
);
}
#[rgpui::test]
fn single_line_inputs_do_not_auto_close_by_default(cx: &mut crate::TestAppContext) {
cx.update(crate::input_ui::init);
cx.update(crate::theme::init);
let (probe, cx) = cx.add_window_view(|window, cx| {
let state = cx.new(|cx| InputState::new(window, cx));
Probe { state }
});
let state = probe.read_with(cx, |probe, _| probe.state.clone());
type_text!(state, cx, "(");
assert_eq!(
state.read_with(cx, |state, _| (state.value().to_string(), state.cursor())),
("(".to_string(), 1)
);
}
#[test]
fn pair_table_is_symmetric() {
for (open, close) in PAIRS {
assert_eq!(matching_closer(open), Some(close));
assert_eq!(matching_opener(close), Some(open));
}
assert_eq!(matching_closer('a'), None);
assert_eq!(matching_opener('<'), None);
}
#[test]
fn single_char_detection() {
assert_eq!(single_typed_char("("), Some('('));
assert_eq!(single_typed_char(""), None);
assert_eq!(single_typed_char("ab"), None);
assert_eq!(single_typed_char("中"), Some('中'));
}
#[test]
fn smart_backspace_range_detection() {
assert!(is_word_char('a'));
assert!(is_word_char('中'));
assert!(is_word_char('_'));
assert!(!is_word_char(' '));
assert!(!is_word_char(')'));
}
}