Alexandre Pasmantier 54399e3777
refactor(screen): extract UI related code to separate crate (#106)
Co-authored-by: Bertrand Chardon <bertrand.chardon@doctrine.fr>
2024-12-08 13:46:30 +01:00

575 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// Input requests are used to change the input state.
///
/// Different backends can be used to convert events into requests.
#[allow(clippy::module_name_repetitions)]
#[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Copy, Hash)]
pub enum InputRequest {
SetCursor(usize),
InsertChar(char),
GoToPrevChar,
GoToNextChar,
GoToPrevWord,
GoToNextWord,
GoToStart,
GoToEnd,
DeletePrevChar,
DeleteNextChar,
DeletePrevWord,
DeleteNextWord,
DeleteLine,
DeleteTillEnd,
}
#[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Copy, Hash)]
pub struct StateChanged {
pub value: bool,
pub cursor: bool,
}
#[allow(clippy::module_name_repetitions)]
pub type InputResponse = Option<StateChanged>;
/// An input buffer with cursor support.
#[derive(Default, Debug, Clone)]
pub struct Input {
value: String,
cursor: usize,
}
impl Input {
/// Initialize a new instance with a given value
/// Cursor will be set to the given value's length.
pub fn new(value: String) -> Self {
let len = value.chars().count();
Self { value, cursor: len }
}
/// Set the value manually.
/// Cursor will be set to the given value's length.
pub fn with_value(mut self, value: String) -> Self {
self.cursor = value.chars().count();
self.value = value;
self
}
/// Set the cursor manually.
/// If the input is larger than the value length, it'll be auto adjusted.
pub fn with_cursor(mut self, cursor: usize) -> Self {
self.cursor = cursor.min(self.value.chars().count());
self
}
// Reset the cursor and value to default
pub fn reset(&mut self) {
self.cursor = Default::default();
self.value = String::default();
}
/// Handle request and emit response.
#[allow(clippy::too_many_lines)]
pub fn handle(&mut self, req: InputRequest) -> InputResponse {
use InputRequest::{
DeleteLine, DeleteNextChar, DeleteNextWord, DeletePrevChar,
DeletePrevWord, DeleteTillEnd, GoToEnd, GoToNextChar,
GoToNextWord, GoToPrevChar, GoToPrevWord, GoToStart, InsertChar,
SetCursor,
};
match req {
SetCursor(pos) => {
let pos = pos.min(self.value.chars().count());
if self.cursor == pos {
None
} else {
self.cursor = pos;
Some(StateChanged {
value: false,
cursor: true,
})
}
}
InsertChar(c) => {
if self.cursor == self.value.chars().count() {
self.value.push(c);
} else {
self.value = self
.value
.chars()
.take(self.cursor)
.chain(
std::iter::once(c)
.chain(self.value.chars().skip(self.cursor)),
)
.collect();
}
self.cursor += 1;
Some(StateChanged {
value: true,
cursor: true,
})
}
DeletePrevChar => {
if self.cursor == 0 {
None
} else {
self.cursor -= 1;
self.value = self
.value
.chars()
.enumerate()
.filter(|(i, _)| i != &self.cursor)
.map(|(_, c)| c)
.collect();
Some(StateChanged {
value: true,
cursor: true,
})
}
}
DeleteNextChar => {
if self.cursor == self.value.chars().count() {
None
} else {
self.value = self
.value
.chars()
.enumerate()
.filter(|(i, _)| i != &self.cursor)
.map(|(_, c)| c)
.collect();
Some(StateChanged {
value: true,
cursor: false,
})
}
}
GoToPrevChar => {
if self.cursor == 0 {
None
} else {
self.cursor -= 1;
Some(StateChanged {
value: false,
cursor: true,
})
}
}
GoToPrevWord => {
if self.cursor == 0 {
None
} else {
self.cursor = self
.value
.chars()
.rev()
.skip(
self.value.chars().count().max(self.cursor)
- self.cursor,
)
.skip_while(|c| !c.is_alphanumeric())
.skip_while(|c| c.is_alphanumeric())
.count();
Some(StateChanged {
value: false,
cursor: true,
})
}
}
GoToNextChar => {
if self.cursor == self.value.chars().count() {
None
} else {
self.cursor += 1;
Some(StateChanged {
value: false,
cursor: true,
})
}
}
GoToNextWord => {
if self.cursor == self.value.chars().count() {
None
} else {
self.cursor = self
.value
.chars()
.enumerate()
.skip(self.cursor)
.skip_while(|(_, c)| c.is_alphanumeric())
.find(|(_, c)| c.is_alphanumeric())
.map(|(i, _)| i)
.unwrap_or_else(|| self.value.chars().count());
Some(StateChanged {
value: false,
cursor: true,
})
}
}
DeleteLine => {
if self.value.is_empty() {
None
} else {
let cursor = self.cursor;
self.value = "".into();
self.cursor = 0;
Some(StateChanged {
value: true,
cursor: self.cursor == cursor,
})
}
}
DeletePrevWord => {
if self.cursor == 0 {
None
} else {
let remaining = self.value.chars().skip(self.cursor);
let rev = self
.value
.chars()
.rev()
.skip(
self.value.chars().count().max(self.cursor)
- self.cursor,
)
.skip_while(|c| !c.is_alphanumeric())
.skip_while(|c| c.is_alphanumeric())
.collect::<Vec<char>>();
let rev_len = rev.len();
self.value =
rev.into_iter().rev().chain(remaining).collect();
self.cursor = rev_len;
Some(StateChanged {
value: true,
cursor: true,
})
}
}
DeleteNextWord => {
if self.cursor == self.value.chars().count() {
None
} else {
self.value = self
.value
.chars()
.take(self.cursor)
.chain(
self.value
.chars()
.skip(self.cursor)
.skip_while(|c| c.is_alphanumeric())
.skip_while(|c| !c.is_alphanumeric()),
)
.collect();
Some(StateChanged {
value: true,
cursor: false,
})
}
}
GoToStart => {
if self.cursor == 0 {
None
} else {
self.cursor = 0;
Some(StateChanged {
value: false,
cursor: true,
})
}
}
GoToEnd => {
let count = self.value.chars().count();
if self.cursor == count {
None
} else {
self.cursor = count;
Some(StateChanged {
value: false,
cursor: true,
})
}
}
DeleteTillEnd => {
self.value = self.value.chars().take(self.cursor).collect();
Some(StateChanged {
value: true,
cursor: false,
})
}
}
}
/// Get a reference to the current value.
pub fn value(&self) -> &str {
self.value.as_str()
}
/// Get the correct cursor placement.
pub fn cursor(&self) -> usize {
self.cursor
}
/// Get the current cursor position with account for multi space characters.
pub fn visual_cursor(&self) -> usize {
if self.cursor == 0 {
return 0;
}
// Safe, because the end index will always be within bounds
unicode_width::UnicodeWidthStr::width(unsafe {
self.value.get_unchecked(
0..self
.value
.char_indices()
.nth(self.cursor)
.map_or_else(|| self.value.len(), |(index, _)| index),
)
})
}
/// Get the scroll position with account for multi space characters.
pub fn visual_scroll(&self, width: usize) -> usize {
let scroll = self.visual_cursor().max(width) - width;
let mut uscroll = 0;
let mut chars = self.value().chars();
while uscroll < scroll {
match chars.next() {
Some(c) => {
uscroll +=
unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
}
None => break,
}
}
uscroll
}
}
impl From<Input> for String {
fn from(input: Input) -> Self {
input.value
}
}
impl From<String> for Input {
fn from(value: String) -> Self {
Self::new(value)
}
}
impl From<&str> for Input {
fn from(value: &str) -> Self {
Self::new(value.into())
}
}
impl std::fmt::Display for Input {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.value.fmt(f)
}
}
#[cfg(test)]
mod tests {
const TEXT: &str = "first second, third.";
use super::*;
#[test]
fn format() {
let input: Input = TEXT.into();
println!("{}", input);
println!("{}", input);
}
#[test]
fn set_cursor() {
let mut input: Input = TEXT.into();
let req = InputRequest::SetCursor(3);
let resp = input.handle(req);
assert_eq!(
resp,
Some(StateChanged {
value: false,
cursor: true,
})
);
assert_eq!(input.value(), "first second, third.");
assert_eq!(input.cursor(), 3);
let req = InputRequest::SetCursor(30);
let resp = input.handle(req);
assert_eq!(input.cursor(), TEXT.chars().count());
assert_eq!(
resp,
Some(StateChanged {
value: false,
cursor: true,
})
);
let req = InputRequest::SetCursor(TEXT.chars().count());
let resp = input.handle(req);
assert_eq!(input.cursor(), TEXT.chars().count());
assert_eq!(resp, None);
}
#[test]
fn insert_char() {
let mut input: Input = TEXT.into();
let req = InputRequest::InsertChar('x');
let resp = input.handle(req);
assert_eq!(
resp,
Some(StateChanged {
value: true,
cursor: true,
})
);
assert_eq!(input.value(), "first second, third.x");
assert_eq!(input.cursor(), TEXT.chars().count() + 1);
input.handle(req);
assert_eq!(input.value(), "first second, third.xx");
assert_eq!(input.cursor(), TEXT.chars().count() + 2);
let mut input = input.with_cursor(3);
input.handle(req);
assert_eq!(input.value(), "firxst second, third.xx");
assert_eq!(input.cursor(), 4);
input.handle(req);
assert_eq!(input.value(), "firxxst second, third.xx");
assert_eq!(input.cursor(), 5);
}
#[test]
fn go_to_prev_char() {
let mut input: Input = TEXT.into();
let req = InputRequest::GoToPrevChar;
let resp = input.handle(req);
assert_eq!(
resp,
Some(StateChanged {
value: false,
cursor: true,
})
);
assert_eq!(input.value(), "first second, third.");
assert_eq!(input.cursor(), TEXT.chars().count() - 1);
let mut input = input.with_cursor(3);
input.handle(req);
assert_eq!(input.value(), "first second, third.");
assert_eq!(input.cursor(), 2);
input.handle(req);
assert_eq!(input.value(), "first second, third.");
assert_eq!(input.cursor(), 1);
}
#[test]
fn remove_unicode_chars() {
let mut input: Input = "¡test¡".into();
let req = InputRequest::DeletePrevChar;
let resp = input.handle(req);
assert_eq!(
resp,
Some(StateChanged {
value: true,
cursor: true,
})
);
assert_eq!(input.value(), "¡test");
assert_eq!(input.cursor(), 5);
input.handle(InputRequest::GoToStart);
let req = InputRequest::DeleteNextChar;
let resp = input.handle(req);
assert_eq!(
resp,
Some(StateChanged {
value: true,
cursor: false,
})
);
assert_eq!(input.value(), "test");
assert_eq!(input.cursor(), 0);
}
#[test]
fn insert_unicode_chars() {
let mut input = Input::from("¡test¡").with_cursor(5);
let req = InputRequest::InsertChar('☆');
let resp = input.handle(req);
assert_eq!(
resp,
Some(StateChanged {
value: true,
cursor: true,
})
);
assert_eq!(input.value(), "¡test☆¡");
assert_eq!(input.cursor(), 6);
input.handle(InputRequest::GoToStart);
input.handle(InputRequest::GoToNextChar);
let req = InputRequest::InsertChar('☆');
let resp = input.handle(req);
assert_eq!(
resp,
Some(StateChanged {
value: true,
cursor: true,
})
);
assert_eq!(input.value(), "¡☆test☆¡");
assert_eq!(input.cursor(), 2);
}
#[test]
fn multispace_characters() {
let input: Input = ", !".into();
assert_eq!(input.cursor(), 13);
assert_eq!(input.visual_cursor(), 23);
assert_eq!(input.visual_scroll(6), 18);
}
}