#![doc = simple_mermaid::mermaid!("../diagrams/session_lifecycle.mmd")]
use crate::buffer::Buffer;
use crate::config::Configuration;
use crate::input_handler::InputHandler;
use crate::render::{LineContext, LineRenderConfig, RenderingContext, RenderingIterator};
use crate::statistics::{Statistics, TempStatistics};
use crate::statistics_tracker::StatisticsTracker;
use crate::{Character, CharacterResult, Word};
use web_time::Duration;
#[derive(Debug, Clone)]
pub struct TypingSession {
text_buffer: Buffer,
input_handler: InputHandler,
statistics: StatisticsTracker,
config: Configuration,
}
impl TypingSession {
pub fn new(string: &str) -> Option<Self> {
let text_buffer = Buffer::new(string)?;
Some(Self {
text_buffer,
input_handler: InputHandler::new(),
statistics: StatisticsTracker::new(),
config: Configuration::default(),
})
}
pub fn with_configuration(mut self, config: Configuration) -> Self {
self.config = config;
self
}
pub fn get_character(&self, index: usize) -> Option<&Character> {
self.text_buffer.get_character(index)
}
pub fn get_word_containing_index(&self, index: usize) -> Option<&Word> {
self.text_buffer.get_word_containing(index)
}
pub fn text_len(&self) -> usize {
self.text_buffer.text_len()
}
pub fn current_character(&self) -> &Character {
self.text_buffer
.current_character(self.input_handler.input_len())
.unwrap()
}
pub fn is_input_empty(&self) -> bool {
self.input_handler.is_input_empty()
}
pub fn input_len(&self) -> usize {
self.input_handler.input_len()
}
pub fn is_fully_typed(&self) -> bool {
self.input_handler
.is_fully_typed(self.text_buffer.text_len())
}
pub fn completion_percentage(&self) -> f64 {
let input_len = self.input_handler.input_len();
let text_len = self.text_buffer.text_len();
if text_len == 0 {
return 0.0;
}
(input_len as f64 / text_len as f64) * 100.0
}
pub fn time_elapsed(&self) -> f64 {
self.statistics
.total_duration()
.as_ref()
.map(Duration::as_secs_f64)
.unwrap_or(0.0)
}
pub fn statistics(&self) -> &TempStatistics {
self.statistics.statistics()
}
pub fn push_string(&mut self, string: &str) {
self.text_buffer.push_string(string);
}
pub fn get_word(&self, index: usize) -> Option<&Word> {
self.text_buffer.get_word(index)
}
pub fn word_count(&self) -> usize {
self.text_buffer.word_count()
}
pub fn words_typed_count(&self) -> usize {
let input_len = self.input_len();
if input_len == 0 {
return 0;
}
let mut completed_words = 0;
for word_index in 0..self.text_buffer.word_count() {
if let Some(word) = self.text_buffer.get_word(word_index) {
if input_len > word.end {
completed_words = word_index + 1;
} else {
break;
}
}
}
completed_words
}
pub fn render<Char, F: FnMut(RenderingContext) -> Char>(&self, mut renderer: F) -> Vec<Char> {
let mut results = Vec::with_capacity(self.text_len());
let cursor_position = self.input_len();
for i in 0..self.text_len() {
let character = self.text_buffer.get_character(i).unwrap();
let word = self.text_buffer.get_word_containing(i);
let has_cursor = i == cursor_position;
let context = RenderingContext {
character,
word,
has_cursor,
index: i,
};
results.push(renderer(context));
}
results
}
pub fn render_lines<Line, F: FnMut(LineContext) -> Option<Line>>(
&self,
mut line_renderer: F,
config: LineRenderConfig,
) -> Vec<Line> {
let mut lines = Vec::new();
let mut current_line_contexts = Vec::new();
let mut current_line_length = 0;
let mut cursor_line_index = None;
for context in self.render_iter() {
let char_is_space = context.character.char.is_ascii_whitespace();
let char_is_newline = context.character.char == '\n';
let context_index = context.index;
let has_cursor = context.has_cursor;
if has_cursor {
cursor_line_index = Some(lines.len()); }
if config.break_at_newlines && char_is_newline {
current_line_contexts.push(context);
lines.push((current_line_contexts, lines.len()));
current_line_contexts = Vec::new();
current_line_length = 0;
continue;
}
if !config.wrap_words && char_is_space && current_line_length > 0 {
let mut look_ahead_length = 0;
let mut look_ahead_index = context_index + 1;
while look_ahead_index < self.text_len() {
if let Some(look_ahead_char) = self.get_character(look_ahead_index) {
if look_ahead_char.char.is_ascii_whitespace() {
break;
}
look_ahead_length += 1;
look_ahead_index += 1;
} else {
break;
}
}
if current_line_length + 1 + look_ahead_length > config.line_length {
current_line_contexts.push(context);
lines.push((current_line_contexts, lines.len())); current_line_contexts = Vec::new();
current_line_length = 0;
continue; }
}
if current_line_length >= config.line_length {
lines.push((current_line_contexts, lines.len())); current_line_contexts = Vec::new();
current_line_length = 0;
if char_is_space {
continue;
}
}
current_line_contexts.push(context);
current_line_length += 1;
}
if !current_line_contexts.is_empty() {
lines.push((current_line_contexts, lines.len()));
}
if cursor_line_index.is_none() {
cursor_line_index = Some(lines.len().saturating_sub(1));
}
let cursor_line = cursor_line_index.unwrap_or(0);
lines
.into_iter()
.filter_map(|(line_contexts, line_index)| {
let line_context = LineContext {
active_line_offset: line_index as isize - cursor_line as isize,
contents: line_contexts,
};
line_renderer(line_context)
})
.collect()
}
pub fn render_iter(&self) -> RenderingIterator<'_> {
self.into()
}
pub fn input(&mut self, input: Option<char>) -> Option<(char, CharacterResult)> {
let result = self
.input_handler
.process_input(input, &mut self.text_buffer);
if let Some((char, char_result)) = result {
self.statistics.update(
char,
char_result,
self.input_handler.input_len(),
&self.config,
);
if self.is_fully_typed() && !self.statistics.is_completed() {
self.statistics.mark_completed();
}
}
result
}
pub fn finalize(self) -> Statistics {
let text_len = self.text_len();
let input_len = self.input_len();
self.statistics.finalize(text_len, input_len)
}
}
#[cfg(test)]
mod tests {
use crate::State;
use super::*;
#[test]
fn test_text_new() {
let text = TypingSession::new("hello world").unwrap();
assert_eq!(text.text_len(), 11);
assert_eq!(text.input_len(), 0);
assert!(text.is_input_empty());
assert!(!text.is_fully_typed());
let text = TypingSession::new("");
assert!(text.is_none());
let text = TypingSession::new("a").unwrap();
assert_eq!(text.text_len(), 1);
assert_eq!(text.current_character().char, 'a');
let text = TypingSession::new("héllo wörld 🚀").unwrap();
assert_eq!(text.text_len(), 13); }
#[test]
fn test_text_push() {
let mut text = TypingSession::new("hello").unwrap();
assert_eq!(text.text_len(), 5);
text.push_string(" world");
assert_eq!(text.text_len(), 11);
text.push_string("");
assert_eq!(text.text_len(), 11);
text.push_string("! 123");
assert_eq!(text.text_len(), 16);
assert_eq!(text.current_character().char, 'h');
}
#[test]
fn test_text_unicode_support() {
let mut text = TypingSession::new("café 🚀").unwrap();
assert_eq!(text.text_len(), 6);
let result = text.input(Some('c')).unwrap();
assert!(matches!(result.1, CharacterResult::Correct));
let result = text.input(Some('a')).unwrap();
assert!(matches!(result.1, CharacterResult::Correct));
let result = text.input(Some('f')).unwrap();
assert!(matches!(result.1, CharacterResult::Correct));
let result = text.input(Some('é')).unwrap();
assert!(matches!(result.1, CharacterResult::Correct));
}
#[test]
fn test_update_word() {
let mut text = TypingSession::new("hello world").unwrap();
assert_eq!(text.get_word(0).unwrap().state, State::None); assert_eq!(text.get_word(1).unwrap().state, State::None);
text.input(Some('h')).unwrap();
assert_eq!(text.get_word(0).unwrap().state, State::Correct);
assert_eq!(text.get_word(1).unwrap().state, State::None);
text.input(Some('e')).unwrap();
assert_eq!(text.get_word(0).unwrap().state, State::Correct);
text.input(Some('x')).unwrap();
assert_eq!(text.get_word(0).unwrap().state, State::Wrong);
text.input(None).unwrap();
assert_eq!(text.get_word(0).unwrap().state, State::WasWrong);
text.input(Some('l')).unwrap();
assert_eq!(text.get_word(0).unwrap().state, State::Corrected);
text.input(Some('l')).unwrap();
text.input(Some('o')).unwrap();
assert_eq!(text.get_word(0).unwrap().state, State::Corrected);
text.input(Some(' ')).unwrap();
assert_eq!(text.get_word(0).unwrap().state, State::Corrected);
assert_eq!(text.get_word(1).unwrap().state, State::None);
text.input(Some('w')).unwrap();
assert_eq!(text.get_word(0).unwrap().state, State::Corrected);
assert_eq!(text.get_word(1).unwrap().state, State::Correct);
text.input(Some('x')).unwrap();
assert_eq!(text.get_word(1).unwrap().state, State::Wrong);
text.input(None).unwrap();
assert_eq!(text.get_word(1).unwrap().state, State::WasWrong);
text.input(Some('o')).unwrap();
assert_eq!(text.get_word(1).unwrap().state, State::Corrected);
text.input(Some('r')).unwrap();
text.input(Some('l')).unwrap();
text.input(Some('d')).unwrap();
assert_eq!(text.get_word(1).unwrap().state, State::Corrected);
let mut text2 = TypingSession::new("test").unwrap();
text2.input(Some('x')).unwrap(); text2.input(None).unwrap(); text2.input(Some('t')).unwrap(); text2.input(Some('e')).unwrap(); assert_eq!(text2.get_word(0).unwrap().state, State::Corrected);
text2.input(Some('x')).unwrap();
assert_eq!(text2.get_word(0).unwrap().state, State::Wrong);
}
#[test]
fn test_rendering() {
let mut text = TypingSession::new("hello").unwrap();
text.input(Some('h')).unwrap(); text.input(Some('x')).unwrap();
let rendered: Vec<String> = text.render(|ctx| {
let state_str = match ctx.character.state {
State::None => "none",
State::Correct => "correct",
State::Wrong => "wrong",
_ => "other",
};
let cursor_str = if ctx.has_cursor { " [cursor]" } else { "" };
format!("{}:{}{}", ctx.character.char, state_str, cursor_str)
});
assert_eq!(rendered.len(), 5);
assert_eq!(rendered[0], "h:correct");
assert_eq!(rendered[1], "e:wrong");
assert_eq!(rendered[2], "l:none [cursor]");
assert_eq!(rendered[3], "l:none");
assert_eq!(rendered[4], "o:none");
let rendered_iter: Vec<char> = text.render_iter().map(|ctx| ctx.character.char).collect();
assert_eq!(rendered_iter, vec!['h', 'e', 'l', 'l', 'o']);
let iter = text.render_iter();
assert_eq!(iter.len(), 5);
assert_eq!(iter.size_hint(), (5, Some(5)));
}
#[test]
fn test_completion_and_finalization() {
let mut text = TypingSession::new("hi").unwrap();
assert!(!text.is_fully_typed());
text.input(Some('h')).unwrap();
assert!(!text.is_fully_typed());
text.input(Some('i')).unwrap();
assert!(text.is_fully_typed());
let stats = text.finalize();
assert_eq!(stats.counters.adds, 2);
assert_eq!(stats.counters.corrects, 2);
assert_eq!(stats.counters.errors, 0);
}
#[test]
fn test_finalization_before_completion() {
let text = TypingSession::new("hello").unwrap();
text.finalize();
}
#[test]
fn test_render_lines() {
let text = TypingSession::new("hello world this is a test").unwrap();
let lines: Vec<String> = text.render_lines(
|line_ctx| {
Some(
line_ctx
.contents
.iter()
.map(|ctx| ctx.character.char)
.collect::<String>(),
)
},
LineRenderConfig::new(10).with_word_wrapping(false), );
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "hello ");
assert_eq!(lines[1], "world this ");
assert_eq!(lines[2], "is a test");
let lines_wrapped: Vec<String> = text.render_lines(
|line_ctx| {
Some(
line_ctx
.contents
.iter()
.map(|ctx| ctx.character.char)
.collect::<String>(),
)
},
LineRenderConfig::new(10).with_word_wrapping(true), );
assert_eq!(lines_wrapped.len(), 3);
assert_eq!(lines_wrapped[0], "hello worl");
assert_eq!(lines_wrapped[1], "d this is ");
assert_eq!(lines_wrapped[2], "a test");
}
#[test]
fn test_render_lines_with_line_context() {
let text = TypingSession::new("one two three").unwrap();
let lines: Vec<(isize, String)> = text.render_lines(
|line_ctx| {
Some((
line_ctx.active_line_offset,
line_ctx
.contents
.iter()
.map(|ctx| ctx.character.char)
.collect::<String>(),
))
},
LineRenderConfig::new(5).with_word_wrapping(false), );
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], (0, "one ".to_string())); assert_eq!(lines[1], (1, "two ".to_string())); assert_eq!(lines[2], (2, "three".to_string())); }
#[test]
fn test_render_lines_cursor_in_middle() {
let mut text = TypingSession::new("one two three four").unwrap();
text.input(Some('o')).unwrap(); text.input(Some('n')).unwrap(); text.input(Some('e')).unwrap(); text.input(Some(' ')).unwrap(); text.input(Some('t')).unwrap();
let lines: Vec<(isize, String)> = text.render_lines(
|line_ctx| {
Some((
line_ctx.active_line_offset,
line_ctx
.contents
.iter()
.map(|ctx| ctx.character.char)
.collect::<String>(),
))
},
LineRenderConfig::new(5).with_word_wrapping(false), );
assert_eq!(lines.len(), 4);
assert_eq!(lines[0], (-1, "one ".to_string())); assert_eq!(lines[1], (0, "two ".to_string())); assert_eq!(lines[2], (1, "three ".to_string())); assert_eq!(lines[3], (2, "four".to_string())); }
#[test]
fn test_render_lines_with_newlines() {
let text = TypingSession::new("hello world\nthis is\na test").unwrap();
let lines: Vec<String> = text.render_lines(
|line_ctx| {
Some(
line_ctx
.contents
.iter()
.map(|ctx| ctx.character.char)
.collect::<String>(),
)
},
LineRenderConfig::new(20).with_newline_breaking(true), );
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "hello world\n"); assert_eq!(lines[1], "this is\n"); assert_eq!(lines[2], "a test"); }
#[test]
fn test_render_lines_without_newline_breaking() {
let text = TypingSession::new("hello world\nthis is").unwrap();
let lines: Vec<String> = text.render_lines(
|line_ctx| {
Some(
line_ctx
.contents
.iter()
.map(|ctx| ctx.character.char)
.collect::<String>(),
)
},
LineRenderConfig::new(20).with_newline_breaking(false), );
assert_eq!(lines.len(), 1);
assert_eq!(lines[0], "hello world\nthis is");
}
#[test]
fn test_completion_percentage() {
let mut text = TypingSession::new("hello").unwrap();
assert_eq!(text.completion_percentage(), 0.0);
text.input(Some('h')).unwrap();
assert_eq!(text.completion_percentage(), 20.0);
text.input(Some('e')).unwrap();
assert_eq!(text.completion_percentage(), 40.0);
text.input(Some('l')).unwrap();
text.input(Some('l')).unwrap();
text.input(Some('o')).unwrap();
assert_eq!(text.completion_percentage(), 100.0);
if let Some(empty_text) = TypingSession::new("") {
assert_eq!(empty_text.completion_percentage(), 0.0);
}
}
#[test]
fn test_words_typed_count() {
let mut session = TypingSession::new("hello world test").unwrap();
for i in 0..session.word_count() {
if let Some(word) = session.get_word(i) {
let chars: String = (word.start..word.end)
.map(|idx| session.get_character(idx).map(|c| c.char).unwrap_or('?'))
.collect();
println!(
"Word {}: start={}, end={}, chars='{}'",
i, word.start, word.end, chars
);
}
}
for i in 0..session.text_len() {
if let Some(ch) = session.get_character(i) {
println!("Char {}: '{}'", i, ch.char);
}
}
println!(
"Initial: input_len={}, words_typed={}",
session.input_len(),
session.words_typed_count()
);
assert_eq!(session.words_typed_count(), 0);
session.input(Some('h')).unwrap();
assert_eq!(session.words_typed_count(), 0);
session.input(Some('e')).unwrap();
session.input(Some('l')).unwrap();
session.input(Some('l')).unwrap();
assert_eq!(session.words_typed_count(), 0);
session.input(Some('o')).unwrap();
assert_eq!(session.words_typed_count(), 1);
session.input(Some(' ')).unwrap();
assert_eq!(session.words_typed_count(), 1);
session.input(Some('w')).unwrap();
session.input(Some('o')).unwrap();
assert_eq!(session.words_typed_count(), 1);
session.input(Some('r')).unwrap();
session.input(Some('l')).unwrap();
session.input(Some('d')).unwrap();
assert_eq!(session.words_typed_count(), 2);
session.input(Some(' ')).unwrap();
assert_eq!(session.words_typed_count(), 2);
session.input(Some('t')).unwrap();
assert_eq!(session.words_typed_count(), 2);
session.input(Some('e')).unwrap();
session.input(Some('s')).unwrap();
session.input(Some('t')).unwrap();
assert_eq!(session.words_typed_count(), 3);
let mut single_word = TypingSession::new("hello").unwrap();
assert_eq!(single_word.words_typed_count(), 0);
for ch in "hello".chars() {
single_word.input(Some(ch)).unwrap();
}
assert_eq!(single_word.words_typed_count(), 1);
let mut spaced = TypingSession::new(" hello world ").unwrap();
assert_eq!(spaced.words_typed_count(), 0);
spaced.input(Some(' ')).unwrap();
assert_eq!(spaced.words_typed_count(), 0);
for ch in "hello".chars() {
spaced.input(Some(ch)).unwrap();
}
assert_eq!(spaced.words_typed_count(), 1);
spaced.input(Some(' ')).unwrap();
assert_eq!(spaced.words_typed_count(), 1);
}
}