fix(input): implement UTF-8 safe IME preedit indexing, Nintendo Switch glyph mapping, and rumble auto-expiration

This commit is contained in:
katR 2026-09-30 07:51:27 +05:30
parent c79c3c36ec
commit 514717f37f
4 changed files with 271 additions and 11 deletions

View file

@ -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,

View file

@ -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<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 {
@ -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<RumbleState> {
self.states.get(&gamepad_id).copied()
}

View file

@ -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;
}
}

View 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);
}