speed-reader 0.2.0

RSVP speed reading overlay — displays text word by word with ORP highlighting
use speed_reader_core::config::ConfigModel;
use speed_reader_core::position::PositionTracker;
use speed_reader_core::state::{Event, ReadingState, State};
use speed_reader_core::tokenizer::Token;
use winit::keyboard::{Key, NamedKey};

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ActionResult {
    Continue, Exit, Render, PausedAndJumped, SpeedChanged(u32), Restarted,
}

pub struct InputHandler {
    file_path: Option<String>,
    tokens: Vec<Token>,
    position_tracker: PositionTracker,
    config: ConfigModel,
}

impl InputHandler {
    pub fn new(fp: Option<String>, tokens: Vec<Token>, text: &str, config: ConfigModel) -> Self {
        Self { file_path: fp, position_tracker: PositionTracker::new(text), tokens, config }
    }

    pub fn handle_key(&self, key: &Key, state: &mut ReadingState) -> ActionResult {
        match key {
            Key::Named(NamedKey::Space) => self.handle_space(state),
            Key::Character(c) if c.as_ref() == " " => self.handle_space(state),
            Key::Named(NamedKey::Escape) => { let _ = state.transition(Event::Stop); ActionResult::Exit }
            Key::Character(c) if c.as_ref() == "q" || c.as_ref() == "Q" => { let _ = state.transition(Event::Stop); ActionResult::Exit }
            Key::Named(NamedKey::ArrowLeft) => self.handle_skip(state, false),
            Key::Named(NamedKey::ArrowRight) => self.handle_skip(state, true),
            Key::Named(NamedKey::ArrowUp) => self.handle_speed(state, true),
            Key::Named(NamedKey::ArrowDown) => self.handle_speed(state, false),
            Key::Character(c) if c.as_ref() == "r" || c.as_ref() == "R" => {
                let _ = state.transition(Event::Stop);
                let _ = state.transition(Event::Play);
                ActionResult::Restarted
            }
            _ => ActionResult::Continue,
        }
    }

    fn handle_space(&self, state: &mut ReadingState) -> ActionResult {
        match state.current_state() {
            State::Playing { .. } => { let _ = state.transition(Event::Pause); self.jump_to_position(state); ActionResult::PausedAndJumped }
            State::Paused { .. } => { let _ = state.transition(Event::Resume); ActionResult::Render }
            _ => ActionResult::Continue,
        }
    }

    fn can_act(state: &ReadingState) -> bool {
        matches!(state.current_state(), State::Playing { .. } | State::Paused { .. })
    }

    fn handle_skip(&self, state: &mut ReadingState, fwd: bool) -> ActionResult {
        if !Self::can_act(state) { return ActionResult::Continue }
        let n = self.config.skip_amount as usize;
        let _ = state.transition(if fwd { Event::SkipForward(n) } else { Event::SkipBackward(n) });
        ActionResult::Render
    }

    fn handle_speed(&self, state: &mut ReadingState, up: bool) -> ActionResult {
        if !Self::can_act(state) { return ActionResult::Continue }
        let step = self.config.speed_step;
        let _ = state.transition(if up { Event::SpeedUp(step) } else { Event::SpeedDown(step) });
        ActionResult::SpeedChanged(state.wpm())
    }

    pub fn jump_to_position(&self, state: &ReadingState) {
        let fp = match &self.file_path { Some(f) => f.clone(), None => return };
        let ti = match state.current_index() { Some(i) => i, None => return };
        if ti >= self.tokens.len() { return }
        let pos = self.position_tracker.position_for_offset(self.tokens[ti].byte_offset);
        let _ = std::process::Command::new(&self.config.editor_cmd)
            .arg(format!("\"{}\":{}:{}", fp, pos.line, pos.column + 1))
            .spawn();
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    fn cfg() -> ConfigModel { ConfigModel::default() }
    fn tok() -> Vec<Token> { vec![Token { word: "hello".into(), orp_index: 1, byte_offset: 0, byte_len: 5, is_sentence_end: false }] }
    fn txt() -> &'static str { "hello" }

    #[test] fn space_pauses() {
        let h = InputHandler::new(Some("x.rs".into()), tok(), txt(), cfg());
        let mut s = ReadingState::new(1, 300); let _ = s.transition(Event::Play);
        assert_eq!(h.handle_key(&Key::Named(NamedKey::Space), &mut s), ActionResult::PausedAndJumped);
    }
    #[test] fn space_resumes() {
        let h = InputHandler::new(Some("x.rs".into()), tok(), txt(), cfg());
        let mut s = ReadingState::new(1, 300); let _ = s.transition(Event::Play); let _ = s.transition(Event::Pause);
        assert_eq!(h.handle_key(&Key::Named(NamedKey::Space), &mut s), ActionResult::Render);
    }
    #[test] fn esc_or_q_exits() {
        let h = InputHandler::new(Some("x.rs".into()), tok(), txt(), cfg());
        let mut s = ReadingState::new(1, 300); let _ = s.transition(Event::Play);
        assert_eq!(h.handle_key(&Key::Named(NamedKey::Escape), &mut s), ActionResult::Exit);
        assert_eq!(h.handle_key(&Key::Character("q".into()), &mut s), ActionResult::Exit);
    }
    #[test] fn skip_works_playing() {
        let h = InputHandler::new(Some("x.rs".into()), tok(), txt(), cfg());
        let mut s = ReadingState::new(5, 300); let _ = s.transition(Event::Play);
        assert_eq!(h.handle_key(&Key::Named(NamedKey::ArrowRight), &mut s), ActionResult::Render);
    }
    #[test] fn speed_works_playing() {
        let h = InputHandler::new(Some("x.rs".into()), tok(), txt(), cfg());
        let mut s = ReadingState::new(5, 300); let _ = s.transition(Event::Play);
        assert!(matches!(h.handle_key(&Key::Named(NamedKey::ArrowUp), &mut s), ActionResult::SpeedChanged(_)));
    }
    #[test] fn speed_works_paused() {
        let h = InputHandler::new(Some("x.rs".into()), tok(), txt(), cfg());
        let mut s = ReadingState::new(5, 300); let _ = s.transition(Event::Play); let _ = s.transition(Event::Pause);
        assert!(matches!(h.handle_key(&Key::Named(NamedKey::ArrowDown), &mut s), ActionResult::SpeedChanged(_)));
    }
}