This repository has been archived on 2026-10-10. You can view files and clone it, but cannot push or open issues or pull requests.
molotov/docs/superpowers/plans/2026-09-29-molotov-phase2d-input-haptics.md

18 KiB

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

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

// 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
// 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
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

// 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
// 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
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

// 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
// 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
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

// 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
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"