diff --git a/crates/molotov-core/src/input/gamepad.rs b/crates/molotov-core/src/input/gamepad.rs index e0c168c..7fdbdd6 100644 --- a/crates/molotov-core/src/input/gamepad.rs +++ b/crates/molotov-core/src/input/gamepad.rs @@ -21,12 +21,24 @@ pub enum GamepadButton { RightStick, LeftShoulder, RightShoulder, + LeftTrigger, + RightTrigger, DpadUp, DpadDown, DpadLeft, DpadRight, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GamepadAxis { + LeftX, + LeftY, + RightX, + RightY, + TriggerLeft, + TriggerRight, +} + #[repr(i32)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AndroidKeyCode { @@ -52,7 +64,51 @@ pub enum AndroidKeyCode { ButtonMode = 110, } -pub fn translate_button_to_android(btn: GamepadButton, _gamepad_type: GamepadType) -> AndroidKeyCode { +#[repr(i32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AndroidAxis { + X = 0, + Y = 1, + Z = 11, + Rz = 14, + Brake = 23, + Gas = 22, +} + +pub fn translate_axis_to_android(axis: GamepadAxis) -> AndroidAxis { + match axis { + GamepadAxis::LeftX => AndroidAxis::X, + GamepadAxis::LeftY => AndroidAxis::Y, + GamepadAxis::RightX => AndroidAxis::Z, + GamepadAxis::RightY => AndroidAxis::Rz, + GamepadAxis::TriggerLeft => AndroidAxis::Brake, + GamepadAxis::TriggerRight => AndroidAxis::Gas, + } +} + +pub fn translate_button_to_android(btn: GamepadButton, gamepad_type: GamepadType) -> AndroidKeyCode { + if gamepad_type == GamepadType::NintendoSwitch { + // Physical Nintendo layout: East is A, South is B, North is X, West is Y + match btn { + GamepadButton::East => AndroidKeyCode::ButtonA, + GamepadButton::South => AndroidKeyCode::ButtonB, + GamepadButton::North => AndroidKeyCode::ButtonX, + GamepadButton::West => AndroidKeyCode::ButtonY, + other => translate_standard_button(other), + } + } else { + // Standard Xbox / PlayStation layout + match btn { + GamepadButton::South => AndroidKeyCode::ButtonA, + GamepadButton::East => AndroidKeyCode::ButtonB, + GamepadButton::West => AndroidKeyCode::ButtonX, + GamepadButton::North => AndroidKeyCode::ButtonY, + other => translate_standard_button(other), + } + } +} + +fn translate_standard_button(btn: GamepadButton) -> AndroidKeyCode { match btn { GamepadButton::South => AndroidKeyCode::ButtonA, GamepadButton::East => AndroidKeyCode::ButtonB, @@ -65,6 +121,8 @@ pub fn translate_button_to_android(btn: GamepadButton, _gamepad_type: GamepadTyp GamepadButton::RightStick => AndroidKeyCode::ButtonThumbR, GamepadButton::LeftShoulder => AndroidKeyCode::ButtonL1, GamepadButton::RightShoulder => AndroidKeyCode::ButtonR1, + GamepadButton::LeftTrigger => AndroidKeyCode::ButtonL2, + GamepadButton::RightTrigger => AndroidKeyCode::ButtonR2, GamepadButton::DpadUp => AndroidKeyCode::DpadUp, GamepadButton::DpadDown => AndroidKeyCode::DpadDown, GamepadButton::DpadLeft => AndroidKeyCode::DpadLeft, diff --git a/crates/molotov-core/src/input/haptics.rs b/crates/molotov-core/src/input/haptics.rs index d1d6028..1be5aa7 100644 --- a/crates/molotov-core/src/input/haptics.rs +++ b/crates/molotov-core/src/input/haptics.rs @@ -1,13 +1,28 @@ // SPDX-License-Identifier: GPL-3.0-or-later use std::collections::HashMap; +use std::time::Instant; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RumbleState { pub low_freq_motor: u16, pub high_freq_motor: u16, pub left_trigger: u16, pub right_trigger: u16, pub duration_ms: u32, + pub started_at: Option, +} + +impl Default for RumbleState { + fn default() -> Self { + Self { + low_freq_motor: 0, + high_freq_motor: 0, + left_trigger: 0, + right_trigger: 0, + duration_ms: 0, + started_at: None, + } + } } pub struct RumbleManager { @@ -28,6 +43,17 @@ impl RumbleManager { } pub fn rumble(&mut self, gamepad_id: u32, low_freq: f32, high_freq: f32, duration_ms: u32) { + self.rumble_with_timestamp(gamepad_id, low_freq, high_freq, duration_ms, Instant::now()); + } + + pub fn rumble_with_timestamp( + &mut self, + gamepad_id: u32, + low_freq: f32, + high_freq: f32, + duration_ms: u32, + now: Instant, + ) { let low = (low_freq.clamp(0.0, 1.0) * 65535.0) as u16; let high = (high_freq.clamp(0.0, 1.0) * 65535.0) as u16; @@ -35,6 +61,16 @@ impl RumbleManager { entry.low_freq_motor = low; entry.high_freq_motor = high; entry.duration_ms = duration_ms; + entry.started_at = Some(now); + } + + pub fn rumble_android_amplitude(&mut self, gamepad_id: u32, amplitude: i32, duration_ms: u32) { + let normalized = if amplitude < 0 { + 0.5f32 // Default amplitude + } else { + (amplitude.clamp(0, 255) as f32) / 255.0 + }; + self.rumble(gamepad_id, normalized, normalized, duration_ms); } pub fn rumble_triggers(&mut self, gamepad_id: u32, left: f32, right: f32, duration_ms: u32) { @@ -45,6 +81,34 @@ impl RumbleManager { entry.left_trigger = l; entry.right_trigger = r; entry.duration_ms = duration_ms; + entry.started_at = Some(Instant::now()); + } + + pub fn update(&mut self, now: Instant) { + for state in self.states.values_mut() { + if let Some(started) = state.started_at { + if now.duration_since(started).as_millis() >= state.duration_ms as u128 { + state.low_freq_motor = 0; + state.high_freq_motor = 0; + state.left_trigger = 0; + state.right_trigger = 0; + state.started_at = None; + } + } + } + } + + pub fn is_active(&self, gamepad_id: u32, now: Instant) -> bool { + if let Some(state) = self.states.get(&gamepad_id) { + if let Some(started) = state.started_at { + now.duration_since(started).as_millis() < state.duration_ms as u128 + && (state.low_freq_motor > 0 || state.high_freq_motor > 0) + } else { + false + } + } else { + false + } } pub fn stop(&mut self, gamepad_id: u32) { @@ -54,9 +118,14 @@ impl RumbleManager { entry.left_trigger = 0; entry.right_trigger = 0; entry.duration_ms = 0; + entry.started_at = None; } } + pub fn disconnect_gamepad(&mut self, gamepad_id: u32) { + self.states.remove(&gamepad_id); + } + pub fn get_state(&self, gamepad_id: u32) -> Option { self.states.get(&gamepad_id).copied() } diff --git a/crates/molotov-core/src/input/ime.rs b/crates/molotov-core/src/input/ime.rs index 3d579bc..311d5c4 100644 --- a/crates/molotov-core/src/input/ime.rs +++ b/crates/molotov-core/src/input/ime.rs @@ -18,7 +18,9 @@ pub struct ImeManager { state: ImeState, cursor_area: Rect, preedit_text: String, - cursor_pos: usize, + cursor_char_pos: usize, + selection_char_len: usize, + cursor_utf16_pos: usize, } impl Default for ImeManager { @@ -33,7 +35,9 @@ impl ImeManager { state: ImeState::Inactive, cursor_area: Rect::default(), preedit_text: String::new(), - cursor_pos: 0, + cursor_char_pos: 0, + selection_char_len: 0, + cursor_utf16_pos: 0, } } @@ -45,13 +49,17 @@ impl ImeManager { self.state = ImeState::Active; self.cursor_area = area; self.preedit_text.clear(); - self.cursor_pos = 0; + self.cursor_char_pos = 0; + self.selection_char_len = 0; + self.cursor_utf16_pos = 0; } pub fn stop_text_input(&mut self) { self.state = ImeState::Inactive; self.preedit_text.clear(); - self.cursor_pos = 0; + self.cursor_char_pos = 0; + self.selection_char_len = 0; + self.cursor_utf16_pos = 0; } pub fn set_cursor_area(&mut self, area: Rect) { @@ -62,9 +70,44 @@ impl ImeManager { self.cursor_area } - pub fn update_preedit(&mut self, text: &str, cursor_pos: usize) { + pub fn update_preedit(&mut self, text: &str, cursor_char: usize) { self.preedit_text = text.to_string(); - self.cursor_pos = cursor_pos.min(self.preedit_text.chars().count()); + self.cursor_char_pos = cursor_char.min(self.preedit_text.chars().count()); + self.selection_char_len = 0; + self.cursor_utf16_pos = self.preedit_text.chars().take(self.cursor_char_pos).map(|c| c.len_utf16()).sum(); + } + + pub fn update_preedit_bytes(&mut self, text: &str, byte_cursor: usize, byte_length: usize) { + self.preedit_text = text.to_string(); + + let safe_byte_cursor = byte_cursor.min(text.len()); + let mut valid_cursor_bytes = 0; + let mut char_count = 0; + let mut utf16_count = 0; + + for (b_idx, ch) in text.char_indices() { + if b_idx >= safe_byte_cursor { + break; + } + valid_cursor_bytes = b_idx + ch.len_utf8(); + char_count += 1; + utf16_count += ch.len_utf16(); + } + + self.cursor_char_pos = char_count; + self.cursor_utf16_pos = utf16_count; + + // Calculate clause selection length + let remaining_text = &text[valid_cursor_bytes..]; + let safe_byte_len = byte_length.min(remaining_text.len()); + let mut sel_chars = 0; + for (b_idx, _) in remaining_text.char_indices() { + if b_idx >= safe_byte_len { + break; + } + sel_chars += 1; + } + self.selection_char_len = sel_chars; } pub fn preedit_text(&self) -> &str { @@ -72,17 +115,33 @@ impl ImeManager { } pub fn cursor_pos(&self) -> usize { - self.cursor_pos + self.cursor_char_pos + } + + pub fn cursor_char_pos(&self) -> usize { + self.cursor_char_pos + } + + pub fn selection_char_len(&self) -> usize { + self.selection_char_len + } + + pub fn cursor_utf16_pos(&self) -> usize { + self.cursor_utf16_pos } pub fn commit_text(&mut self, text: &str) -> String { self.preedit_text.clear(); - self.cursor_pos = 0; + self.cursor_char_pos = 0; + self.selection_char_len = 0; + self.cursor_utf16_pos = 0; text.to_string() } pub fn cancel_composition(&mut self) { self.preedit_text.clear(); - self.cursor_pos = 0; + self.cursor_char_pos = 0; + self.selection_char_len = 0; + self.cursor_utf16_pos = 0; } } diff --git a/crates/molotov-core/tests/input_haptics_fixes_test.rs b/crates/molotov-core/tests/input_haptics_fixes_test.rs new file mode 100644 index 0000000..59ac2ab --- /dev/null +++ b/crates/molotov-core/tests/input_haptics_fixes_test.rs @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +use molotov_core::input::gamepad::{ + translate_axis_to_android, translate_button_to_android, AndroidAxis, AndroidKeyCode, + GamepadAxis, GamepadButton, GamepadType, +}; +use molotov_core::input::haptics::RumbleManager; +use molotov_core::input::ime::ImeManager; +use std::time::{Duration, Instant}; + +#[test] +fn test_ime_multibyte_utf8_cursor_boundaries() { + let mut ime = ImeManager::new(); + ime.start_text_input(Default::default()); + + // Japanese: "とうきょう" = 5 chars, 15 UTF-8 bytes (3 bytes/char) + // Cursor after second char "とう|" -> byte offset 6 + ime.update_preedit_bytes("とうきょう", 6, 3); + + assert_eq!(ime.preedit_text(), "とうきょう"); + assert_eq!(ime.cursor_char_pos(), 2, "Byte offset 6 must correspond to char index 2"); + assert_eq!(ime.selection_char_len(), 1, "Clause length 3 bytes must correspond to 1 char"); +} + +#[test] +fn test_switch_controller_glyph_transposition() { + // Standard Xbox: South is A, East is B + assert_eq!( + translate_button_to_android(GamepadButton::South, GamepadType::Xbox), + AndroidKeyCode::ButtonA + ); + assert_eq!( + translate_button_to_android(GamepadButton::East, GamepadType::Xbox), + AndroidKeyCode::ButtonB + ); + + // Nintendo Switch layout: East is physical A, South is physical B + assert_eq!( + translate_button_to_android(GamepadButton::East, GamepadType::NintendoSwitch), + AndroidKeyCode::ButtonA, + "Switch East (Physical A) must generate Android ButtonA" + ); + assert_eq!( + translate_button_to_android(GamepadButton::South, GamepadType::NintendoSwitch), + AndroidKeyCode::ButtonB, + "Switch South (Physical B) must generate Android ButtonB" + ); +} + +#[test] +fn test_rumble_timer_expiration_and_disconnect() { + let mut manager = RumbleManager::new(); + let pad_id = 1; + let t0 = Instant::now(); + + manager.rumble_with_timestamp(pad_id, 1.0, 1.0, 50, t0); + assert!(manager.is_active(pad_id, t0)); + + // After 60ms (> 50ms duration), rumble should expire + let t1 = t0 + Duration::from_millis(60); + manager.update(t1); + assert!(!manager.is_active(pad_id, t1)); + + // Disconnecting gamepad removes state + manager.disconnect_gamepad(pad_id); + assert_eq!(manager.get_state(pad_id), None); +} + +#[test] +fn test_gamepad_axes_mapping() { + assert_eq!(translate_axis_to_android(GamepadAxis::LeftX), AndroidAxis::X); + assert_eq!(translate_axis_to_android(GamepadAxis::LeftY), AndroidAxis::Y); + assert_eq!(translate_axis_to_android(GamepadAxis::RightX), AndroidAxis::Z); + assert_eq!(translate_axis_to_android(GamepadAxis::RightY), AndroidAxis::Rz); +}