fix(input): implement UTF-8 safe IME preedit indexing, Nintendo Switch glyph mapping, and rumble auto-expiration
This commit is contained in:
parent
c79c3c36ec
commit
514717f37f
4 changed files with 271 additions and 11 deletions
|
|
@ -21,12 +21,24 @@ pub enum GamepadButton {
|
||||||
RightStick,
|
RightStick,
|
||||||
LeftShoulder,
|
LeftShoulder,
|
||||||
RightShoulder,
|
RightShoulder,
|
||||||
|
LeftTrigger,
|
||||||
|
RightTrigger,
|
||||||
DpadUp,
|
DpadUp,
|
||||||
DpadDown,
|
DpadDown,
|
||||||
DpadLeft,
|
DpadLeft,
|
||||||
DpadRight,
|
DpadRight,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum GamepadAxis {
|
||||||
|
LeftX,
|
||||||
|
LeftY,
|
||||||
|
RightX,
|
||||||
|
RightY,
|
||||||
|
TriggerLeft,
|
||||||
|
TriggerRight,
|
||||||
|
}
|
||||||
|
|
||||||
#[repr(i32)]
|
#[repr(i32)]
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum AndroidKeyCode {
|
pub enum AndroidKeyCode {
|
||||||
|
|
@ -52,7 +64,51 @@ pub enum AndroidKeyCode {
|
||||||
ButtonMode = 110,
|
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 {
|
match btn {
|
||||||
GamepadButton::South => AndroidKeyCode::ButtonA,
|
GamepadButton::South => AndroidKeyCode::ButtonA,
|
||||||
GamepadButton::East => AndroidKeyCode::ButtonB,
|
GamepadButton::East => AndroidKeyCode::ButtonB,
|
||||||
|
|
@ -65,6 +121,8 @@ pub fn translate_button_to_android(btn: GamepadButton, _gamepad_type: GamepadTyp
|
||||||
GamepadButton::RightStick => AndroidKeyCode::ButtonThumbR,
|
GamepadButton::RightStick => AndroidKeyCode::ButtonThumbR,
|
||||||
GamepadButton::LeftShoulder => AndroidKeyCode::ButtonL1,
|
GamepadButton::LeftShoulder => AndroidKeyCode::ButtonL1,
|
||||||
GamepadButton::RightShoulder => AndroidKeyCode::ButtonR1,
|
GamepadButton::RightShoulder => AndroidKeyCode::ButtonR1,
|
||||||
|
GamepadButton::LeftTrigger => AndroidKeyCode::ButtonL2,
|
||||||
|
GamepadButton::RightTrigger => AndroidKeyCode::ButtonR2,
|
||||||
GamepadButton::DpadUp => AndroidKeyCode::DpadUp,
|
GamepadButton::DpadUp => AndroidKeyCode::DpadUp,
|
||||||
GamepadButton::DpadDown => AndroidKeyCode::DpadDown,
|
GamepadButton::DpadDown => AndroidKeyCode::DpadDown,
|
||||||
GamepadButton::DpadLeft => AndroidKeyCode::DpadLeft,
|
GamepadButton::DpadLeft => AndroidKeyCode::DpadLeft,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,28 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
use std::collections::HashMap;
|
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 struct RumbleState {
|
||||||
pub low_freq_motor: u16,
|
pub low_freq_motor: u16,
|
||||||
pub high_freq_motor: u16,
|
pub high_freq_motor: u16,
|
||||||
pub left_trigger: u16,
|
pub left_trigger: u16,
|
||||||
pub right_trigger: u16,
|
pub right_trigger: u16,
|
||||||
pub duration_ms: u32,
|
pub duration_ms: u32,
|
||||||
|
pub started_at: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
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) {
|
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 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;
|
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.low_freq_motor = low;
|
||||||
entry.high_freq_motor = high;
|
entry.high_freq_motor = high;
|
||||||
entry.duration_ms = duration_ms;
|
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) {
|
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.left_trigger = l;
|
||||||
entry.right_trigger = r;
|
entry.right_trigger = r;
|
||||||
entry.duration_ms = duration_ms;
|
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) {
|
pub fn stop(&mut self, gamepad_id: u32) {
|
||||||
|
|
@ -54,9 +118,14 @@ impl RumbleManager {
|
||||||
entry.left_trigger = 0;
|
entry.left_trigger = 0;
|
||||||
entry.right_trigger = 0;
|
entry.right_trigger = 0;
|
||||||
entry.duration_ms = 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<RumbleState> {
|
pub fn get_state(&self, gamepad_id: u32) -> Option<RumbleState> {
|
||||||
self.states.get(&gamepad_id).copied()
|
self.states.get(&gamepad_id).copied()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,9 @@ pub struct ImeManager {
|
||||||
state: ImeState,
|
state: ImeState,
|
||||||
cursor_area: Rect,
|
cursor_area: Rect,
|
||||||
preedit_text: String,
|
preedit_text: String,
|
||||||
cursor_pos: usize,
|
cursor_char_pos: usize,
|
||||||
|
selection_char_len: usize,
|
||||||
|
cursor_utf16_pos: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ImeManager {
|
impl Default for ImeManager {
|
||||||
|
|
@ -33,7 +35,9 @@ impl ImeManager {
|
||||||
state: ImeState::Inactive,
|
state: ImeState::Inactive,
|
||||||
cursor_area: Rect::default(),
|
cursor_area: Rect::default(),
|
||||||
preedit_text: String::new(),
|
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.state = ImeState::Active;
|
||||||
self.cursor_area = area;
|
self.cursor_area = area;
|
||||||
self.preedit_text.clear();
|
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) {
|
pub fn stop_text_input(&mut self) {
|
||||||
self.state = ImeState::Inactive;
|
self.state = ImeState::Inactive;
|
||||||
self.preedit_text.clear();
|
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) {
|
pub fn set_cursor_area(&mut self, area: Rect) {
|
||||||
|
|
@ -62,9 +70,44 @@ impl ImeManager {
|
||||||
self.cursor_area
|
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.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 {
|
pub fn preedit_text(&self) -> &str {
|
||||||
|
|
@ -72,17 +115,33 @@ impl ImeManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cursor_pos(&self) -> usize {
|
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 {
|
pub fn commit_text(&mut self, text: &str) -> String {
|
||||||
self.preedit_text.clear();
|
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()
|
text.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cancel_composition(&mut self) {
|
pub fn cancel_composition(&mut self) {
|
||||||
self.preedit_text.clear();
|
self.preedit_text.clear();
|
||||||
self.cursor_pos = 0;
|
self.cursor_char_pos = 0;
|
||||||
|
self.selection_char_len = 0;
|
||||||
|
self.cursor_utf16_pos = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
74
crates/molotov-core/tests/input_haptics_fixes_test.rs
Normal file
74
crates/molotov-core/tests/input_haptics_fixes_test.rs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
Reference in a new issue