docs: add Phase 2D Input & Haptics implementation plan

This commit is contained in:
katR 2026-09-30 06:55:29 +05:30
parent 27dc294b5f
commit be35eaaffd

View file

@ -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"
```
---