docs: add Phase 2D Input & Haptics implementation plan
This commit is contained in:
parent
27dc294b5f
commit
be35eaaffd
1 changed files with 608 additions and 0 deletions
|
|
@ -0,0 +1,608 @@
|
||||||
|
# Molotov Phase 2D: SDL3 Text Input (IME Preedit) & Gamepad Rumble Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Implement the SDL3 Text Input (with IME preedit composition for fcitx5 and ibus) and Gamepad Support (with physical glyph mapping and dual-motor force feedback rumble) in `molotov-core`, completing the native Linux I/O bridges for Molotov.
|
||||||
|
|
||||||
|
**Architecture:** Bridges native desktop input methods and gamepads via SDL3 APIs. Manages in-progress IME composition strings (`SDL_EVENT_TEXT_EDITING`) and candidate window positioning (`SDL_SetTextInputArea`). Translates gamepad inputs to Android `KeyEvent` / `MotionEvent` codes and intercepts Android `Vibrator` JNI calls to drive gamepad motor and trigger rumble.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust 1.75+, `libc`, `tracing`, `thiserror`.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-09-29-molotov-architecture-design.md` (Sections 7.2 & 7.3)
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **Language & Edition**: Rust 1.75+ (2021 edition), strictly zero Wine or Proton.
|
||||||
|
- **License**: GNU General Public License v3.0 or later (GPL-3.0-or-later) with headers in all source files.
|
||||||
|
- **IME Preedit Support**: Must support in-flight multi-byte character composition (CJK, accents) under Wayland and X11 without premature character commits.
|
||||||
|
- **Accurate Glyph Rendering**: Maps physical controller layouts (Xbox, DualSense, Switch) accurately without inverted button prompts.
|
||||||
|
- **Dual-Motor & Trigger Rumble**: Handles low/high frequency vibration effects and clamp durations.
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
1. **Preedit Text Desynchronization**: Canceling or clearing an IME preedit composition must not leave orphan characters in the active text field.
|
||||||
|
2. **Multi-Byte UTF-8 Boundary Splitting**: Cursor position calculations in preedit strings must strictly index on valid UTF-8 character boundaries.
|
||||||
|
3. **Gamepad Disconnection & Hotplugging**: Disconnecting a controller during active rumble must cancel vibration timers without panics.
|
||||||
|
4. **Vibration Amplitude Clamping**: Float/integer amplitudes from Android `VibrationEffect` must clamp to valid 16-bit unsigned motor ranges (`0..=65535`).
|
||||||
|
5. **Simultaneous Multi-Controller Rumble**: Independent vibration actuation across multiple connected gamepads without cross-talk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: IME Preedit State Machine & Candidate Area Tracker
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `crates/molotov-core/src/input/ime.rs`
|
||||||
|
- Create: `crates/molotov-core/src/input/mod.rs`
|
||||||
|
- Test: `crates/molotov-core/tests/ime_preedit_test.rs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: None
|
||||||
|
- Produces:
|
||||||
|
- `molotov_core::input::ime::ImeManager`
|
||||||
|
- `molotov_core::input::ime::ImeState`
|
||||||
|
- `molotov_core::input::ime::Rect`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test for IME preedit composition and commit**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// crates/molotov-core/tests/ime_preedit_test.rs
|
||||||
|
use molotov_core::input::ime::{ImeManager, Rect};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ime_preedit_composition_flow() {
|
||||||
|
let mut ime = ImeManager::new();
|
||||||
|
ime.start_text_input(Rect { x: 100, y: 200, width: 20, height: 24 });
|
||||||
|
assert!(ime.is_active());
|
||||||
|
|
||||||
|
// User types in Japanese IME: preedit string "とうきょう"
|
||||||
|
ime.update_preedit("とうきょう", 4);
|
||||||
|
assert_eq!(ime.preedit_text(), "とうきょう");
|
||||||
|
assert_eq!(ime.cursor_pos(), 4);
|
||||||
|
|
||||||
|
// User selects candidate: commit "東京"
|
||||||
|
let committed = ime.commit_text("東京");
|
||||||
|
assert_eq!(committed, "東京");
|
||||||
|
assert_eq!(ime.preedit_text(), "", "Preedit must clear on commit");
|
||||||
|
|
||||||
|
ime.stop_text_input();
|
||||||
|
assert!(!ime.is_active());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cargo test -p molotov-core --test ime_preedit_test`
|
||||||
|
Expected: FAIL with unexported module `input`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement ImeManager**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// crates/molotov-core/src/input/ime.rs
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub struct Rect {
|
||||||
|
pub x: i32,
|
||||||
|
pub y: i32,
|
||||||
|
pub width: i32,
|
||||||
|
pub height: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ImeState {
|
||||||
|
Inactive,
|
||||||
|
Active,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ImeManager {
|
||||||
|
state: ImeState,
|
||||||
|
cursor_area: Rect,
|
||||||
|
preedit_text: String,
|
||||||
|
cursor_pos: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ImeManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImeManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
state: ImeState::Inactive,
|
||||||
|
cursor_area: Rect::default(),
|
||||||
|
preedit_text: String::new(),
|
||||||
|
cursor_pos: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
self.state == ImeState::Active
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_text_input(&mut self, area: Rect) {
|
||||||
|
self.state = ImeState::Active;
|
||||||
|
self.cursor_area = area;
|
||||||
|
self.preedit_text.clear();
|
||||||
|
self.cursor_pos = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop_text_input(&mut self) {
|
||||||
|
self.state = ImeState::Inactive;
|
||||||
|
self.preedit_text.clear();
|
||||||
|
self.cursor_pos = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_cursor_area(&mut self, area: Rect) {
|
||||||
|
self.cursor_area = area;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cursor_area(&self) -> Rect {
|
||||||
|
self.cursor_area
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_preedit(&mut self, text: &str, cursor_pos: usize) {
|
||||||
|
self.preedit_text = text.to_string();
|
||||||
|
self.cursor_pos = cursor_pos.min(self.preedit_text.chars().count());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn preedit_text(&self) -> &str {
|
||||||
|
&self.preedit_text
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cursor_pos(&self) -> usize {
|
||||||
|
self.cursor_pos
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn commit_text(&mut self, text: &str) -> String {
|
||||||
|
self.preedit_text.clear();
|
||||||
|
self.cursor_pos = 0;
|
||||||
|
text.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel_composition(&mut self) {
|
||||||
|
self.preedit_text.clear();
|
||||||
|
self.cursor_pos = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// crates/molotov-core/src/input/mod.rs
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
pub mod ime;
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `crates/molotov-core/src/lib.rs` to expose `pub mod input;`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cargo test -p molotov-core --test ime_preedit_test`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/molotov-core/
|
||||||
|
git commit -m "feat(input): implement IME preedit state machine and cursor area tracker"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Gamepad Button & Axis Mapping with Glyph Translation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `crates/molotov-core/src/input/gamepad.rs`
|
||||||
|
- Modify: `crates/molotov-core/src/input/mod.rs`
|
||||||
|
- Test: `crates/molotov-core/tests/gamepad_mapping_test.rs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: None
|
||||||
|
- Produces:
|
||||||
|
- `molotov_core::input::gamepad::GamepadButton`
|
||||||
|
- `molotov_core::input::gamepad::GamepadType`
|
||||||
|
- `molotov_core::input::gamepad::AndroidKeyCode`
|
||||||
|
- `molotov_core::input::gamepad::translate_button_to_android()`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test for button mapping and glyph accuracy**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// crates/molotov-core/tests/gamepad_mapping_test.rs
|
||||||
|
use molotov_core::input::gamepad::{
|
||||||
|
translate_button_to_android, AndroidKeyCode, GamepadButton, GamepadType,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_xbox_and_playstation_button_mapping() {
|
||||||
|
// Xbox 'A' button -> Android KEYCODE_BUTTON_A
|
||||||
|
assert_eq!(
|
||||||
|
translate_button_to_android(GamepadButton::South, GamepadType::Xbox),
|
||||||
|
AndroidKeyCode::ButtonA
|
||||||
|
);
|
||||||
|
|
||||||
|
// PlayStation 'Cross' button -> Android KEYCODE_BUTTON_A
|
||||||
|
assert_eq!(
|
||||||
|
translate_button_to_android(GamepadButton::South, GamepadType::PlayStation),
|
||||||
|
AndroidKeyCode::ButtonA
|
||||||
|
);
|
||||||
|
|
||||||
|
// D-Pad Up -> Android KEYCODE_DPAD_UP
|
||||||
|
assert_eq!(
|
||||||
|
translate_button_to_android(GamepadButton::DpadUp, GamepadType::Xbox),
|
||||||
|
AndroidKeyCode::DpadUp
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cargo test -p molotov-core --test gamepad_mapping_test`
|
||||||
|
Expected: FAIL with unexported module `gamepad`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement GamepadButton, GamepadType, and translator**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// crates/molotov-core/src/input/gamepad.rs
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum GamepadType {
|
||||||
|
Xbox,
|
||||||
|
PlayStation,
|
||||||
|
NintendoSwitch,
|
||||||
|
Generic,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum GamepadButton {
|
||||||
|
South, // A (Xbox) / Cross (PS) / B (Switch)
|
||||||
|
East, // B (Xbox) / Circle (PS) / A (Switch)
|
||||||
|
West, // X (Xbox) / Square (PS) / Y (Switch)
|
||||||
|
North, // Y (Xbox) / Triangle (PS) / X (Switch)
|
||||||
|
Back,
|
||||||
|
Guide,
|
||||||
|
Start,
|
||||||
|
LeftStick,
|
||||||
|
RightStick,
|
||||||
|
LeftShoulder,
|
||||||
|
RightShoulder,
|
||||||
|
DpadUp,
|
||||||
|
DpadDown,
|
||||||
|
DpadLeft,
|
||||||
|
DpadRight,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(i32)]
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AndroidKeyCode {
|
||||||
|
DpadUp = 19,
|
||||||
|
DpadDown = 20,
|
||||||
|
DpadLeft = 21,
|
||||||
|
DpadRight = 22,
|
||||||
|
DpadCenter = 23,
|
||||||
|
ButtonA = 96,
|
||||||
|
ButtonB = 97,
|
||||||
|
ButtonC = 98,
|
||||||
|
ButtonX = 99,
|
||||||
|
ButtonY = 100,
|
||||||
|
ButtonZ = 101,
|
||||||
|
ButtonL1 = 102,
|
||||||
|
ButtonR1 = 103,
|
||||||
|
ButtonL2 = 104,
|
||||||
|
ButtonR2 = 105,
|
||||||
|
ButtonThumbL = 106,
|
||||||
|
ButtonThumbR = 107,
|
||||||
|
ButtonStart = 108,
|
||||||
|
ButtonSelect = 109,
|
||||||
|
ButtonMode = 110,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn translate_button_to_android(btn: GamepadButton, _gamepad_type: GamepadType) -> AndroidKeyCode {
|
||||||
|
match btn {
|
||||||
|
GamepadButton::South => AndroidKeyCode::ButtonA,
|
||||||
|
GamepadButton::East => AndroidKeyCode::ButtonB,
|
||||||
|
GamepadButton::West => AndroidKeyCode::ButtonX,
|
||||||
|
GamepadButton::North => AndroidKeyCode::ButtonY,
|
||||||
|
GamepadButton::Back => AndroidKeyCode::ButtonSelect,
|
||||||
|
GamepadButton::Guide => AndroidKeyCode::ButtonMode,
|
||||||
|
GamepadButton::Start => AndroidKeyCode::ButtonStart,
|
||||||
|
GamepadButton::LeftStick => AndroidKeyCode::ButtonThumbL,
|
||||||
|
GamepadButton::RightStick => AndroidKeyCode::ButtonThumbR,
|
||||||
|
GamepadButton::LeftShoulder => AndroidKeyCode::ButtonL1,
|
||||||
|
GamepadButton::RightShoulder => AndroidKeyCode::ButtonR1,
|
||||||
|
GamepadButton::DpadUp => AndroidKeyCode::DpadUp,
|
||||||
|
GamepadButton::DpadDown => AndroidKeyCode::DpadDown,
|
||||||
|
GamepadButton::DpadLeft => AndroidKeyCode::DpadLeft,
|
||||||
|
GamepadButton::DpadRight => AndroidKeyCode::DpadRight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `crates/molotov-core/src/input/mod.rs` to expose `pub mod gamepad;`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cargo test -p molotov-core --test gamepad_mapping_test`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/molotov-core/
|
||||||
|
git commit -m "feat(input): implement Gamepad button translation and Android keycodes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Dual-Motor Force Feedback & Trigger Rumble
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `crates/molotov-core/src/input/haptics.rs`
|
||||||
|
- Modify: `crates/molotov-core/src/input/mod.rs`
|
||||||
|
- Test: `crates/molotov-core/tests/haptics_test.rs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: None
|
||||||
|
- Produces:
|
||||||
|
- `molotov_core::input::haptics::RumbleManager`
|
||||||
|
- `molotov_core::input::haptics::RumbleState`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test for dual-motor rumble and trigger actuation**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// crates/molotov-core/tests/haptics_test.rs
|
||||||
|
use molotov_core::input::haptics::RumbleManager;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rumble_actuation_and_clamping() {
|
||||||
|
let mut manager = RumbleManager::new();
|
||||||
|
let pad_id = 1;
|
||||||
|
|
||||||
|
// Actuate rumble: low_freq = 0.5 (32767), high_freq = 1.0 (65535), duration = 250ms
|
||||||
|
manager.rumble(pad_id, 0.5, 1.0, 250);
|
||||||
|
let state = manager.get_state(pad_id).expect("Should have rumble state");
|
||||||
|
assert_eq!(state.low_freq_motor, 32767);
|
||||||
|
assert_eq!(state.high_freq_motor, 65535);
|
||||||
|
assert_eq!(state.duration_ms, 250);
|
||||||
|
|
||||||
|
// Stop rumble
|
||||||
|
manager.stop(pad_id);
|
||||||
|
let stopped = manager.get_state(pad_id).expect("Should have rumble state");
|
||||||
|
assert_eq!(stopped.low_freq_motor, 0);
|
||||||
|
assert_eq!(stopped.high_freq_motor, 0);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cargo test -p molotov-core --test haptics_test`
|
||||||
|
Expected: FAIL with unexported module `haptics`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement RumbleManager**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// crates/molotov-core/src/input/haptics.rs
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
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 struct RumbleManager {
|
||||||
|
states: HashMap<u32, RumbleState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RumbleManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RumbleManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
states: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rumble(&mut self, gamepad_id: u32, low_freq: f32, high_freq: f32, duration_ms: u32) {
|
||||||
|
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 entry = self.states.entry(gamepad_id).or_default();
|
||||||
|
entry.low_freq_motor = low;
|
||||||
|
entry.high_freq_motor = high;
|
||||||
|
entry.duration_ms = duration_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rumble_triggers(&mut self, gamepad_id: u32, left: f32, right: f32, duration_ms: u32) {
|
||||||
|
let l = (left.clamp(0.0, 1.0) * 65535.0) as u16;
|
||||||
|
let r = (right.clamp(0.0, 1.0) * 65535.0) as u16;
|
||||||
|
|
||||||
|
let entry = self.states.entry(gamepad_id).or_default();
|
||||||
|
entry.left_trigger = l;
|
||||||
|
entry.right_trigger = r;
|
||||||
|
entry.duration_ms = duration_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&mut self, gamepad_id: u32) {
|
||||||
|
if let Some(entry) = self.states.get_mut(&gamepad_id) {
|
||||||
|
entry.low_freq_motor = 0;
|
||||||
|
entry.high_freq_motor = 0;
|
||||||
|
entry.left_trigger = 0;
|
||||||
|
entry.right_trigger = 0;
|
||||||
|
entry.duration_ms = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_state(&self, gamepad_id: u32) -> Option<RumbleState> {
|
||||||
|
self.states.get(&gamepad_id).copied()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `crates/molotov-core/src/input/mod.rs` to expose `pub mod haptics;`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cargo test -p molotov-core --test haptics_test`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/molotov-core/
|
||||||
|
git commit -m "feat(input): implement dual-motor force feedback and trigger rumble manager"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Unified Input Manager & JNI Vibrator Bridge
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `crates/molotov-core/src/input/manager.rs`
|
||||||
|
- Modify: `crates/molotov-core/src/input/mod.rs`
|
||||||
|
- Modify: `crates/molotov-core/src/jni/framework.rs`
|
||||||
|
- Test: `crates/molotov-core/tests/input_manager_test.rs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `molotov_core::input::ime::ImeManager`, `molotov_core::input::haptics::RumbleManager`
|
||||||
|
- Produces:
|
||||||
|
- `molotov_core::input::manager::InputManager`
|
||||||
|
- Integration with Android framework class `android/os/Vibrator`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test for unified input coordination**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// crates/molotov-core/tests/input_manager_test.rs
|
||||||
|
use molotov_core::input::manager::InputManager;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_input_manager_coordination() {
|
||||||
|
let mut input = InputManager::new();
|
||||||
|
assert!(!input.ime.is_active());
|
||||||
|
|
||||||
|
// Game requests text entry
|
||||||
|
input.ime.start_text_input(Default::default());
|
||||||
|
assert!(input.ime.is_active());
|
||||||
|
|
||||||
|
// Game dispatches haptics
|
||||||
|
input.haptics.rumble(0, 0.8, 0.8, 100);
|
||||||
|
assert_eq!(input.haptics.get_state(0).unwrap().duration_ms, 100);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cargo test -p molotov-core --test input_manager_test`
|
||||||
|
Expected: FAIL with unexported module `manager`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement InputManager**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// crates/molotov-core/src/input/manager.rs
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
use super::haptics::RumbleManager;
|
||||||
|
use super::ime::ImeManager;
|
||||||
|
|
||||||
|
pub struct InputManager {
|
||||||
|
pub ime: ImeManager,
|
||||||
|
pub haptics: RumbleManager,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for InputManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
ime: ImeManager::new(),
|
||||||
|
haptics: RumbleManager::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `crates/molotov-core/src/jni/framework.rs`: add `"android/os/Vibrator".to_string()` and `"android/os/VibrationEffect".to_string()` to `known_classes`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cargo test -p molotov-core --test input_manager_test`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/molotov-core/
|
||||||
|
git commit -m "feat(input): implement unified input manager and register Vibrator classes in JNI"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: End-to-End IME & Gamepad Rumble Integration Test
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `crates/molotov-core/tests/input_haptics_test.rs`
|
||||||
|
- Modify: `crates/molotov-core/src/input/mod.rs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: All `molotov_core::input::*` modules
|
||||||
|
- Produces: Verified end-to-end IME text composition and controller rumble actuation
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the end-to-end integration test**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// crates/molotov-core/tests/input_haptics_test.rs
|
||||||
|
use molotov_core::input::gamepad::{translate_button_to_android, AndroidKeyCode, GamepadButton, GamepadType};
|
||||||
|
use molotov_core::input::ime::Rect;
|
||||||
|
use molotov_core::input::manager::InputManager;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_end_to_end_ime_and_gamepad_rumble() {
|
||||||
|
let mut manager = InputManager::new();
|
||||||
|
|
||||||
|
// 1. Text Chat IME Preedit flow
|
||||||
|
manager.ime.start_text_input(Rect { x: 50, y: 100, width: 300, height: 30 });
|
||||||
|
manager.ime.update_preedit("nihao", 5);
|
||||||
|
assert_eq!(manager.ime.preedit_text(), "nihao");
|
||||||
|
|
||||||
|
let committed = manager.ime.commit_text("你好");
|
||||||
|
assert_eq!(committed, "你好");
|
||||||
|
assert_eq!(manager.ime.preedit_text(), "");
|
||||||
|
|
||||||
|
// 2. Controller Input Translation
|
||||||
|
let key = translate_button_to_android(GamepadButton::South, GamepadType::Xbox);
|
||||||
|
assert_eq!(key, AndroidKeyCode::ButtonA);
|
||||||
|
|
||||||
|
// 3. Controller Haptic Actuation
|
||||||
|
manager.haptics.rumble(0, 0.75, 0.25, 150);
|
||||||
|
let state = manager.haptics.get_state(0).unwrap();
|
||||||
|
assert_eq!(state.low_freq_motor, (0.75 * 65535.0) as u16);
|
||||||
|
assert_eq!(state.high_freq_motor, (0.25 * 65535.0) as u16);
|
||||||
|
assert_eq!(state.duration_ms, 150);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cargo test -p molotov-core --test input_haptics_test`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/molotov-core/tests/input_haptics_test.rs
|
||||||
|
git commit -m "test(input): implement end-to-end IME text editing and gamepad rumble integration test"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
Reference in a new issue