diff --git a/docs/superpowers/plans/2026-09-29-molotov-phase2d-input-haptics.md b/docs/superpowers/plans/2026-09-29-molotov-phase2d-input-haptics.md new file mode 100644 index 0000000..6538859 --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-molotov-phase2d-input-haptics.md @@ -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, +} + +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 { + 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" +``` + +---