diff --git a/.gitignore b/.gitignore index b291c05ea..534edb3e0 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/app/src/main/assets/inputcontrols/profiles/controls-1.icp b/app/src/main/assets/inputcontrols/profiles/controls-1.icp index 9edb7f0cc..62c44bd55 100644 --- a/app/src/main/assets/inputcontrols/profiles/controls-1.icp +++ b/app/src/main/assets/inputcontrols/profiles/controls-1.icp @@ -1 +1,64 @@ -{"id":1,"name":"RTS","cursorSpeed":1,"elements":[{"type":"BUTTON","shape":"SQUARE","bindings":["KEY_DEL","NONE","NONE","NONE"],"scale":1,"x":0.12745098769664764,"y":0.35555556416511536,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"SQUARE","bindings":["KEY_DOWN","NONE","NONE","NONE"],"scale":1,"x":0.12745098769664764,"y":0.7555555701255798,"toggleSwitch":false,"text":"","iconId":5},{"type":"BUTTON","shape":"SQUARE","bindings":["KEY_RIGHT","NONE","NONE","NONE"],"scale":1,"x":0.12745098769664764,"y":0.6222222447395325,"toggleSwitch":false,"text":"","iconId":4},{"type":"BUTTON","shape":"SQUARE","bindings":["KEY_LEFT","NONE","NONE","NONE"],"scale":1,"x":0.06862745434045792,"y":0.6222222447395325,"toggleSwitch":false,"text":"","iconId":2},{"type":"BUTTON","shape":"SQUARE","bindings":["KEY_UP","NONE","NONE","NONE"],"scale":1,"x":0.12745098769664764,"y":0.4888888895511627,"toggleSwitch":false,"text":"","iconId":3},{"type":"BUTTON","shape":"SQUARE","bindings":["KEY_ESC","NONE","NONE","NONE"],"scale":1,"x":0.06862745434045792,"y":0.35555556416511536,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"SQUARE","bindings":["MOUSE_RIGHT_BUTTON","NONE","NONE","NONE"],"scale":1,"x":0.8721405267715454,"y":0.7555555701255798,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"SQUARE","bindings":["KEY_TAB","NONE","NONE","NONE"],"scale":1,"x":0.06862745434045792,"y":0.4888888895511627,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"SQUARE","bindings":["KEY_SHIFT_L","NONE","NONE","NONE"],"scale":1,"x":0.06862745434045792,"y":0.7555555701255798,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"SQUARE","bindings":["KEY_CTRL_L","NONE","NONE","NONE"],"scale":1,"x":0.06862745434045792,"y":0.8888888955116272,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"SQUARE","bindings":["KEY_ALT_L","NONE","NONE","NONE"],"scale":1,"x":0.12745098769664764,"y":0.8888888955116272,"toggleSwitch":false,"text":"","iconId":0},{"type":"RANGE_BUTTON","shape":"CIRCLE","bindings":["NONE","NONE","NONE","NONE","NONE","NONE"],"scale":1,"x":0.1568627506494522,"y":0.08888889104127884,"toggleSwitch":false,"text":"","iconId":0,"range":"FROM_0_TO_9"},{"type":"BUTTON","shape":"SQUARE","bindings":["KEY_SPACE","NONE","NONE","NONE"],"scale":1,"x":0.8721405267715454,"y":0.8888888955116272,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"SQUARE","bindings":["KEY_BKSP","NONE","NONE","NONE"],"scale":1,"x":0.9309640526771545,"y":0.7555555701255798,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"SQUARE","bindings":["KEY_ENTER","NONE","NONE","NONE"],"scale":1,"x":0.9309640526771545,"y":0.8888888955116272,"toggleSwitch":false,"text":"","iconId":0},{"type":"RANGE_BUTTON","shape":"CIRCLE","bindings":["NONE","NONE","NONE","NONE","NONE","NONE"],"scale":1,"x":0.843137264251709,"y":0.08888889104127884,"toggleSwitch":false,"text":"","iconId":0}]} \ No newline at end of file +{ + "id": 1, + "name": "FPS/TPS Template", + "cursorSpeed": 1, + "physicalStickDeadZone": 0.15, + "physicalStickSensitivity": 3.0, + "physicalDpadDeadZone": 0.15, + "virtualStickDeadZone": 0.15, + "virtualStickSensitivity": 3.0, + "virtualDpadDeadZone": 0.3, + "enableTapToClick": true, + "enableTwoFingerRightClick": true, + "disableTouchpadMouse": false, + "touchscreenMode": false, + "elements": [ + {"type":"STICK","shape":"CIRCLE","bindings":["KEY_W","KEY_D","KEY_S","KEY_A"],"scale":1,"x":0.1,"y":0.65,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"STICK","shape":"CIRCLE","bindings":["MOUSE_MOVE_UP","MOUSE_MOVE_RIGHT","MOUSE_MOVE_DOWN","MOUSE_MOVE_LEFT"],"scale":1,"x":0.78,"y":0.65,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_SPACE","NONE","NONE","NONE"],"scale":1,"x":0.87,"y":0.45,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_CTRL_L","NONE","NONE","NONE"],"scale":1,"x":0.93,"y":0.38,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_R","NONE","NONE","NONE"],"scale":1,"x":0.81,"y":0.38,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_E","NONE","NONE","NONE"],"scale":1,"x":0.87,"y":0.31,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["MOUSE_LEFT_BUTTON","NONE","NONE","NONE"],"scale":1,"x":0.97,"y":0.22,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["MOUSE_RIGHT_BUTTON","NONE","NONE","NONE"],"scale":2,"x":0.93,"y":0.07,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["KEY_G","NONE","NONE","NONE"],"scale":1,"x":0.03,"y":0.22,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["KEY_F","NONE","NONE","NONE"],"scale":2,"x":0.07,"y":0.07,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"D_PAD","shape":"CIRCLE","bindings":["KEY_1","KEY_2","KEY_3","KEY_4"],"scale":0.85,"x":0.11,"y":0.38,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"ROUND_RECT","bindings":["KEY_ESC","NONE","NONE","NONE"],"scale":0.85,"x":0.46,"y":0.91,"toggleSwitch":false,"text":"","iconId":16}, + {"type":"BUTTON","shape":"ROUND_RECT","bindings":["KEY_TAB","NONE","NONE","NONE"],"scale":0.85,"x":0.54,"y":0.91,"toggleSwitch":false,"text":"","iconId":15}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_SHIFT_L","NONE","NONE","NONE"],"scale":0.85,"x":0.05,"y":0.73,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_V","NONE","NONE","NONE"],"scale":0.85,"x":0.95,"y":0.73,"toggleSwitch":false,"text":"","iconId":0} + ], + "controllers": [ + { + "id": "*", + "name": "Default Physical Controller", + "controllerBindings": [ + {"keyCode": 96, "binding": "KEY_SPACE"}, + {"keyCode": 97, "binding": "KEY_CTRL_L"}, + {"keyCode": 99, "binding": "KEY_R"}, + {"keyCode": 100, "binding": "KEY_E"}, + {"keyCode": 102, "binding": "KEY_G"}, + {"keyCode": 103, "binding": "MOUSE_LEFT_BUTTON"}, + {"keyCode": 104, "binding": "KEY_F"}, + {"keyCode": 105, "binding": "MOUSE_RIGHT_BUTTON"}, + {"keyCode": 106, "binding": "KEY_SHIFT_L"}, + {"keyCode": 107, "binding": "KEY_V"}, + {"keyCode": 108, "binding": "KEY_TAB"}, + {"keyCode": 109, "binding": "KEY_ESC"}, + {"keyCode": 19, "binding": "KEY_1"}, + {"keyCode": 20, "binding": "KEY_3"}, + {"keyCode": 21, "binding": "KEY_4"}, + {"keyCode": 22, "binding": "KEY_2"}, + {"keyCode": -3, "binding": "KEY_W"}, + {"keyCode": -4, "binding": "KEY_S"}, + {"keyCode": -1, "binding": "KEY_A"}, + {"keyCode": -2, "binding": "KEY_D"}, + {"keyCode": -7, "binding": "MOUSE_MOVE_UP"}, + {"keyCode": -8, "binding": "MOUSE_MOVE_DOWN"}, + {"keyCode": -5, "binding": "MOUSE_MOVE_LEFT"}, + {"keyCode": -6, "binding": "MOUSE_MOVE_RIGHT"} + ] + } + ] +} diff --git a/app/src/main/assets/inputcontrols/profiles/controls-2.icp b/app/src/main/assets/inputcontrols/profiles/controls-2.icp index d88d79942..e046ec976 100644 --- a/app/src/main/assets/inputcontrols/profiles/controls-2.icp +++ b/app/src/main/assets/inputcontrols/profiles/controls-2.icp @@ -1 +1,64 @@ -{"id":2,"name":"Template (12 buttons)","cursorSpeed":1,"elements":[{"type":"D_PAD","shape":"CIRCLE","bindings":["KEY_W","KEY_D","KEY_S","KEY_A"],"scale":1,"x":0.10784313827753067,"y":0.7333333492279053,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"CIRCLE","bindings":["NONE","NONE","NONE","NONE"],"scale":1,"x":0.8133170008659363,"y":0.7333333492279053,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"CIRCLE","bindings":["NONE","NONE","NONE","NONE"],"scale":1,"x":0.8721405267715454,"y":0.6000000238418579,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"CIRCLE","bindings":["NONE","NONE","NONE","NONE"],"scale":1,"x":0.8721405267715454,"y":0.8666666746139526,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"CIRCLE","bindings":["NONE","NONE","NONE","NONE"],"scale":1,"x":0.9309640526771545,"y":0.7333333492279053,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"RECT","bindings":["NONE","NONE","NONE","NONE"],"scale":1,"x":0.8235294222831726,"y":0.08888889104127884,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"RECT","bindings":["NONE","NONE","NONE","NONE"],"scale":1,"x":0.9215686321258545,"y":0.08888889104127884,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"CIRCLE","bindings":["NONE","NONE","NONE","NONE"],"scale":0.85,"x":0.06862745434045792,"y":0.4444444477558136,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"RECT","bindings":["NONE","NONE","NONE","NONE"],"scale":1,"x":0.0784313753247261,"y":0.08888889104127884,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"RECT","bindings":["NONE","NONE","NONE","NONE"],"scale":1,"x":0.1764705926179886,"y":0.08888889104127884,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"CIRCLE","bindings":["NONE","NONE","NONE","NONE"],"scale":0.85,"x":0.9309640526771545,"y":0.4444444477558136,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"ROUND_RECT","bindings":["NONE","NONE","NONE","NONE"],"scale":0.85,"x":0.538807213306427,"y":0.9111111164093018,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"ROUND_RECT","bindings":["NONE","NONE","NONE","NONE"],"scale":0.85,"x":0.46078431606292725,"y":0.9111111164093018,"toggleSwitch":false,"text":"","iconId":0}]} \ No newline at end of file +{ + "id": 2, + "name": "Action/RPG Template", + "cursorSpeed": 1, + "physicalStickDeadZone": 0.15, + "physicalStickSensitivity": 3.0, + "physicalDpadDeadZone": 0.15, + "virtualStickDeadZone": 0.15, + "virtualStickSensitivity": 3.0, + "virtualDpadDeadZone": 0.3, + "enableTapToClick": true, + "enableTwoFingerRightClick": true, + "disableTouchpadMouse": false, + "touchscreenMode": false, + "elements": [ + {"type":"STICK","shape":"CIRCLE","bindings":["KEY_W","KEY_D","KEY_S","KEY_A"],"scale":1,"x":0.1,"y":0.65,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"STICK","shape":"CIRCLE","bindings":["MOUSE_MOVE_UP","MOUSE_MOVE_RIGHT","MOUSE_MOVE_DOWN","MOUSE_MOVE_LEFT"],"scale":1,"x":0.78,"y":0.65,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_SPACE","NONE","NONE","NONE"],"scale":1,"x":0.87,"y":0.45,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["MOUSE_LEFT_BUTTON","NONE","NONE","NONE"],"scale":1,"x":0.93,"y":0.38,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_E","NONE","NONE","NONE"],"scale":1,"x":0.81,"y":0.38,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["MOUSE_RIGHT_BUTTON","NONE","NONE","NONE"],"scale":1,"x":0.87,"y":0.31,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["KEY_1","NONE","NONE","NONE"],"scale":1,"x":0.97,"y":0.22,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["KEY_SHIFT_L","NONE","NONE","NONE"],"scale":2,"x":0.93,"y":0.07,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["KEY_2","NONE","NONE","NONE"],"scale":1,"x":0.03,"y":0.22,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["KEY_CTRL_L","NONE","NONE","NONE"],"scale":2,"x":0.07,"y":0.07,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"D_PAD","shape":"CIRCLE","bindings":["KEY_UP","KEY_RIGHT","KEY_DOWN","KEY_LEFT"],"scale":0.85,"x":0.11,"y":0.38,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"ROUND_RECT","bindings":["KEY_ESC","NONE","NONE","NONE"],"scale":0.85,"x":0.46,"y":0.91,"toggleSwitch":false,"text":"","iconId":16}, + {"type":"BUTTON","shape":"ROUND_RECT","bindings":["KEY_TAB","NONE","NONE","NONE"],"scale":0.85,"x":0.54,"y":0.91,"toggleSwitch":false,"text":"","iconId":15}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_C","NONE","NONE","NONE"],"scale":0.85,"x":0.05,"y":0.73,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_V","NONE","NONE","NONE"],"scale":0.85,"x":0.95,"y":0.73,"toggleSwitch":false,"text":"","iconId":0} + ], + "controllers": [ + { + "id": "*", + "name": "Default Physical Controller", + "controllerBindings": [ + {"keyCode": 96, "binding": "KEY_SPACE"}, + {"keyCode": 97, "binding": "MOUSE_LEFT_BUTTON"}, + {"keyCode": 99, "binding": "KEY_E"}, + {"keyCode": 100, "binding": "MOUSE_RIGHT_BUTTON"}, + {"keyCode": 102, "binding": "KEY_2"}, + {"keyCode": 103, "binding": "KEY_1"}, + {"keyCode": 104, "binding": "KEY_CTRL_L"}, + {"keyCode": 105, "binding": "KEY_SHIFT_L"}, + {"keyCode": 106, "binding": "KEY_C"}, + {"keyCode": 107, "binding": "KEY_V"}, + {"keyCode": 108, "binding": "KEY_TAB"}, + {"keyCode": 109, "binding": "KEY_ESC"}, + {"keyCode": 19, "binding": "KEY_UP"}, + {"keyCode": 20, "binding": "KEY_DOWN"}, + {"keyCode": 21, "binding": "KEY_LEFT"}, + {"keyCode": 22, "binding": "KEY_RIGHT"}, + {"keyCode": -3, "binding": "KEY_W"}, + {"keyCode": -4, "binding": "KEY_S"}, + {"keyCode": -1, "binding": "KEY_A"}, + {"keyCode": -2, "binding": "KEY_D"}, + {"keyCode": -7, "binding": "MOUSE_MOVE_UP"}, + {"keyCode": -8, "binding": "MOUSE_MOVE_DOWN"}, + {"keyCode": -5, "binding": "MOUSE_MOVE_LEFT"}, + {"keyCode": -6, "binding": "MOUSE_MOVE_RIGHT"} + ] + } + ] +} diff --git a/app/src/main/assets/inputcontrols/profiles/controls-3.icp b/app/src/main/assets/inputcontrols/profiles/controls-3.icp index 86479db9a..99f81c895 100644 --- a/app/src/main/assets/inputcontrols/profiles/controls-3.icp +++ b/app/src/main/assets/inputcontrols/profiles/controls-3.icp @@ -1 +1,64 @@ -{"id":3,"name":"Virtual Gamepad","cursorSpeed":1,"elements":[{"type":"D_PAD","shape":"CIRCLE","bindings":["GAMEPAD_DPAD_UP","GAMEPAD_DPAD_RIGHT","GAMEPAD_DPAD_DOWN","GAMEPAD_DPAD_LEFT"],"scale":0.85,"x":0.10784313827753067,"y":0.4,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_X","NONE","NONE","NONE"],"scale":1,"x":0.8133170008659363,"y":0.4,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_Y","NONE","NONE","NONE"],"scale":1,"x":0.8721405267715454,"y":0.2666666746,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_A","NONE","NONE","NONE"],"scale":1,"x":0.8721405267715454,"y":0.5333333254,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_B","NONE","NONE","NONE"],"scale":1,"x":0.9309640526771545,"y":0.4,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"RECT","bindings":["GAMEPAD_BUTTON_R2","NONE","NONE","NONE"],"scale":2,"x":0.93,"y":0.07,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"RECT","bindings":["GAMEPAD_BUTTON_R1","NONE","NONE","NONE"],"scale":1,"x":0.97,"y":0.2222222388,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"RECT","bindings":["GAMEPAD_BUTTON_L1","NONE","NONE","NONE"],"scale":1,"x":0.03,"y":0.2222222388,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"RECT","bindings":["GAMEPAD_BUTTON_L2","NONE","NONE","NONE"],"scale":2,"x":0.07,"y":0.07,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"ROUND_RECT","bindings":["GAMEPAD_BUTTON_START","NONE","NONE","NONE"],"scale":0.85,"x":0.538807213306427,"y":0.9111111164093018,"toggleSwitch":false,"text":"","iconId":15},{"type":"BUTTON","shape":"ROUND_RECT","bindings":["GAMEPAD_BUTTON_SELECT","NONE","NONE","NONE"],"scale":0.85,"x":0.46078431606292725,"y":0.9111111164093018,"toggleSwitch":false,"text":"","iconId":16},{"type":"STICK","shape":"CIRCLE","bindings":["GAMEPAD_LEFT_THUMB_UP","GAMEPAD_LEFT_THUMB_RIGHT","GAMEPAD_LEFT_THUMB_DOWN","GAMEPAD_LEFT_THUMB_LEFT"],"scale":1,"x":0.21568627655506134,"y":0.7333333492279053,"toggleSwitch":false,"text":"","iconId":0},{"type":"STICK","shape":"CIRCLE","bindings":["GAMEPAD_RIGHT_THUMB_UP","GAMEPAD_RIGHT_THUMB_RIGHT","GAMEPAD_RIGHT_THUMB_DOWN","GAMEPAD_RIGHT_THUMB_LEFT"],"scale":1,"x":0.7843137383460999,"y":0.7333333492279053,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_L3","NONE","NONE","NONE"],"scale":0.85,"x":0.05,"y":0.7333333492279053,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_R3","NONE","NONE","NONE"],"scale":0.85,"x":0.95,"y":0.7333333492279053,"toggleSwitch":false,"text":"","iconId":0}]} +{ + "id": 3, + "name": "Virtual Gamepad", + "cursorSpeed": 1, + "physicalStickDeadZone": 0.15, + "physicalStickSensitivity": 3.0, + "physicalDpadDeadZone": 0.15, + "virtualStickDeadZone": 0.15, + "virtualStickSensitivity": 3.0, + "virtualDpadDeadZone": 0.3, + "enableTapToClick": true, + "enableTwoFingerRightClick": true, + "disableTouchpadMouse": false, + "touchscreenMode": false, + "elements": [ + {"type":"D_PAD","shape":"CIRCLE","bindings":["GAMEPAD_DPAD_UP","GAMEPAD_DPAD_RIGHT","GAMEPAD_DPAD_DOWN","GAMEPAD_DPAD_LEFT"],"scale":0.85,"x":0.10784313827753067,"y":0.4,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_X","NONE","NONE","NONE"],"scale":1,"x":0.8133170008659363,"y":0.4,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_Y","NONE","NONE","NONE"],"scale":1,"x":0.8721405267715454,"y":0.2666666746,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_A","NONE","NONE","NONE"],"scale":1,"x":0.8721405267715454,"y":0.5333333254,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_B","NONE","NONE","NONE"],"scale":1,"x":0.9309640526771545,"y":0.4,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["GAMEPAD_BUTTON_R2","NONE","NONE","NONE"],"scale":2,"x":0.93,"y":0.07,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["GAMEPAD_BUTTON_R1","NONE","NONE","NONE"],"scale":1,"x":0.97,"y":0.2222222388,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["GAMEPAD_BUTTON_L1","NONE","NONE","NONE"],"scale":1,"x":0.03,"y":0.2222222388,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["GAMEPAD_BUTTON_L2","NONE","NONE","NONE"],"scale":2,"x":0.07,"y":0.07,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"ROUND_RECT","bindings":["GAMEPAD_BUTTON_START","NONE","NONE","NONE"],"scale":0.85,"x":0.538807213306427,"y":0.9111111164093018,"toggleSwitch":false,"text":"","iconId":15}, + {"type":"BUTTON","shape":"ROUND_RECT","bindings":["GAMEPAD_BUTTON_SELECT","NONE","NONE","NONE"],"scale":0.85,"x":0.46078431606292725,"y":0.9111111164093018,"toggleSwitch":false,"text":"","iconId":16}, + {"type":"STICK","shape":"CIRCLE","bindings":["GAMEPAD_LEFT_THUMB_UP","GAMEPAD_LEFT_THUMB_RIGHT","GAMEPAD_LEFT_THUMB_DOWN","GAMEPAD_LEFT_THUMB_LEFT"],"scale":1,"x":0.21568627655506134,"y":0.7333333492279053,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"STICK","shape":"CIRCLE","bindings":["GAMEPAD_RIGHT_THUMB_UP","GAMEPAD_RIGHT_THUMB_RIGHT","GAMEPAD_RIGHT_THUMB_DOWN","GAMEPAD_RIGHT_THUMB_LEFT"],"scale":1,"x":0.7843137383460999,"y":0.7333333492279053,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_L3","NONE","NONE","NONE"],"scale":0.85,"x":0.05,"y":0.7333333492279053,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_R3","NONE","NONE","NONE"],"scale":0.85,"x":0.95,"y":0.7333333492279053,"toggleSwitch":false,"text":"","iconId":0} + ], + "controllers": [ + { + "id": "*", + "name": "Default Physical Controller", + "controllerBindings": [ + {"keyCode": 96, "binding": "GAMEPAD_BUTTON_A"}, + {"keyCode": 97, "binding": "GAMEPAD_BUTTON_B"}, + {"keyCode": 99, "binding": "GAMEPAD_BUTTON_X"}, + {"keyCode": 100, "binding": "GAMEPAD_BUTTON_Y"}, + {"keyCode": 102, "binding": "GAMEPAD_BUTTON_L1"}, + {"keyCode": 103, "binding": "GAMEPAD_BUTTON_R1"}, + {"keyCode": 104, "binding": "GAMEPAD_BUTTON_L2"}, + {"keyCode": 105, "binding": "GAMEPAD_BUTTON_R2"}, + {"keyCode": 106, "binding": "GAMEPAD_BUTTON_L3"}, + {"keyCode": 107, "binding": "GAMEPAD_BUTTON_R3"}, + {"keyCode": 108, "binding": "GAMEPAD_BUTTON_START"}, + {"keyCode": 109, "binding": "GAMEPAD_BUTTON_SELECT"}, + {"keyCode": 19, "binding": "GAMEPAD_DPAD_UP"}, + {"keyCode": 20, "binding": "GAMEPAD_DPAD_DOWN"}, + {"keyCode": 21, "binding": "GAMEPAD_DPAD_LEFT"}, + {"keyCode": 22, "binding": "GAMEPAD_DPAD_RIGHT"}, + {"keyCode": -3, "binding": "GAMEPAD_LEFT_THUMB_UP"}, + {"keyCode": -4, "binding": "GAMEPAD_LEFT_THUMB_DOWN"}, + {"keyCode": -1, "binding": "GAMEPAD_LEFT_THUMB_LEFT"}, + {"keyCode": -2, "binding": "GAMEPAD_LEFT_THUMB_RIGHT"}, + {"keyCode": -7, "binding": "GAMEPAD_RIGHT_THUMB_UP"}, + {"keyCode": -8, "binding": "GAMEPAD_RIGHT_THUMB_DOWN"}, + {"keyCode": -5, "binding": "GAMEPAD_RIGHT_THUMB_LEFT"}, + {"keyCode": -6, "binding": "GAMEPAD_RIGHT_THUMB_RIGHT"} + ] + } + ] +} diff --git a/app/src/main/assets/inputcontrols/profiles/controls-4.icp b/app/src/main/assets/inputcontrols/profiles/controls-4.icp new file mode 100644 index 000000000..47be39569 --- /dev/null +++ b/app/src/main/assets/inputcontrols/profiles/controls-4.icp @@ -0,0 +1,64 @@ +{ + "id": 4, + "name": "Racing/Driving Template", + "cursorSpeed": 1, + "physicalStickDeadZone": 0.15, + "physicalStickSensitivity": 3.0, + "physicalDpadDeadZone": 0.15, + "virtualStickDeadZone": 0.15, + "virtualStickSensitivity": 3.0, + "virtualDpadDeadZone": 0.3, + "enableTapToClick": true, + "enableTwoFingerRightClick": true, + "disableTouchpadMouse": false, + "touchscreenMode": false, + "elements": [ + {"type":"STICK","shape":"CIRCLE","bindings":["KEY_W","KEY_D","KEY_S","KEY_A"],"scale":1,"x":0.1,"y":0.65,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"STICK","shape":"CIRCLE","bindings":["MOUSE_MOVE_UP","MOUSE_MOVE_RIGHT","MOUSE_MOVE_DOWN","MOUSE_MOVE_LEFT"],"scale":1,"x":0.78,"y":0.65,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_SPACE","NONE","NONE","NONE"],"scale":1,"x":0.87,"y":0.45,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_C","NONE","NONE","NONE"],"scale":1,"x":0.93,"y":0.38,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_SHIFT_L","NONE","NONE","NONE"],"scale":1,"x":0.81,"y":0.38,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_H","NONE","NONE","NONE"],"scale":1,"x":0.87,"y":0.31,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["KEY_E","NONE","NONE","NONE"],"scale":1,"x":0.97,"y":0.22,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["KEY_V","NONE","NONE","NONE"],"scale":2,"x":0.93,"y":0.07,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["KEY_Q","NONE","NONE","NONE"],"scale":1,"x":0.03,"y":0.22,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["KEY_R","NONE","NONE","NONE"],"scale":2,"x":0.07,"y":0.07,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"D_PAD","shape":"CIRCLE","bindings":["KEY_UP","KEY_RIGHT","KEY_DOWN","KEY_LEFT"],"scale":0.85,"x":0.11,"y":0.38,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"ROUND_RECT","bindings":["KEY_ESC","NONE","NONE","NONE"],"scale":0.85,"x":0.46,"y":0.91,"toggleSwitch":false,"text":"","iconId":16}, + {"type":"BUTTON","shape":"ROUND_RECT","bindings":["KEY_M","NONE","NONE","NONE"],"scale":0.85,"x":0.54,"y":0.91,"toggleSwitch":false,"text":"","iconId":15}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_CTRL_L","NONE","NONE","NONE"],"scale":0.85,"x":0.05,"y":0.73,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_TAB","NONE","NONE","NONE"],"scale":0.85,"x":0.95,"y":0.73,"toggleSwitch":false,"text":"","iconId":0} + ], + "controllers": [ + { + "id": "*", + "name": "Default Physical Controller", + "controllerBindings": [ + {"keyCode": 96, "binding": "KEY_SPACE"}, + {"keyCode": 97, "binding": "KEY_C"}, + {"keyCode": 99, "binding": "KEY_SHIFT_L"}, + {"keyCode": 100, "binding": "KEY_H"}, + {"keyCode": 102, "binding": "KEY_Q"}, + {"keyCode": 103, "binding": "KEY_E"}, + {"keyCode": 104, "binding": "KEY_R"}, + {"keyCode": 105, "binding": "KEY_V"}, + {"keyCode": 106, "binding": "KEY_CTRL_L"}, + {"keyCode": 107, "binding": "KEY_TAB"}, + {"keyCode": 108, "binding": "KEY_M"}, + {"keyCode": 109, "binding": "KEY_ESC"}, + {"keyCode": 19, "binding": "KEY_UP"}, + {"keyCode": 20, "binding": "KEY_DOWN"}, + {"keyCode": 21, "binding": "KEY_LEFT"}, + {"keyCode": 22, "binding": "KEY_RIGHT"}, + {"keyCode": -3, "binding": "KEY_W"}, + {"keyCode": -4, "binding": "KEY_S"}, + {"keyCode": -1, "binding": "KEY_A"}, + {"keyCode": -2, "binding": "KEY_D"}, + {"keyCode": -7, "binding": "MOUSE_MOVE_UP"}, + {"keyCode": -8, "binding": "MOUSE_MOVE_DOWN"}, + {"keyCode": -5, "binding": "MOUSE_MOVE_LEFT"}, + {"keyCode": -6, "binding": "MOUSE_MOVE_RIGHT"} + ] + } + ] +} diff --git a/app/src/main/assets/inputcontrols/profiles/controls-5.icp b/app/src/main/assets/inputcontrols/profiles/controls-5.icp new file mode 100644 index 000000000..8599f5e05 --- /dev/null +++ b/app/src/main/assets/inputcontrols/profiles/controls-5.icp @@ -0,0 +1,64 @@ +{ + "id": 5, + "name": "Strategy/RTS Template", + "cursorSpeed": 1, + "physicalStickDeadZone": 0.15, + "physicalStickSensitivity": 3.0, + "physicalDpadDeadZone": 0.15, + "virtualStickDeadZone": 0.15, + "virtualStickSensitivity": 3.0, + "virtualDpadDeadZone": 0.3, + "enableTapToClick": true, + "enableTwoFingerRightClick": true, + "disableTouchpadMouse": false, + "touchscreenMode": false, + "elements": [ + {"type":"TRACKPAD","shape":"ROUND_RECT","bindings":["MOUSE_MOVE_UP","MOUSE_MOVE_RIGHT","MOUSE_MOVE_DOWN","MOUSE_MOVE_LEFT"],"scale":1.5,"x":0.5,"y":0.5,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_A","NONE","NONE","NONE"],"scale":1,"x":0.87,"y":0.45,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_S","NONE","NONE","NONE"],"scale":1,"x":0.93,"y":0.38,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_Q","NONE","NONE","NONE"],"scale":1,"x":0.81,"y":0.38,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_W","NONE","NONE","NONE"],"scale":1,"x":0.87,"y":0.31,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["MOUSE_LEFT_BUTTON","NONE","NONE","NONE"],"scale":1,"x":0.97,"y":0.22,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["MOUSE_RIGHT_BUTTON","NONE","NONE","NONE"],"scale":2,"x":0.93,"y":0.07,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["KEY_CTRL_L","NONE","NONE","NONE"],"scale":1,"x":0.03,"y":0.22,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["KEY_TAB","NONE","NONE","NONE"],"scale":2,"x":0.07,"y":0.07,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"D_PAD","shape":"CIRCLE","bindings":["KEY_1","KEY_2","KEY_3","KEY_4"],"scale":0.85,"x":0.11,"y":0.82,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"ROUND_RECT","bindings":["KEY_ESC","NONE","NONE","NONE"],"scale":0.85,"x":0.46,"y":0.91,"toggleSwitch":false,"text":"","iconId":16}, + {"type":"BUTTON","shape":"ROUND_RECT","bindings":["KEY_M","NONE","NONE","NONE"],"scale":0.85,"x":0.54,"y":0.91,"toggleSwitch":false,"text":"","iconId":15}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_E","NONE","NONE","NONE"],"scale":0.85,"x":0.82,"y":0.82,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_R","NONE","NONE","NONE"],"scale":0.85,"x":0.89,"y":0.82,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"RANGE_BUTTON","shape":"CIRCLE","bindings":["NONE","NONE","NONE","NONE","NONE","NONE"],"scale":0.85,"x":0.2,"y":0.09,"toggleSwitch":false,"text":"","iconId":0,"range":"FROM_0_TO_9"} + ], + "controllers": [ + { + "id": "*", + "name": "Default Physical Controller", + "controllerBindings": [ + {"keyCode": 96, "binding": "KEY_A"}, + {"keyCode": 97, "binding": "KEY_S"}, + {"keyCode": 99, "binding": "KEY_Q"}, + {"keyCode": 100, "binding": "KEY_W"}, + {"keyCode": 102, "binding": "KEY_CTRL_L"}, + {"keyCode": 103, "binding": "MOUSE_LEFT_BUTTON"}, + {"keyCode": 104, "binding": "KEY_TAB"}, + {"keyCode": 105, "binding": "MOUSE_RIGHT_BUTTON"}, + {"keyCode": 106, "binding": "KEY_E"}, + {"keyCode": 107, "binding": "KEY_R"}, + {"keyCode": 108, "binding": "KEY_M"}, + {"keyCode": 109, "binding": "KEY_ESC"}, + {"keyCode": 19, "binding": "KEY_1"}, + {"keyCode": 20, "binding": "KEY_3"}, + {"keyCode": 21, "binding": "KEY_4"}, + {"keyCode": 22, "binding": "KEY_2"}, + {"keyCode": -3, "binding": "KEY_UP"}, + {"keyCode": -4, "binding": "KEY_DOWN"}, + {"keyCode": -1, "binding": "KEY_LEFT"}, + {"keyCode": -2, "binding": "KEY_RIGHT"}, + {"keyCode": -7, "binding": "MOUSE_MOVE_UP"}, + {"keyCode": -8, "binding": "MOUSE_MOVE_DOWN"}, + {"keyCode": -5, "binding": "MOUSE_MOVE_LEFT"}, + {"keyCode": -6, "binding": "MOUSE_MOVE_RIGHT"} + ] + } + ] +} diff --git a/app/src/main/assets/inputcontrols/profiles/controls-6.icp b/app/src/main/assets/inputcontrols/profiles/controls-6.icp new file mode 100644 index 000000000..2536017c5 --- /dev/null +++ b/app/src/main/assets/inputcontrols/profiles/controls-6.icp @@ -0,0 +1,63 @@ +{ + "id": 6, + "name": "Platformer/2D Template", + "cursorSpeed": 1, + "physicalStickDeadZone": 0.15, + "physicalStickSensitivity": 3.0, + "physicalDpadDeadZone": 0.15, + "virtualStickDeadZone": 0.15, + "virtualStickSensitivity": 3.0, + "virtualDpadDeadZone": 0.3, + "enableTapToClick": true, + "enableTwoFingerRightClick": true, + "disableTouchpadMouse": false, + "touchscreenMode": false, + "elements": [ + {"type":"D_PAD","shape":"CIRCLE","bindings":["KEY_W","KEY_D","KEY_S","KEY_A"],"scale":1,"x":0.11,"y":0.65,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"STICK","shape":"CIRCLE","bindings":["KEY_W","KEY_D","KEY_S","KEY_A"],"scale":1,"x":0.11,"y":0.38,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_SPACE","NONE","NONE","NONE"],"scale":1,"x":0.87,"y":0.45,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_Z","NONE","NONE","NONE"],"scale":1,"x":0.93,"y":0.38,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_X","NONE","NONE","NONE"],"scale":1,"x":0.81,"y":0.38,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_C","NONE","NONE","NONE"],"scale":1,"x":0.87,"y":0.31,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["KEY_Q","NONE","NONE","NONE"],"scale":1,"x":0.97,"y":0.22,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["KEY_SHIFT_L","NONE","NONE","NONE"],"scale":2,"x":0.93,"y":0.07,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["KEY_E","NONE","NONE","NONE"],"scale":1,"x":0.03,"y":0.22,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["KEY_CTRL_L","NONE","NONE","NONE"],"scale":2,"x":0.07,"y":0.07,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"ROUND_RECT","bindings":["KEY_ESC","NONE","NONE","NONE"],"scale":0.85,"x":0.46,"y":0.91,"toggleSwitch":false,"text":"","iconId":16}, + {"type":"BUTTON","shape":"ROUND_RECT","bindings":["KEY_TAB","NONE","NONE","NONE"],"scale":0.85,"x":0.54,"y":0.91,"toggleSwitch":false,"text":"","iconId":15}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_R","NONE","NONE","NONE"],"scale":0.85,"x":0.82,"y":0.75,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_F","NONE","NONE","NONE"],"scale":0.85,"x":0.89,"y":0.75,"toggleSwitch":false,"text":"","iconId":0} + ], + "controllers": [ + { + "id": "*", + "name": "Default Physical Controller", + "controllerBindings": [ + {"keyCode": 96, "binding": "KEY_SPACE"}, + {"keyCode": 97, "binding": "KEY_Z"}, + {"keyCode": 99, "binding": "KEY_X"}, + {"keyCode": 100, "binding": "KEY_C"}, + {"keyCode": 102, "binding": "KEY_E"}, + {"keyCode": 103, "binding": "KEY_Q"}, + {"keyCode": 104, "binding": "KEY_CTRL_L"}, + {"keyCode": 105, "binding": "KEY_SHIFT_L"}, + {"keyCode": 106, "binding": "KEY_R"}, + {"keyCode": 107, "binding": "KEY_F"}, + {"keyCode": 108, "binding": "KEY_TAB"}, + {"keyCode": 109, "binding": "KEY_ESC"}, + {"keyCode": 19, "binding": "KEY_W"}, + {"keyCode": 20, "binding": "KEY_S"}, + {"keyCode": 21, "binding": "KEY_A"}, + {"keyCode": 22, "binding": "KEY_D"}, + {"keyCode": -3, "binding": "KEY_W"}, + {"keyCode": -4, "binding": "KEY_S"}, + {"keyCode": -1, "binding": "KEY_A"}, + {"keyCode": -2, "binding": "KEY_D"}, + {"keyCode": -7, "binding": "KEY_UP"}, + {"keyCode": -8, "binding": "KEY_DOWN"}, + {"keyCode": -5, "binding": "KEY_LEFT"}, + {"keyCode": -6, "binding": "KEY_RIGHT"} + ] + } + ] +} diff --git a/app/src/main/assets/inputcontrols/profiles/controls-7.icp b/app/src/main/assets/inputcontrols/profiles/controls-7.icp new file mode 100644 index 000000000..63606c818 --- /dev/null +++ b/app/src/main/assets/inputcontrols/profiles/controls-7.icp @@ -0,0 +1,64 @@ +{ + "id": 7, + "name": "Default Virtual Gamepad", + "cursorSpeed": 1, + "physicalStickDeadZone": 0.15, + "physicalStickSensitivity": 3.0, + "physicalDpadDeadZone": 0.15, + "virtualStickDeadZone": 0.15, + "virtualStickSensitivity": 3.0, + "virtualDpadDeadZone": 0.3, + "enableTapToClick": true, + "enableTwoFingerRightClick": true, + "disableTouchpadMouse": false, + "touchscreenMode": false, + "elements": [ + {"type":"D_PAD","shape":"CIRCLE","bindings":["GAMEPAD_DPAD_UP","GAMEPAD_DPAD_RIGHT","GAMEPAD_DPAD_DOWN","GAMEPAD_DPAD_LEFT"],"scale":0.85,"x":0.10784313827753067,"y":0.4,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_X","NONE","NONE","NONE"],"scale":1,"x":0.8133170008659363,"y":0.4,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_Y","NONE","NONE","NONE"],"scale":1,"x":0.8721405267715454,"y":0.2666666746,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_A","NONE","NONE","NONE"],"scale":1,"x":0.8721405267715454,"y":0.5333333254,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_B","NONE","NONE","NONE"],"scale":1,"x":0.9309640526771545,"y":0.4,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["GAMEPAD_BUTTON_R2","NONE","NONE","NONE"],"scale":2,"x":0.93,"y":0.07,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["GAMEPAD_BUTTON_R1","NONE","NONE","NONE"],"scale":1,"x":0.97,"y":0.2222222388,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["GAMEPAD_BUTTON_L1","NONE","NONE","NONE"],"scale":1,"x":0.03,"y":0.2222222388,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"RECT","bindings":["GAMEPAD_BUTTON_L2","NONE","NONE","NONE"],"scale":2,"x":0.07,"y":0.07,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"ROUND_RECT","bindings":["GAMEPAD_BUTTON_START","NONE","NONE","NONE"],"scale":0.85,"x":0.538807213306427,"y":0.9111111164093018,"toggleSwitch":false,"text":"","iconId":15}, + {"type":"BUTTON","shape":"ROUND_RECT","bindings":["GAMEPAD_BUTTON_SELECT","NONE","NONE","NONE"],"scale":0.85,"x":0.46078431606292725,"y":0.9111111164093018,"toggleSwitch":false,"text":"","iconId":16}, + {"type":"STICK","shape":"CIRCLE","bindings":["GAMEPAD_LEFT_THUMB_UP","GAMEPAD_LEFT_THUMB_RIGHT","GAMEPAD_LEFT_THUMB_DOWN","GAMEPAD_LEFT_THUMB_LEFT"],"scale":1,"x":0.21568627655506134,"y":0.7333333492279053,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"STICK","shape":"CIRCLE","bindings":["GAMEPAD_RIGHT_THUMB_UP","GAMEPAD_RIGHT_THUMB_RIGHT","GAMEPAD_RIGHT_THUMB_DOWN","GAMEPAD_RIGHT_THUMB_LEFT"],"scale":1,"x":0.7843137383460999,"y":0.7333333492279053,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_L3","NONE","NONE","NONE"],"scale":0.85,"x":0.05,"y":0.7333333492279053,"toggleSwitch":false,"text":"","iconId":0}, + {"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_R3","NONE","NONE","NONE"],"scale":0.85,"x":0.95,"y":0.7333333492279053,"toggleSwitch":false,"text":"","iconId":0} + ], + "controllers": [ + { + "id": "*", + "name": "Default Physical Controller", + "controllerBindings": [ + {"keyCode": 96, "binding": "GAMEPAD_BUTTON_A"}, + {"keyCode": 97, "binding": "GAMEPAD_BUTTON_B"}, + {"keyCode": 99, "binding": "GAMEPAD_BUTTON_X"}, + {"keyCode": 100, "binding": "GAMEPAD_BUTTON_Y"}, + {"keyCode": 102, "binding": "GAMEPAD_BUTTON_L1"}, + {"keyCode": 103, "binding": "GAMEPAD_BUTTON_R1"}, + {"keyCode": 104, "binding": "GAMEPAD_BUTTON_L2"}, + {"keyCode": 105, "binding": "GAMEPAD_BUTTON_R2"}, + {"keyCode": 106, "binding": "GAMEPAD_BUTTON_L3"}, + {"keyCode": 107, "binding": "GAMEPAD_BUTTON_R3"}, + {"keyCode": 108, "binding": "GAMEPAD_BUTTON_START"}, + {"keyCode": 109, "binding": "GAMEPAD_BUTTON_SELECT"}, + {"keyCode": 19, "binding": "GAMEPAD_DPAD_UP"}, + {"keyCode": 20, "binding": "GAMEPAD_DPAD_DOWN"}, + {"keyCode": 21, "binding": "GAMEPAD_DPAD_LEFT"}, + {"keyCode": 22, "binding": "GAMEPAD_DPAD_RIGHT"}, + {"keyCode": -3, "binding": "GAMEPAD_LEFT_THUMB_UP"}, + {"keyCode": -4, "binding": "GAMEPAD_LEFT_THUMB_DOWN"}, + {"keyCode": -1, "binding": "GAMEPAD_LEFT_THUMB_LEFT"}, + {"keyCode": -2, "binding": "GAMEPAD_LEFT_THUMB_RIGHT"}, + {"keyCode": -7, "binding": "GAMEPAD_RIGHT_THUMB_UP"}, + {"keyCode": -8, "binding": "GAMEPAD_RIGHT_THUMB_DOWN"}, + {"keyCode": -5, "binding": "GAMEPAD_RIGHT_THUMB_LEFT"}, + {"keyCode": -6, "binding": "GAMEPAD_RIGHT_THUMB_RIGHT"} + ] + } + ] +} diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt index 547b9b40c..edb5c80d1 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt @@ -96,6 +96,8 @@ import com.winlator.core.GPUHelper import com.winlator.core.WineInfo import com.winlator.core.WineInfo.MAIN_WINE_VERSION import com.winlator.fexcore.FEXCoreManager +import com.winlator.inputcontrols.InputControlsManager +import com.winlator.inputcontrols.ControlsProfile import java.util.Locale /** @@ -123,6 +125,7 @@ fun ContainerConfigDialog( default: Boolean = false, title: String, initialConfig: ContainerData = ContainerData(), + container: Container? = null, onDismissRequest: () -> Unit, onSave: (ContainerData) -> Unit, ) { @@ -1394,6 +1397,23 @@ fun ContainerConfigDialog( ) } if (selectedTab == 3) SettingsGroup() { + val context = LocalContext.current + var showProfileEditor: ControlsProfile? by remember { mutableStateOf(null) } + + // Profile Selection Card - Prominent placement at top + app.gamenative.ui.component.settings.ProfileSelectionCard( + context = context, + selectedProfileId = config.controlsProfileId, + onProfileSelected = { profileId -> + config = config.copy(controlsProfileId = profileId) + }, + onEditProfile = { profile -> + showProfileEditor = profile + }, + container = container + ) + + // Wine API Settings Section if (!default) { SettingsSwitch( colors = settingsTileColorsAlt(), @@ -1404,7 +1424,7 @@ fun ContainerConfigDialog( }, ) } - // Enable XInput API + SettingsSwitch( colors = settingsTileColorsAlt(), title = { Text(text = stringResource(R.string.enable_xinput_api)) }, @@ -1413,7 +1433,7 @@ fun ContainerConfigDialog( config = config.copy(enableXInput = it) } ) - // Enable DirectInput API + SettingsSwitch( colors = settingsTileColorsAlt(), title = { Text(text = stringResource(R.string.enable_directinput_api)) }, @@ -1422,7 +1442,30 @@ fun ContainerConfigDialog( config = config.copy(enableDInput = it) } ) - // DirectInput Mapper Type + // Auto-hide Controls + SettingsSwitch( + colors = settingsTileColors(), + title = { Text(text = "Auto-hide On-Screen Controls") }, + subtitle = { Text(text = "Automatically hide on-screen controls when a physical controller is connected") }, + state = config.autoHideControls, + onCheckedChange = { + config = config.copy(autoHideControls = it) + } + ) + + // Show profile editor dialog if requested + showProfileEditor?.let { profileToEdit -> + app.gamenative.ui.component.dialog.UnifiedProfileEditorDialog( + profile = profileToEdit, + initialTab = 0, + onDismiss = { showProfileEditor = null }, + onSave = { + showProfileEditor = null + // Refresh config to pick up any profile changes + } + ) + } + SettingsListDropdown( colors = settingsTileColors(), title = { Text(text = stringResource(R.string.directinput_mapper_type)) }, @@ -1440,7 +1483,7 @@ fun ContainerConfigDialog( onCheckedChange = { config = config.copy(disableMouseInput = it) } ) - // Touchscreen mode + // Auto-hide Controls SettingsSwitch( colors = settingsTileColorsAlt(), title = { Text(text = stringResource(R.string.touchscreen_mode)) }, diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ControllerBindingDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ControllerBindingDialog.kt new file mode 100644 index 000000000..33a840d09 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ControllerBindingDialog.kt @@ -0,0 +1,330 @@ +package app.gamenative.ui.component.dialog + +import android.util.Log +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.winlator.inputcontrols.Binding + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ControllerBindingDialog( + buttonName: String, + currentBinding: Binding?, + onDismiss: () -> Unit, + onBindingSelected: (Binding?) -> Unit +) { + Log.d("ControllerBindingDialog", "Opening binding dialog for button: $buttonName, current binding: ${currentBinding?.name}") + + var searchQuery by remember { mutableStateOf("") } + var selectedCategory by remember { mutableStateOf(0) } // 0 = Keyboard, 1 = Mouse, 2 = Gamepad + + // Get bindings by category + val keyboardBindings = remember { Binding.keyboardBindingValues().toList() } + val mouseBindings = remember { Binding.mouseBindingValues().toList() } + val gamepadBindings = remember { Binding.gamepadBindingValues().toList() } + + val currentBindings = when (selectedCategory) { + 0 -> keyboardBindings + 1 -> mouseBindings + 2 -> gamepadBindings + else -> keyboardBindings + } + + // Filter bindings based on search + val filteredBindings = remember(searchQuery, currentBindings) { + if (searchQuery.isBlank()) { + currentBindings + } else { + currentBindings.filter { + it.toString().contains(searchQuery, ignoreCase = true) + } + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, // Allow custom width beyond platform default + dismissOnBackPress = true, + dismissOnClickOutside = false + ) + ) { + Surface( + modifier = Modifier + .fillMaxWidth(0.98f) // Nearly full width for better space utilization + .fillMaxHeight(0.92f), // Taller to maximize vertical space + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface + ) { + Column(modifier = Modifier.fillMaxSize()) { + // Header with title, current binding, and close button + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Title and current binding + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = "Bind: $buttonName", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + if (currentBinding != null) { + Text( + text = "Current: $currentBinding", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + + // Close button + IconButton( + onClick = onDismiss, + modifier = Modifier.size(40.dp) + ) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + + // Two-column layout: Left = Controls, Right = Bindings list + Row( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Left column: Search and Category selection + Column( + modifier = Modifier + .weight(0.4f) + .fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Search field + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { Text("Search...") }, + leadingIcon = { + Icon( + Icons.Default.Search, + contentDescription = "Search", + modifier = Modifier.size(20.dp) + ) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + // Category buttons (vertical stack) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = "Category", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 4.dp) + ) + + // Keyboard button + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { selectedCategory = 0 }, + color = if (selectedCategory == 0) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small + ) { + Text( + text = "Keyboard", + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (selectedCategory == 0) FontWeight.Bold else FontWeight.Normal, + modifier = Modifier.padding(12.dp) + ) + } + + // Mouse button + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { selectedCategory = 1 }, + color = if (selectedCategory == 1) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small + ) { + Text( + text = "Mouse", + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (selectedCategory == 1) FontWeight.Bold else FontWeight.Normal, + modifier = Modifier.padding(12.dp) + ) + } + + // Gamepad button + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { selectedCategory = 2 }, + color = if (selectedCategory == 2) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small + ) { + Text( + text = "Gamepad", + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (selectedCategory == 2) FontWeight.Bold else FontWeight.Normal, + modifier = Modifier.padding(12.dp) + ) + } + + // Clear Binding button + if (currentBinding != null) { + Spacer(modifier = Modifier.height(4.dp)) + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { + Log.d("ControllerBindingDialog", "Clearing binding for $buttonName") + onBindingSelected(null) + }, + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + shape = MaterialTheme.shapes.small + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Close, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp) + ) + Text( + text = "Clear Binding", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error + ) + } + } + } + } + } + + // Right column: Bindings list + Column( + modifier = Modifier + .weight(0.6f) + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (filteredBindings.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(100.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "No bindings found", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + filteredBindings.forEach { binding -> + BindingOption( + binding = binding, + isSelected = binding == currentBinding, + onClick = { + Log.d("ControllerBindingDialog", "Binding selected for $buttonName: ${binding.name}") + onBindingSelected(binding) + } + ) + } + } + } + } + } + } + } +} + +@Composable +fun BindingOption( + binding: Binding, + isSelected: Boolean, + onClick: () -> Unit +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + color = if (isSelected) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = binding.toString(), + style = MaterialTheme.typography.bodyLarge, + color = if (isSelected) + MaterialTheme.colorScheme.onPrimaryContainer + else + MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary + ) + } + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ControllerBindingPresets.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ControllerBindingPresets.kt new file mode 100644 index 000000000..327f6c3e9 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ControllerBindingPresets.kt @@ -0,0 +1,614 @@ +package app.gamenative.ui.component.dialog + +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.winlator.inputcontrols.ControlsProfile + +/** + * Controller binding presets and reset functionality + * + * Provides quick preset configurations for: + * - Left/Right stick mappings (WASD, Arrows, Mouse, Gamepad) + * - D-Pad mappings + * - Reset to default gamepad bindings + */ + + +@Composable +internal fun BindingPresetsSection( + profile: ControlsProfile, + controllerId: String, + onProfileUpdated: () -> Unit +) { + var expanded by remember { mutableStateOf(false) } + var showResetDialog by remember { mutableStateOf(false) } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + // Header with expand/collapse + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(20.dp) + ) + Text( + text = "Quick Presets", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (expanded) "Collapse" else "Expand" + ) + } + + if (expanded) { + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Quickly configure common binding patterns", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Compact grid layout for presets + Column( + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + // Left Stick row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "L", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.width(16.dp) + ) + PresetButton( + text = "WASD", + modifier = Modifier.weight(1f), + onClick = { + applyPreset(profile, controllerId, PresetType.LEFT_STICK_WASD, onProfileUpdated) + }, + compact = true + ) + PresetButton( + text = "Arrows", + modifier = Modifier.weight(1f), + onClick = { + applyPreset(profile, controllerId, PresetType.LEFT_STICK_ARROWS, onProfileUpdated) + }, + compact = true + ) + PresetButton( + text = "Mouse", + modifier = Modifier.weight(1f), + onClick = { + applyPreset(profile, controllerId, PresetType.LEFT_STICK_MOUSE, onProfileUpdated) + }, + compact = true + ) + PresetButton( + text = "Gamepad", + modifier = Modifier.weight(1f), + onClick = { + applyPreset(profile, controllerId, PresetType.LEFT_STICK, onProfileUpdated) + }, + compact = true + ) + } + + // Right Stick row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "R", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.width(16.dp) + ) + PresetButton( + text = "WASD", + modifier = Modifier.weight(1f), + onClick = { + applyPreset(profile, controllerId, PresetType.RIGHT_STICK_WASD, onProfileUpdated) + }, + compact = true + ) + PresetButton( + text = "Arrows", + modifier = Modifier.weight(1f), + onClick = { + applyPreset(profile, controllerId, PresetType.RIGHT_STICK_ARROWS, onProfileUpdated) + }, + compact = true + ) + PresetButton( + text = "Mouse", + modifier = Modifier.weight(1f), + onClick = { + applyPreset(profile, controllerId, PresetType.RIGHT_STICK_MOUSE, onProfileUpdated) + }, + compact = true + ) + PresetButton( + text = "Gamepad", + modifier = Modifier.weight(1f), + onClick = { + applyPreset(profile, controllerId, PresetType.RIGHT_STICK, onProfileUpdated) + }, + compact = true + ) + } + + // D-Pad row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "+", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.width(16.dp) + ) + PresetButton( + text = "WASD", + modifier = Modifier.weight(1f), + onClick = { + applyPreset(profile, controllerId, PresetType.DPAD_WASD, onProfileUpdated) + }, + compact = true + ) + PresetButton( + text = "Arrows", + modifier = Modifier.weight(1f), + onClick = { + applyPreset(profile, controllerId, PresetType.DPAD_ARROWS, onProfileUpdated) + }, + compact = true + ) + PresetButton( + text = "Mouse", + modifier = Modifier.weight(1f), + onClick = { + applyPreset(profile, controllerId, PresetType.DPAD_MOUSE, onProfileUpdated) + }, + compact = true + ) + PresetButton( + text = "Gamepad", + modifier = Modifier.weight(1f), + onClick = { + applyPreset(profile, controllerId, PresetType.DPAD, onProfileUpdated) + }, + compact = true + ) + } + } + + Spacer(modifier = Modifier.height(6.dp)) + + // Reset to Default button (compact) + HorizontalDivider(color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.3f)) + + Spacer(modifier = Modifier.height(6.dp)) + + OutlinedButton( + onClick = { + showResetDialog = true + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp) + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + modifier = Modifier.size(16.dp).padding(end = 4.dp) + ) + Text("Reset to Default Gamepad", style = MaterialTheme.typography.labelSmall) + } + } + } + } + + // Reset to Default dialog with checkboxes + if (showResetDialog) { + ResetToDefaultDialog( + profile = profile, + controllerId = controllerId, + onDismiss = { showResetDialog = false }, + onReset = { resetLeftStick, resetRightStick, resetDPad, resetButtons -> + // Apply the selected resets + if (resetLeftStick) { + applyPreset(profile, controllerId, PresetType.LEFT_STICK, onProfileUpdated) + } + if (resetRightStick) { + applyPreset(profile, controllerId, PresetType.RIGHT_STICK, onProfileUpdated) + } + if (resetDPad) { + applyPreset(profile, controllerId, PresetType.DPAD, onProfileUpdated) + } + if (resetButtons) { + applyPreset(profile, controllerId, PresetType.BUTTONS, onProfileUpdated) + } + showResetDialog = false + } + ) + } +} + +/** + * Reset to Default dialog with checkboxes for selecting what to reset + */ + +@Composable +internal fun ResetToDefaultDialog( + profile: ControlsProfile, + controllerId: String, + onDismiss: () -> Unit, + onReset: (Boolean, Boolean, Boolean, Boolean) -> Unit +) { + var resetLeftStick by remember { mutableStateOf(true) } + var resetRightStick by remember { mutableStateOf(true) } + var resetDPad by remember { mutableStateOf(true) } + var resetButtons by remember { mutableStateOf(true) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Reset to Default Gamepad") }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Select which controls to reset to default gamepad bindings:", + style = MaterialTheme.typography.bodyMedium + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + // Left Stick checkbox + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { resetLeftStick = !resetLeftStick }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Left Stick", + style = MaterialTheme.typography.bodyMedium + ) + Checkbox( + checked = resetLeftStick, + onCheckedChange = { resetLeftStick = it } + ) + } + + // Right Stick checkbox + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { resetRightStick = !resetRightStick }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Right Stick", + style = MaterialTheme.typography.bodyMedium + ) + Checkbox( + checked = resetRightStick, + onCheckedChange = { resetRightStick = it } + ) + } + + // D-Pad checkbox + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { resetDPad = !resetDPad }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "D-Pad", + style = MaterialTheme.typography.bodyMedium + ) + Checkbox( + checked = resetDPad, + onCheckedChange = { resetDPad = it } + ) + } + + // All Buttons checkbox + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { resetButtons = !resetButtons }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "All Buttons (A, B, X, Y, L1, R1, L2, R2, etc.)", + style = MaterialTheme.typography.bodyMedium + ) + Checkbox( + checked = resetButtons, + onCheckedChange = { resetButtons = it } + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + onReset(resetLeftStick, resetRightStick, resetDPad, resetButtons) + }, + enabled = resetLeftStick || resetRightStick || resetDPad || resetButtons + ) { + Text("Reset Selected") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +/** + * Preset types for binding configurations + */ +internal enum class PresetType { + // Stick-specific presets + LEFT_STICK_WASD, LEFT_STICK_ARROWS, LEFT_STICK_MOUSE, + RIGHT_STICK_WASD, RIGHT_STICK_ARROWS, RIGHT_STICK_MOUSE, + + // D-Pad specific presets + DPAD_WASD, DPAD_ARROWS, DPAD_MOUSE, + + // Gamepad presets + DPAD, LEFT_STICK, RIGHT_STICK, BUTTONS, + + // Reset + RESET_DEFAULT +} + +/** + * Apply a preset binding configuration + */ +internal fun applyPreset( + profile: ControlsProfile, + controllerId: String, + presetType: PresetType, + onProfileUpdated: () -> Unit +) { + val controller = profile.getController(controllerId) ?: return + + // Define the mappings for each direction + val mappings = when (presetType) { + // LEFT STICK ONLY presets + PresetType.LEFT_STICK_WASD -> listOf( + -3 to com.winlator.inputcontrols.Binding.KEY_W, // Left Stick Up -> W + -4 to com.winlator.inputcontrols.Binding.KEY_S, // Left Stick Down -> S + -1 to com.winlator.inputcontrols.Binding.KEY_A, // Left Stick Left -> A + -2 to com.winlator.inputcontrols.Binding.KEY_D // Left Stick Right -> D + ) + PresetType.LEFT_STICK_ARROWS -> listOf( + -3 to com.winlator.inputcontrols.Binding.KEY_UP, // Left Stick Up -> Up Arrow + -4 to com.winlator.inputcontrols.Binding.KEY_DOWN, // Left Stick Down -> Down Arrow + -1 to com.winlator.inputcontrols.Binding.KEY_LEFT, // Left Stick Left -> Left Arrow + -2 to com.winlator.inputcontrols.Binding.KEY_RIGHT // Left Stick Right -> Right Arrow + ) + PresetType.LEFT_STICK_MOUSE -> listOf( + -3 to com.winlator.inputcontrols.Binding.MOUSE_MOVE_UP, // Left Stick Up -> Mouse Up + -4 to com.winlator.inputcontrols.Binding.MOUSE_MOVE_DOWN, // Left Stick Down -> Mouse Down + -1 to com.winlator.inputcontrols.Binding.MOUSE_MOVE_LEFT, // Left Stick Left -> Mouse Left + -2 to com.winlator.inputcontrols.Binding.MOUSE_MOVE_RIGHT // Left Stick Right -> Mouse Right + ) + // RIGHT STICK ONLY presets + PresetType.RIGHT_STICK_WASD -> listOf( + -7 to com.winlator.inputcontrols.Binding.KEY_W, // Right Stick Up -> W + -8 to com.winlator.inputcontrols.Binding.KEY_S, // Right Stick Down -> S + -5 to com.winlator.inputcontrols.Binding.KEY_A, // Right Stick Left -> A + -6 to com.winlator.inputcontrols.Binding.KEY_D // Right Stick Right -> D + ) + PresetType.RIGHT_STICK_ARROWS -> listOf( + -7 to com.winlator.inputcontrols.Binding.KEY_UP, // Right Stick Up -> Up Arrow + -8 to com.winlator.inputcontrols.Binding.KEY_DOWN, // Right Stick Down -> Down Arrow + -5 to com.winlator.inputcontrols.Binding.KEY_LEFT, // Right Stick Left -> Left Arrow + -6 to com.winlator.inputcontrols.Binding.KEY_RIGHT // Right Stick Right -> Right Arrow + ) + PresetType.RIGHT_STICK_MOUSE -> listOf( + -7 to com.winlator.inputcontrols.Binding.MOUSE_MOVE_UP, // Right Stick Up -> Mouse Up + -8 to com.winlator.inputcontrols.Binding.MOUSE_MOVE_DOWN, // Right Stick Down -> Mouse Down + -5 to com.winlator.inputcontrols.Binding.MOUSE_MOVE_LEFT, // Right Stick Left -> Mouse Left + -6 to com.winlator.inputcontrols.Binding.MOUSE_MOVE_RIGHT // Right Stick Right -> Mouse Right + ) + // D-PAD ONLY presets + PresetType.DPAD_WASD -> listOf( + 19 to com.winlator.inputcontrols.Binding.KEY_W, // D-Pad Up -> W + 20 to com.winlator.inputcontrols.Binding.KEY_S, // D-Pad Down -> S + 21 to com.winlator.inputcontrols.Binding.KEY_A, // D-Pad Left -> A + 22 to com.winlator.inputcontrols.Binding.KEY_D // D-Pad Right -> D + ) + PresetType.DPAD_ARROWS -> listOf( + 19 to com.winlator.inputcontrols.Binding.KEY_UP, // D-Pad Up -> Up Arrow + 20 to com.winlator.inputcontrols.Binding.KEY_DOWN, // D-Pad Down -> Down Arrow + 21 to com.winlator.inputcontrols.Binding.KEY_LEFT, // D-Pad Left -> Left Arrow + 22 to com.winlator.inputcontrols.Binding.KEY_RIGHT // D-Pad Right -> Right Arrow + ) + PresetType.DPAD_MOUSE -> listOf( + 19 to com.winlator.inputcontrols.Binding.MOUSE_MOVE_UP, // D-Pad Up -> Mouse Up + 20 to com.winlator.inputcontrols.Binding.MOUSE_MOVE_DOWN, // D-Pad Down -> Mouse Down + 21 to com.winlator.inputcontrols.Binding.MOUSE_MOVE_LEFT, // D-Pad Left -> Mouse Left + 22 to com.winlator.inputcontrols.Binding.MOUSE_MOVE_RIGHT // D-Pad Right -> Mouse Right + ) + PresetType.DPAD -> listOf( + 19 to com.winlator.inputcontrols.Binding.GAMEPAD_DPAD_UP, + 20 to com.winlator.inputcontrols.Binding.GAMEPAD_DPAD_DOWN, + 21 to com.winlator.inputcontrols.Binding.GAMEPAD_DPAD_LEFT, + 22 to com.winlator.inputcontrols.Binding.GAMEPAD_DPAD_RIGHT, + -3 to com.winlator.inputcontrols.Binding.GAMEPAD_DPAD_UP, // AXIS_Y_NEGATIVE + -4 to com.winlator.inputcontrols.Binding.GAMEPAD_DPAD_DOWN, // AXIS_Y_POSITIVE + -1 to com.winlator.inputcontrols.Binding.GAMEPAD_DPAD_LEFT, // AXIS_X_NEGATIVE + -2 to com.winlator.inputcontrols.Binding.GAMEPAD_DPAD_RIGHT // AXIS_X_POSITIVE + ) + PresetType.LEFT_STICK -> listOf( + 19 to com.winlator.inputcontrols.Binding.GAMEPAD_LEFT_THUMB_UP, + 20 to com.winlator.inputcontrols.Binding.GAMEPAD_LEFT_THUMB_DOWN, + 21 to com.winlator.inputcontrols.Binding.GAMEPAD_LEFT_THUMB_LEFT, + 22 to com.winlator.inputcontrols.Binding.GAMEPAD_LEFT_THUMB_RIGHT, + -3 to com.winlator.inputcontrols.Binding.GAMEPAD_LEFT_THUMB_UP, // AXIS_Y_NEGATIVE + -4 to com.winlator.inputcontrols.Binding.GAMEPAD_LEFT_THUMB_DOWN, // AXIS_Y_POSITIVE + -1 to com.winlator.inputcontrols.Binding.GAMEPAD_LEFT_THUMB_LEFT, // AXIS_X_NEGATIVE + -2 to com.winlator.inputcontrols.Binding.GAMEPAD_LEFT_THUMB_RIGHT // AXIS_X_POSITIVE + ) + PresetType.RIGHT_STICK -> listOf( + 19 to com.winlator.inputcontrols.Binding.GAMEPAD_RIGHT_THUMB_UP, + 20 to com.winlator.inputcontrols.Binding.GAMEPAD_RIGHT_THUMB_DOWN, + 21 to com.winlator.inputcontrols.Binding.GAMEPAD_RIGHT_THUMB_LEFT, + 22 to com.winlator.inputcontrols.Binding.GAMEPAD_RIGHT_THUMB_RIGHT, + -7 to com.winlator.inputcontrols.Binding.GAMEPAD_RIGHT_THUMB_UP, // AXIS_RZ_NEGATIVE + -8 to com.winlator.inputcontrols.Binding.GAMEPAD_RIGHT_THUMB_DOWN, // AXIS_RZ_POSITIVE + -5 to com.winlator.inputcontrols.Binding.GAMEPAD_RIGHT_THUMB_LEFT, // AXIS_Z_NEGATIVE + -6 to com.winlator.inputcontrols.Binding.GAMEPAD_RIGHT_THUMB_RIGHT // AXIS_Z_POSITIVE + ) + PresetType.BUTTONS -> listOf( + // All Gamepad Buttons (A, B, X, Y, L1, R1, L2, R2, L3, R3, Start, Select) + 96 to com.winlator.inputcontrols.Binding.GAMEPAD_BUTTON_A, // KEYCODE_BUTTON_A + 97 to com.winlator.inputcontrols.Binding.GAMEPAD_BUTTON_B, // KEYCODE_BUTTON_B + 99 to com.winlator.inputcontrols.Binding.GAMEPAD_BUTTON_X, // KEYCODE_BUTTON_X + 100 to com.winlator.inputcontrols.Binding.GAMEPAD_BUTTON_Y, // KEYCODE_BUTTON_Y + 102 to com.winlator.inputcontrols.Binding.GAMEPAD_BUTTON_L1, // KEYCODE_BUTTON_L1 + 103 to com.winlator.inputcontrols.Binding.GAMEPAD_BUTTON_R1, // KEYCODE_BUTTON_R1 + 104 to com.winlator.inputcontrols.Binding.GAMEPAD_BUTTON_L2, // KEYCODE_BUTTON_L2 + 105 to com.winlator.inputcontrols.Binding.GAMEPAD_BUTTON_R2, // KEYCODE_BUTTON_R2 + 106 to com.winlator.inputcontrols.Binding.GAMEPAD_BUTTON_L3, // KEYCODE_BUTTON_THUMBL + 107 to com.winlator.inputcontrols.Binding.GAMEPAD_BUTTON_R3, // KEYCODE_BUTTON_THUMBR + 108 to com.winlator.inputcontrols.Binding.GAMEPAD_BUTTON_START, // KEYCODE_BUTTON_START + 109 to com.winlator.inputcontrols.Binding.GAMEPAD_BUTTON_SELECT // KEYCODE_BUTTON_SELECT + ) + PresetType.RESET_DEFAULT -> listOf( + // Buttons (matching InputControlsManager.addDefaultControllerBindings) + 96 to com.winlator.inputcontrols.Binding.GAMEPAD_BUTTON_A, // KEYCODE_BUTTON_A + 97 to com.winlator.inputcontrols.Binding.GAMEPAD_BUTTON_B, // KEYCODE_BUTTON_B + 99 to com.winlator.inputcontrols.Binding.GAMEPAD_BUTTON_X, // KEYCODE_BUTTON_X + 100 to com.winlator.inputcontrols.Binding.GAMEPAD_BUTTON_Y, // KEYCODE_BUTTON_Y + 102 to com.winlator.inputcontrols.Binding.GAMEPAD_BUTTON_L1, // KEYCODE_BUTTON_L1 + 103 to com.winlator.inputcontrols.Binding.GAMEPAD_BUTTON_R1, // KEYCODE_BUTTON_R1 + 104 to com.winlator.inputcontrols.Binding.GAMEPAD_BUTTON_L2, // KEYCODE_BUTTON_L2 + 105 to com.winlator.inputcontrols.Binding.GAMEPAD_BUTTON_R2, // KEYCODE_BUTTON_R2 + 106 to com.winlator.inputcontrols.Binding.GAMEPAD_BUTTON_L3, // KEYCODE_BUTTON_THUMBL + 107 to com.winlator.inputcontrols.Binding.GAMEPAD_BUTTON_R3, // KEYCODE_BUTTON_THUMBR + 108 to com.winlator.inputcontrols.Binding.GAMEPAD_BUTTON_START, // KEYCODE_BUTTON_START + 109 to com.winlator.inputcontrols.Binding.GAMEPAD_BUTTON_SELECT,// KEYCODE_BUTTON_SELECT + // D-Pad + 19 to com.winlator.inputcontrols.Binding.GAMEPAD_DPAD_UP, // KEYCODE_DPAD_UP + 20 to com.winlator.inputcontrols.Binding.GAMEPAD_DPAD_DOWN, // KEYCODE_DPAD_DOWN + 21 to com.winlator.inputcontrols.Binding.GAMEPAD_DPAD_LEFT, // KEYCODE_DPAD_LEFT + 22 to com.winlator.inputcontrols.Binding.GAMEPAD_DPAD_RIGHT, // KEYCODE_DPAD_RIGHT + // Left Stick - default to gamepad left stick bindings + -3 to com.winlator.inputcontrols.Binding.GAMEPAD_LEFT_THUMB_UP, // AXIS_Y_NEGATIVE + -4 to com.winlator.inputcontrols.Binding.GAMEPAD_LEFT_THUMB_DOWN, // AXIS_Y_POSITIVE + -1 to com.winlator.inputcontrols.Binding.GAMEPAD_LEFT_THUMB_LEFT, // AXIS_X_NEGATIVE + -2 to com.winlator.inputcontrols.Binding.GAMEPAD_LEFT_THUMB_RIGHT, // AXIS_X_POSITIVE + // Right Stick - default to gamepad right stick bindings + -7 to com.winlator.inputcontrols.Binding.GAMEPAD_RIGHT_THUMB_UP, // AXIS_RZ_NEGATIVE + -8 to com.winlator.inputcontrols.Binding.GAMEPAD_RIGHT_THUMB_DOWN, // AXIS_RZ_POSITIVE + -5 to com.winlator.inputcontrols.Binding.GAMEPAD_RIGHT_THUMB_LEFT, // AXIS_Z_NEGATIVE + -6 to com.winlator.inputcontrols.Binding.GAMEPAD_RIGHT_THUMB_RIGHT // AXIS_Z_POSITIVE + ) + } + + // Clear all existing bindings first for better performance (avoids O(n*m) complexity) + controller.controllerBindings.clear() + + // Apply each mapping + mappings.forEach { (keyCode, binding) -> + // Add new binding + val newBinding = com.winlator.inputcontrols.ExternalControllerBinding() + newBinding.setKeyCode(keyCode) + newBinding.binding = binding + controller.controllerBindings.add(newBinding) + } + + // Save and update + profile.save() + onProfileUpdated() +} + + +@Composable +internal fun PresetButton( + text: String, + modifier: Modifier = Modifier, + compact: Boolean = false, + onClick: () -> Unit +) { + OutlinedButton( + onClick = onClick, + modifier = modifier, + contentPadding = if (compact) { + PaddingValues(horizontal = 6.dp, vertical = 4.dp) + } else { + PaddingValues(horizontal = 12.dp, vertical = 8.dp) + } + ) { + Text( + text = text, + style = if (compact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.labelMedium, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } +} diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ElementEditorDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ElementEditorDialog.kt new file mode 100644 index 000000000..985c616fa --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ElementEditorDialog.kt @@ -0,0 +1,912 @@ +package app.gamenative.ui.component.dialog + +import android.util.Log +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.FileCopy +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import app.gamenative.ui.component.settings.SettingsListDropdown +import app.gamenative.ui.component.settings.SettingsTextField +import app.gamenative.ui.theme.settingsTileColors +import com.alorma.compose.settings.ui.SettingsGroup +import com.alorma.compose.settings.ui.SettingsMenuLink +import com.winlator.inputcontrols.ControlElement +import com.winlator.widget.InputControlsView + +/** + * Compose-based element editor dialog matching the app's settings design pattern. + * + * Features: + * - Full-width dialog at bottom of screen + * - When adjusting size, dialog minimizes to show only slider controls + * - Live preview of all changes on the actual control element + * - Reset button returns size to 1.0x default + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ElementEditorDialog( + element: ControlElement, + view: InputControlsView, + onDismiss: () -> Unit, + onSave: () -> Unit +) { + val context = LocalContext.current + + // Store original values for cancel/restore + val originalScale by remember { mutableFloatStateOf(element.scale) } + val originalText by remember { mutableStateOf(element.text) } // Keep null as null + val originalType by remember { mutableStateOf(element.type) } + val originalShape by remember { mutableStateOf(element.shape) } + + // Store original bindings for restore on cancel + val originalBindings by remember { + mutableStateOf( + (0 until 4).map { element.getBindingAt(it) } + ) + } + + // Track if there are unsaved changes + var hasUnsavedChanges by remember { mutableStateOf(false) } + var showExitConfirmation by remember { mutableStateOf(false) } + + // Get the display text (either custom text or binding-based text) + // Match the logic from ControlElement.getDisplayText() + val initialDisplayText = remember(element) { + val customText = element.text + if (!customText.isNullOrEmpty()) { + customText + } else { + // Show what's actually displayed (based on first binding) + val binding = element.getBindingAt(0) + if (binding != null && binding != com.winlator.inputcontrols.Binding.NONE) { + var text = binding.toString().replace("NUMPAD ", "NP").replace("BUTTON ", "") + if (text.length > 7) { + // Abbreviate long binding names (e.g., "KEY A B" -> "KAB") + val parts = text.split(" ") + val sb = StringBuilder() + for (part in parts) { + if (part.isNotEmpty()) sb.append(part[0]) + } + text = (if (binding.isMouse) "M" else "") + sb.toString() + } + text + } else { + "" + } + } + } + + // Current editing values with live preview + var currentScale by remember { mutableFloatStateOf(element.scale) } + var currentText by remember { mutableStateOf(initialDisplayText) } + var showBindingsEditor by remember { mutableStateOf(false) } + var bindingSlotToEdit by remember { mutableStateOf?>(null) } + + // Force recomposition of bindings section when bindings change + var bindingsRefreshKey by remember { mutableIntStateOf(0) } + + // Track current dropdown selections + var currentTypeIndex by remember { mutableIntStateOf(element.type.ordinal) } + var currentShapeIndex by remember { mutableIntStateOf(element.shape.ordinal) } + + // State for size adjustment mode + var showSizeAdjuster by remember { mutableStateOf(false) } + + // Get types array for saving + val types = remember { ControlElement.Type.values() } + + // Apply changes to element for live preview + LaunchedEffect(currentScale) { + element.setScale(currentScale) + view.invalidate() + } + + // Only update element text if user has actually modified it + // Don't apply preview for initial display text + // Debounce text changes to avoid excessive redraws (500ms delay) + LaunchedEffect(currentText) { + // Only set custom text if user has explicitly modified it from the initial value + // AND it's not empty (empty should remain null for binding-based display) + if (currentText != initialDisplayText && currentText.isNotEmpty()) { + kotlinx.coroutines.delay(500) // Debounce 500ms + element.setText(currentText) + view.invalidate() + } + } + + Dialog( + onDismissRequest = { + if (hasUnsavedChanges) { + showExitConfirmation = true + } else { + onDismiss() + } + }, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true, + dismissOnClickOutside = false + ) + ) { + // Show either full settings dialog or minimized size adjuster + if (showSizeAdjuster) { + // Minimized size adjuster mode + SizeAdjusterOverlay( + element = element, + view = view, + currentScale = currentScale, + onScaleChange = { currentScale = it }, + onConfirm = { + showSizeAdjuster = false + }, + onCancel = { + currentScale = originalScale + element.setScale(originalScale) + view.invalidate() + showSizeAdjuster = false + }, + onReset = { + currentScale = 1.0f + element.setScale(1.0f) + view.invalidate() + } + ) + } else { + // Full settings dialog + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + CenterAlignedTopAppBar( + title = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = "Edit ${element.type.name}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = "Pos: (${element.x}, ${element.y}) • Size: ${String.format("%.2f", currentScale)}x", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + navigationIcon = { + IconButton(onClick = { + if (hasUnsavedChanges) { + showExitConfirmation = true + } else { + onDismiss() + } + }) { + Icon(Icons.Default.Close, "Cancel") + } + }, + actions = { + IconButton(onClick = { + // Save changes to element + element.setScale(currentScale) + // If currentText is empty string, set to null to use binding-based text + // Otherwise use the current text value + element.setText(if (currentText.isEmpty()) null else currentText) + // Only change type if it's different (setType() calls reset() which clears bindings!) + if (element.type != types[currentTypeIndex]) { + element.type = types[currentTypeIndex] + } + + // Save to disk + view.profile?.save() + + // Log for debugging + Log.d("ElementEditorDialog", "Saved element ${element.type}: bindings = [${element.getBindingAt(0)?.name}, ${element.getBindingAt(1)?.name}, ${element.getBindingAt(2)?.name}, ${element.getBindingAt(3)?.name}]") + + // Update canvas to show new bindings + view.invalidate() + + // Mark as saved + hasUnsavedChanges = false + + // Close dialog + onSave() + }) { + Icon(Icons.Default.Save, "Save") + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + ) { + // Appearance Section + SettingsGroup(title = { Text("Appearance") }) { + // Scale/Size - click to enter adjustment mode + SettingsMenuLink( + colors = settingsTileColors(), + title = { Text("Size") }, + subtitle = { Text("${String.format("%.2f", currentScale)}x scale - Tap to adjust or copy from another element") }, + onClick = { + showSizeAdjuster = true + } + ) + + // Text/Label - only for BUTTON type + if (element.type == ControlElement.Type.BUTTON) { + SettingsTextField( + colors = settingsTileColors(), + title = { Text("Label Text") }, + subtitle = { Text("Custom text displayed on element") }, + value = currentText, + onValueChange = { currentText = it }, + action = { + // Reset button to restore original text + IconButton(onClick = { + // Reset to initial display text (binding-based or original custom text) + currentText = initialDisplayText + element.setText(originalText) + view.invalidate() + }) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Reset to original", + modifier = Modifier.size(20.dp) + ) + } + } + ) + } + + // Element Type + val types = ControlElement.Type.values() + val typeNames = types.map { it.name.replace("_", " ") } + SettingsListDropdown( + colors = settingsTileColors(), + title = { Text("Element Type") }, + subtitle = { Text("Change the element's behavior and appearance") }, + value = currentTypeIndex, + items = typeNames, + onItemSelected = { index -> + currentTypeIndex = index + element.type = types[index] + view.invalidate() + } + ) + + // Element Shape (with restrictions) + // STICK, TRACKPAD, RANGE_BUTTON have fixed rendering shapes + // D_PAD uses custom cross-shaped path and doesn't respect shape + val availableShapes = when (element.type) { + ControlElement.Type.STICK -> { + // Stick is always rendered as CIRCLE + listOf(ControlElement.Shape.CIRCLE) + } + ControlElement.Type.TRACKPAD, + ControlElement.Type.RANGE_BUTTON -> { + // Trackpad and Range Button are always rendered as ROUND_RECT + listOf(ControlElement.Shape.ROUND_RECT) + } + ControlElement.Type.D_PAD -> { + // D-Pad uses custom cross-shaped path, shape doesn't affect rendering + // Don't allow changing shape + listOf(element.shape) + } + ControlElement.Type.BUTTON -> { + // Buttons fully support all shapes + ControlElement.Shape.values().toList() + } + else -> ControlElement.Shape.values().toList() + } + + if (availableShapes.size > 1) { + val shapeNames = availableShapes.map { it.name.replace("_", " ") } + val currentShapeIndexInList = availableShapes.indexOf(element.shape).coerceAtLeast(0) + SettingsListDropdown( + colors = settingsTileColors(), + title = { Text("Shape") }, + subtitle = { Text("Visual style of the element") }, + value = currentShapeIndexInList, + items = shapeNames, + onItemSelected = { index -> + element.shape = availableShapes[index] + view.invalidate() + } + ) + } else if (availableShapes.size == 1 && element.type != ControlElement.Type.D_PAD) { + // Show info for restricted types (but not D-PAD since it's obvious) + SettingsMenuLink( + colors = settingsTileColors(), + title = { Text("Shape") }, + subtitle = { Text("${element.type.name} can only use ${availableShapes[0].name.replace("_", " ")} shape") }, + enabled = false, + onClick = {} + ) + } + } + + // Bindings Section + // Use key() with bindingsRefreshKey to force recomposition when bindings change + key(bindingsRefreshKey) { + SettingsGroup(title = { Text("Bindings") }) { + // Quick Presets for directional controls (D-Pad and Stick only) + if (element.type == ControlElement.Type.D_PAD || element.type == ControlElement.Type.STICK) { + VirtualControlPresets( + element = element, + view = view, + onPresetsApplied = { + // Mark as having unsaved changes + hasUnsavedChanges = true + // Force UI refresh after presets are applied + bindingsRefreshKey++ + } + ) + } + + if (element.type == ControlElement.Type.RANGE_BUTTON) { + SettingsMenuLink( + colors = settingsTileColors(), + title = { Text("Bindings (Auto-Generated)") }, + subtitle = { Text("Range Button bindings are generated from Range setting") }, + enabled = false, + onClick = {} + ) + } else { + val bindingCount = when (element.type) { + ControlElement.Type.BUTTON -> 2 + else -> 4 + } + + for (i in 0 until bindingCount) { + val binding = element.getBindingAt(i) + val bindingName = binding?.toString() ?: "NONE" + + val slotLabel = when (element.type) { + ControlElement.Type.BUTTON -> { + if (i == 0) "Primary Action" else "Secondary Action" + } + ControlElement.Type.D_PAD, + ControlElement.Type.STICK -> { + listOf("Up", "Right", "Down", "Left")[i] + } + ControlElement.Type.TRACKPAD -> { + listOf("Up", "Right", "Down", "Left")[i] + " (Mouse)" + } + else -> "Slot ${i + 1}" + } + + SettingsMenuLink( + colors = settingsTileColors(), + title = { Text(slotLabel) }, + subtitle = { Text(bindingName) }, + onClick = { + bindingSlotToEdit = Pair(i, slotLabel) + } + ) + } + + // Helper text for buttons + if (element.type == ControlElement.Type.BUTTON) { + Text( + text = "Both slots are pressed simultaneously. Use secondary for modifier keys (Shift, Ctrl, Alt).", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(16.dp) + ) + } + } + } + } + + // Properties Section + SettingsGroup(title = { Text("Properties") }) { + SettingsMenuLink( + colors = settingsTileColors(), + title = { Text("Position") }, + subtitle = { Text("X: ${element.x}, Y: ${element.y}") }, + enabled = false, + onClick = {} + ) + } + } + } + } + } + + // Show binding selector dialog + bindingSlotToEdit?.let { (slotIndex, slotLabel) -> + val currentBinding = element.getBindingAt(slotIndex) + + ControllerBindingDialog( + buttonName = slotLabel, + currentBinding = currentBinding, + onDismiss = { bindingSlotToEdit = null }, + onBindingSelected = { binding -> + Log.d("ElementEditorDialog", "Binding changed for element ${element.type} slot $slotIndex ($slotLabel): ${currentBinding?.name} -> ${binding?.name ?: "NONE"}") + + // Update binding in memory only (not saved to disk yet) + if (binding != null) { + element.setBindingAt(slotIndex, binding) + } else { + element.setBindingAt(slotIndex, com.winlator.inputcontrols.Binding.NONE) + } + + // If this is a button and slot 0 (primary), update label text to match new binding + // (ControlElement.getDisplayText() returns custom text if set, otherwise binding text) + if (element.type == ControlElement.Type.BUTTON && slotIndex == 0) { + // Check if custom text is empty or same as old binding text + val customText = element.text + if (customText.isNullOrEmpty() || customText == currentBinding?.toString()?.replace("NUMPAD ", "NP")?.replace("BUTTON ", "")) { + // Clear custom text so new binding text will show + element.setText(null) + + // Update currentText state to show what will actually be displayed (new binding text) + val newBindingText = binding?.toString()?.replace("NUMPAD ", "NP")?.replace("BUTTON ", "") ?: "" + currentText = if (newBindingText.length > 7) { + // Abbreviate long names to match getDisplayText() logic + val parts = newBindingText.split(" ") + val sb = StringBuilder() + for (part in parts) { + if (part.isNotEmpty()) sb.append(part[0]) + } + (if (binding?.isMouse() == true) "M" else "") + sb.toString() + } else { + newBindingText + } + } + } + + // Mark as having unsaved changes + hasUnsavedChanges = true + + // Update canvas to show new binding immediately + view.invalidate() + + // Close binding selector + bindingSlotToEdit = null + + // Force UI refresh to show updated binding in list + bindingsRefreshKey++ + } + ) + } + + // Show exit confirmation dialog if there are unsaved changes + if (showExitConfirmation) { + androidx.compose.material3.AlertDialog( + onDismissRequest = { showExitConfirmation = false }, + title = { Text("Unsaved Changes") }, + text = { Text("You have unsaved changes. Do you want to save before closing?") }, + confirmButton = { + TextButton(onClick = { + // Save and close + element.setScale(currentScale) + element.setText(if (currentText.isEmpty()) null else currentText) + // Only change type if it's different (setType() calls reset() which clears bindings!) + if (element.type != types[currentTypeIndex]) { + element.type = types[currentTypeIndex] + } + view.profile?.save() + view.invalidate() + showExitConfirmation = false + onDismiss() + }) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = { + // Discard changes and close + element.setScale(originalScale) + element.setText(originalText) + element.type = originalType + element.shape = originalShape + // Restore original bindings + originalBindings.forEachIndexed { index, binding -> + if (binding != null) { + element.setBindingAt(index, binding) + } + } + view.invalidate() + showExitConfirmation = false + onDismiss() + }) { + Text("Discard") + } + } + ) + } +} + +/** + * Floating size adjuster overlay - appears on top of the controls view + * with a slider and action buttons positioned away from the control element. + * Automatically positions itself at top or bottom based on element location. + */ +@Composable +private fun SizeAdjusterOverlay( + element: ControlElement, + view: InputControlsView, + currentScale: Float, + onScaleChange: (Float) -> Unit, + onConfirm: () -> Unit, + onCancel: () -> Unit, + onReset: () -> Unit +) { + // Determine if element is in top or bottom half of screen + // Coordinates are in actual screen pixels (not normalized) + // Y=0 is at TOP, Y increases downward (standard Android Canvas) + val elementY = element.y + val screenHeight = view.height.toFloat() + + // Use 60% threshold: if Y < 60% of screen height, element is in top portion, show slider at bottom + // Otherwise element is in bottom 40%, show slider at top + val isElementInTopPortion = elementY < (screenHeight * 0.6f) + + // Debug log + android.util.Log.d("ElementEditor", "Element Y: $elementY, Screen Height: $screenHeight, ${(elementY/screenHeight*100).toInt()}% from top, showing slider at ${if (isElementInTopPortion) "BOTTOM" else "TOP"}") + + // Full screen transparent overlay + Box( + modifier = Modifier.fillMaxSize() + ) { + // Position slider opposite to element location - more compact and transparent + Surface( + modifier = Modifier + .wrapContentHeight() + .widthIn(max = 400.dp) + .align(if (isElementInTopPortion) Alignment.BottomCenter else Alignment.TopCenter) + .padding(16.dp), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f), + tonalElevation = 4.dp + ) { + Column( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + // Title and current scale + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Adjust Size", + style = MaterialTheme.typography.labelLarge + ) + Text( + text = "${String.format("%.2f", currentScale)}x", + style = MaterialTheme.typography.titleSmall, + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold + ) + } + + // Slider - more compact + Slider( + value = currentScale, + onValueChange = onScaleChange, + valueRange = 0.1f..5.0f, + modifier = Modifier + .fillMaxWidth() + .height(32.dp) + ) + + // All 4 buttons in one row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + // Copy Size button + OutlinedButton( + onClick = { + val context = view.context + showCopySizeDialog(context, element, view) { newScale -> + onScaleChange(newScale) + } + }, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 4.dp, vertical = 6.dp) + ) { + Icon( + imageVector = Icons.Default.FileCopy, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(2.dp)) + Text("Copy", style = MaterialTheme.typography.labelSmall) + } + + // Reset button + OutlinedButton( + onClick = onReset, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 4.dp, vertical = 6.dp) + ) { + Text("Reset", style = MaterialTheme.typography.labelSmall) + } + + // Cancel button + OutlinedButton( + onClick = onCancel, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 4.dp, vertical = 6.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(2.dp)) + Text("Cancel", style = MaterialTheme.typography.labelSmall) + } + + // Confirm button + Button( + onClick = onConfirm, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 4.dp, vertical = 6.dp) + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(2.dp)) + Text("Done", style = MaterialTheme.typography.labelSmall) + } + } + } + } + } +} + +/** + * Show dialog to copy size from another element + */ +private fun showCopySizeDialog( + context: android.content.Context, + currentElement: ControlElement, + view: InputControlsView, + onSizeCopied: (Float) -> Unit +) { + val profile = view.profile ?: return + val elements = profile.getElements() + + // Filter out the current element and create display list + val otherElements = elements.filter { it != currentElement } + + if (otherElements.isEmpty()) { + android.widget.Toast.makeText( + context, + "No other elements to copy from", + android.widget.Toast.LENGTH_SHORT + ).show() + return + } + + // Create display items showing element info with better formatting + val elementNames = otherElements.map { element -> + val typeStr = element.type.name.replace("_", " ") + val scaleStr = String.format("%.2fx", element.scale) + + // Get display text/binding + val label = if (!element.text.isNullOrEmpty()) { + element.text + } else { + val binding = element.getBindingAt(0) + if (binding != null && binding != com.winlator.inputcontrols.Binding.NONE) { + binding.toString().take(15) + } else { + "No Binding" + } + } + + // Format: "BUTTON • 1.50x • Space" + "$typeStr • $scaleStr • $label" + }.toTypedArray() + + android.app.AlertDialog.Builder(context) + .setTitle("Copy Size From Element") + .setItems(elementNames) { _, which -> + val selectedElement = otherElements[which] + onSizeCopied(selectedElement.scale) + android.widget.Toast.makeText( + context, + "Copied size: ${String.format("%.2f", selectedElement.scale)}x", + android.widget.Toast.LENGTH_SHORT + ).show() + } + .setNegativeButton("Cancel", null) + .show() +} + +/** + * Quick preset buttons for virtual control bindings + */ +@Composable +private fun VirtualControlPresets( + element: ControlElement, + view: InputControlsView, + onPresetsApplied: () -> Unit = {} +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Quick Presets", + style = MaterialTheme.typography.labelLarge, + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + + // Keyboard layouts + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = { + applyVirtualPreset(element, VirtualPresetType.WASD, view) + onPresetsApplied() + }, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp) + ) { + Text("WASD", style = MaterialTheme.typography.labelSmall) + } + OutlinedButton( + onClick = { + applyVirtualPreset(element, VirtualPresetType.ARROW_KEYS, view) + onPresetsApplied() + }, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp) + ) { + Text("Arrows", style = MaterialTheme.typography.labelSmall) + } + OutlinedButton( + onClick = { + applyVirtualPreset(element, VirtualPresetType.MOUSE_MOVE, view) + onPresetsApplied() + }, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp) + ) { + Text("Mouse", style = MaterialTheme.typography.labelSmall) + } + } + + // Gamepad modes + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = { + applyVirtualPreset(element, VirtualPresetType.DPAD, view) + onPresetsApplied() + }, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp) + ) { + Text("D-Pad", style = MaterialTheme.typography.labelSmall) + } + OutlinedButton( + onClick = { + applyVirtualPreset(element, VirtualPresetType.LEFT_STICK, view) + onPresetsApplied() + }, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp) + ) { + Text("L-Stick", style = MaterialTheme.typography.labelSmall) + } + OutlinedButton( + onClick = { + applyVirtualPreset(element, VirtualPresetType.RIGHT_STICK, view) + onPresetsApplied() + }, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp) + ) { + Text("R-Stick", style = MaterialTheme.typography.labelSmall) + } + } + } + } +} + +/** + * Preset types for virtual control bindings + */ +private enum class VirtualPresetType { + WASD, ARROW_KEYS, MOUSE_MOVE, DPAD, LEFT_STICK, RIGHT_STICK +} + +/** + * Apply a preset binding to a virtual control element + */ +private fun applyVirtualPreset( + element: ControlElement, + presetType: VirtualPresetType, + view: InputControlsView +) { + // Define bindings for each preset (Up, Right, Down, Left order) + val bindings = when (presetType) { + VirtualPresetType.WASD -> listOf( + com.winlator.inputcontrols.Binding.KEY_W, + com.winlator.inputcontrols.Binding.KEY_D, + com.winlator.inputcontrols.Binding.KEY_S, + com.winlator.inputcontrols.Binding.KEY_A + ) + VirtualPresetType.ARROW_KEYS -> listOf( + com.winlator.inputcontrols.Binding.KEY_UP, + com.winlator.inputcontrols.Binding.KEY_RIGHT, + com.winlator.inputcontrols.Binding.KEY_DOWN, + com.winlator.inputcontrols.Binding.KEY_LEFT + ) + VirtualPresetType.MOUSE_MOVE -> listOf( + com.winlator.inputcontrols.Binding.MOUSE_MOVE_UP, + com.winlator.inputcontrols.Binding.MOUSE_MOVE_RIGHT, + com.winlator.inputcontrols.Binding.MOUSE_MOVE_DOWN, + com.winlator.inputcontrols.Binding.MOUSE_MOVE_LEFT + ) + VirtualPresetType.DPAD -> listOf( + com.winlator.inputcontrols.Binding.GAMEPAD_DPAD_UP, + com.winlator.inputcontrols.Binding.GAMEPAD_DPAD_RIGHT, + com.winlator.inputcontrols.Binding.GAMEPAD_DPAD_DOWN, + com.winlator.inputcontrols.Binding.GAMEPAD_DPAD_LEFT + ) + VirtualPresetType.LEFT_STICK -> listOf( + com.winlator.inputcontrols.Binding.GAMEPAD_LEFT_THUMB_UP, + com.winlator.inputcontrols.Binding.GAMEPAD_LEFT_THUMB_RIGHT, + com.winlator.inputcontrols.Binding.GAMEPAD_LEFT_THUMB_DOWN, + com.winlator.inputcontrols.Binding.GAMEPAD_LEFT_THUMB_LEFT + ) + VirtualPresetType.RIGHT_STICK -> listOf( + com.winlator.inputcontrols.Binding.GAMEPAD_RIGHT_THUMB_UP, + com.winlator.inputcontrols.Binding.GAMEPAD_RIGHT_THUMB_RIGHT, + com.winlator.inputcontrols.Binding.GAMEPAD_RIGHT_THUMB_DOWN, + com.winlator.inputcontrols.Binding.GAMEPAD_RIGHT_THUMB_LEFT + ) + } + + // Apply bindings to element in memory only (not saved to disk yet) + Log.d("ElementEditorDialog", "Applying preset $presetType to element ${element.type}") + bindings.forEachIndexed { index, binding -> + Log.d("ElementEditorDialog", " Slot $index: ${element.getBindingAt(index)?.name} -> ${binding.name}") + element.setBindingAt(index, binding) + } + + // Update canvas to show new bindings immediately + view.invalidate() + Log.d("ElementEditorDialog", "Preset applied (not saved to disk yet)") +} diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ImportProfileDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ImportProfileDialog.kt new file mode 100644 index 000000000..2c8f44966 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ImportProfileDialog.kt @@ -0,0 +1,158 @@ +package app.gamenative.ui.component.dialog + +import android.content.Context +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.winlator.inputcontrols.InputControlsManager +import org.json.JSONObject + +/** + * Import Profile Dialog + * + * Allows users to import profile .icp files from storage + */ +@Composable +fun ImportProfileDialog( + context: Context, + onProfileImported: () -> Unit, + onDismiss: () -> Unit +) { + val inputControlsManager = remember { InputControlsManager(context) } + var isImporting by remember { mutableStateOf(false) } + + // File picker launcher + val filePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri -> + if (uri != null) { + isImporting = true + try { + // Read file content + val inputStream = context.contentResolver.openInputStream(uri) + val content = inputStream?.bufferedReader()?.use { it.readText() } + inputStream?.close() + + if (content != null) { + // Parse and import + val jsonData = JSONObject(content) + val importedProfile = inputControlsManager.importProfile(jsonData) + + if (importedProfile != null) { + Toast.makeText( + context, + "Profile '${importedProfile.name}' imported successfully", + Toast.LENGTH_SHORT + ).show() + onProfileImported() + onDismiss() + } else { + Toast.makeText( + context, + "Failed to import profile: Invalid format", + Toast.LENGTH_LONG + ).show() + } + } else { + Toast.makeText( + context, + "Failed to read file", + Toast.LENGTH_SHORT + ).show() + } + } catch (e: Exception) { + Toast.makeText( + context, + "Error importing profile: ${e.message}", + Toast.LENGTH_LONG + ).show() + } finally { + isImporting = false + } + } + } + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Header + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Upload, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = "Import Profile", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + } + + Text( + text = "Select a .icp profile file to import. The profile will be added to your collection.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + HorizontalDivider() + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.weight(1f), + enabled = !isImporting + ) { + Text("Cancel") + } + + Button( + onClick = { + // Launch file picker for all files (Android doesn't support custom extensions well) + filePickerLauncher.launch(arrayOf("*/*")) + }, + modifier = Modifier.weight(1f), + enabled = !isImporting + ) { + if (isImporting) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text("Choose File") + } + } + } + } + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/InAppFileBrowserDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/InAppFileBrowserDialog.kt new file mode 100644 index 000000000..e49e73490 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/dialog/InAppFileBrowserDialog.kt @@ -0,0 +1,375 @@ +package app.gamenative.ui.component.dialog + +import android.content.Context +import android.os.Environment +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import java.io.File +import java.text.SimpleDateFormat +import java.util.* + +/** + * In-app file browser dialog for selecting .icp profile files + * Avoids Activity recreation issues by staying within the app's Compose context + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InAppFileBrowserDialog( + context: Context, + onFileSelected: (File) -> Unit, + onDismiss: () -> Unit +) { + // Start at the default export location + val defaultDir = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + "Winlator/profiles" + ) + + var currentDirectory by remember { + mutableStateOf( + if (defaultDir.exists() && defaultDir.isDirectory) defaultDir + else Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + ) + } + var searchQuery by remember { mutableStateOf("") } + + // Get list of .icp files and directories + val items = remember(currentDirectory, searchQuery) { + try { + val files = currentDirectory.listFiles() + if (files == null || !currentDirectory.canRead()) { + emptyList() + } else { + val filtered = files.filter { file -> + try { + // Show directories (that can be read) or .icp files + ((file.isDirectory && file.canRead()) || + file.name.endsWith(".icp", ignoreCase = true)) && + (searchQuery.isBlank() || file.name.contains(searchQuery, ignoreCase = true)) + } catch (e: Exception) { + false + } + }.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() })) + filtered + } + } catch (e: Exception) { + emptyList() + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true, + dismissOnClickOutside = false, + decorFitsSystemWindows = false + ) + ) { + Card( + modifier = Modifier + .fillMaxWidth(0.95f) + .fillMaxHeight(0.85f), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier.fillMaxSize() + ) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Icon( + Icons.Default.FolderOpen, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Column { + Text( + text = "Select Profile File", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Text( + text = currentDirectory.name, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, "Close") + } + } + + HorizontalDivider() + + // Navigation bar + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Up button + Button( + onClick = { + currentDirectory.parentFile?.let { parent -> + currentDirectory = parent + } + }, + enabled = currentDirectory.parentFile != null + ) { + Icon( + Icons.Default.ArrowUpward, + contentDescription = "Up", + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Up") + } + + // Go to default location button + if (currentDirectory != defaultDir && defaultDir.exists()) { + OutlinedButton( + onClick = { currentDirectory = defaultDir } + ) { + Icon( + Icons.Default.Home, + contentDescription = "Default", + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Profiles") + } + } + } + + // Search field + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { Text("Search files...") }, + leadingIcon = { + Icon( + Icons.Default.Search, + contentDescription = "Search", + modifier = Modifier.size(20.dp) + ) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon( + Icons.Default.Clear, + contentDescription = "Clear", + modifier = Modifier.size(20.dp) + ) + } + } + }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + + // File list + if (items.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.FolderOff, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = if (searchQuery.isBlank()) "No files found" else "No matching files", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (currentDirectory != defaultDir && defaultDir.exists()) { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = { currentDirectory = defaultDir } + ) { + Text("Go to Profiles folder") + } + } + } + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(items) { file -> + FileItem( + file = file, + onClick = { + if (file.isDirectory) { + // Navigate into directory + try { + if (file.canRead()) { + currentDirectory = file + // Clear search when navigating + searchQuery = "" + } else { + android.widget.Toast.makeText( + context, + "Cannot access folder: ${file.name}", + android.widget.Toast.LENGTH_SHORT + ).show() + } + } catch (e: Exception) { + android.widget.Toast.makeText( + context, + "Error accessing folder: ${e.message}", + android.widget.Toast.LENGTH_SHORT + ).show() + } + } else { + // Select file + onFileSelected(file) + } + } + ) + } + } + } + } + } + } +} + +@Composable +private fun FileItem( + file: File, + onClick: () -> Unit +) { + val dateFormat = remember { SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()) } + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + colors = CardDefaults.cardColors( + containerColor = if (file.isDirectory) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Icon( + if (file.isDirectory) Icons.Default.Folder else Icons.Default.InsertDriveFile, + contentDescription = null, + tint = if (file.isDirectory) { + MaterialTheme.colorScheme.secondary + } else { + MaterialTheme.colorScheme.primary + }, + modifier = Modifier.size(32.dp) + ) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = file.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + if (!file.isDirectory) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = formatFileSize(file.length()), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = dateFormat.format(Date(file.lastModified())), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + Icon( + if (file.isDirectory) Icons.Default.ChevronRight else Icons.Default.Check, + contentDescription = if (file.isDirectory) "Open" else "Select", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + } + } +} + +private fun formatFileSize(bytes: Long): String { + return when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "${bytes / 1024} KB" + else -> "${bytes / (1024 * 1024)} MB" + } +} diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/InGameProfileManagerDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/InGameProfileManagerDialog.kt new file mode 100644 index 000000000..0df6abe09 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/dialog/InGameProfileManagerDialog.kt @@ -0,0 +1,216 @@ +package app.gamenative.ui.component.dialog + +import android.content.Context +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.winlator.container.Container +import com.winlator.inputcontrols.ControlsProfile + +/** + * In-game profile manager dialog + * + * Features: + * - Three buttons for profile creation methods + * - All profiles created are automatically locked to the current game + * - Returns created profile to caller for immediate application + */ +@Composable +fun InGameProfileManagerDialog( + context: Context, + container: Container, + currentProfileId: Int? = null, + onProfileSelected: (ControlsProfile) -> Unit, + onDismiss: () -> Unit +) { + var showSwitchProfile by remember { mutableStateOf(false) } + var showCreateProfile by remember { mutableStateOf(false) } + var showImportProfile by remember { mutableStateOf(false) } + + // Main dialog + if (!showSwitchProfile && !showCreateProfile && !showImportProfile) { + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Header + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Gamepad, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = "Manage Profiles", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + } + + Text( + text = "For: ${container.name}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + HorizontalDivider() + + // Switch Profile button + FilledTonalButton( + onClick = { showSwitchProfile = true }, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.SwapHoriz, contentDescription = null) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = "Switch Profile", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "Choose from existing profiles", + style = MaterialTheme.typography.bodySmall + ) + } + } + } + + HorizontalDivider() + + // Create New Profile button + FilledTonalButton( + onClick = { showCreateProfile = true }, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Add, contentDescription = null) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = "Create New Profile", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "Create blank or copy from template/existing profile", + style = MaterialTheme.typography.bodySmall + ) + } + } + } + + HorizontalDivider() + + // Import Profile button + FilledTonalButton( + onClick = { showImportProfile = true }, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Upload, contentDescription = null) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = "Import Profile", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "Import a profile from a file", + style = MaterialTheme.typography.bodySmall + ) + } + } + } + + HorizontalDivider() + + // Cancel button + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth() + ) { + Text("Cancel") + } + } + } + } + } + + // Sub-dialogs + if (showCreateProfile) { + UnifiedProfileCreationDialog( + context = context, + container = container, + onProfileCreated = { profile -> + onProfileSelected(profile) + onDismiss() + }, + onDismiss = { showCreateProfile = false } + ) + } + + if (showSwitchProfile) { + ProfilePickerDialog( + context = context, + container = container, + currentProfileId = currentProfileId ?: 0, + onProfileSelected = { profileId -> + // Load the selected profile and pass it back + val manager = com.winlator.inputcontrols.InputControlsManager(context) + manager.getProfile(profileId)?.let { profile -> + onProfileSelected(profile) + onDismiss() + } + }, + onDismiss = { showSwitchProfile = false } + ) + } + + if (showImportProfile) { + ImportProfileDialog( + context = context, + onProfileImported = { + // Optionally could select the imported profile here + // For now just dismiss + }, + onDismiss = { showImportProfile = false } + ) + } +} diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/PhysicalControllerConfigSection.kt b/app/src/main/java/app/gamenative/ui/component/dialog/PhysicalControllerConfigSection.kt new file mode 100644 index 000000000..650c813c1 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/dialog/PhysicalControllerConfigSection.kt @@ -0,0 +1,495 @@ +package app.gamenative.ui.component.dialog + +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.TouchApp +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.winlator.inputcontrols.ControlsProfile +import com.winlator.inputcontrols.InputControlsManager + +/** + * Physical Controller Configuration UI + * + * Displays and manages: + * - Detected physical controllers + * - Controller button bindings + * - Sensitivity settings + * - Quick presets for controller mappings + */ + + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun PhysicalControllerConfigSection( + profile: ControlsProfile, + onSwitchToOnScreen: () -> Unit, + onSave: () -> Unit, + onDismiss: () -> Unit, + onProfileUpdated: () -> Unit, + onRenameProfile: () -> Unit = {} +) { + val context = LocalContext.current + // Use state to track controllers so UI updates when controllers are added/removed + var controllers by remember { mutableStateOf(profile.getControllers()) } + var selectedControllerId by remember { mutableStateOf(controllers.firstOrNull()?.id) } + var showBindingDialog by remember { mutableStateOf?>(null) } + + // Refresh counter to force binding list recomposition when presets are applied + var bindingsRefreshKey by remember { mutableStateOf(0) } + + // Refresh controllers list when profile is updated + LaunchedEffect(profile) { + controllers = profile.getControllers() + if (selectedControllerId == null) { + selectedControllerId = controllers.firstOrNull()?.id + } + } + + // Update selectedControllerId if controllers list changes and current selection is invalid + LaunchedEffect(controllers) { + if (selectedControllerId == null && controllers.isNotEmpty()) { + selectedControllerId = controllers.first().id + } else if (selectedControllerId != null && controllers.none { it.id == selectedControllerId }) { + // Current selection no longer exists, select first available + selectedControllerId = controllers.firstOrNull()?.id + } + } + + var showSensitivitySettings by remember { mutableStateOf(false) } + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + // Fixed Header matching Controls Profiles style + CenterAlignedTopAppBar( + title = { + Text( + text = profile.name, + style = MaterialTheme.typography.titleLarge + ) + }, + actions = { + // Edit profile name button + IconButton(onClick = onRenameProfile) { + Icon(Icons.Default.Edit, "Rename Profile") + } + + // Export button + IconButton(onClick = { + val inputControlsManager = InputControlsManager(context) + val exportedFile = inputControlsManager.exportProfile(profile) + if (exportedFile != null) { + Toast.makeText( + context, + "Profile exported to:\n${exportedFile.absolutePath}", + Toast.LENGTH_LONG + ).show() + } else { + Toast.makeText( + context, + "Failed to export profile", + Toast.LENGTH_SHORT + ).show() + } + }) { + Icon(Icons.Default.FileDownload, "Export Profile") + } + + // Switch to On-Screen Controls button + IconButton(onClick = onSwitchToOnScreen) { + Icon(Icons.Default.TouchApp, "Switch to On-Screen Controls") + } + + // Save button + IconButton(onClick = onSave) { + Icon(Icons.Default.Check, "Save") + } + + // Close button + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, "Close") + } + } + ) + + // Content area with padding - scrollable (with side bars, not full width) + Column( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(0.85f) + .align(Alignment.CenterHorizontally) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 8.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Configure physical controller button mappings and sensitivity for this profile.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // Sensitivity settings toggle button + OutlinedButton( + onClick = { showSensitivitySettings = !showSensitivitySettings }, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + if (showSensitivitySettings) Icons.Default.Close else Icons.Default.Settings, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp) + ) + Text(if (showSensitivitySettings) "Hide Sensitivity Settings" else "Sensitivity Settings") + } + + // Sensitivity settings for physical controller (collapsible) + if (showSensitivitySettings) { + app.gamenative.ui.component.settings.SensitivitySettingsSection( + profile = profile, + isPhysicalController = true, + onSettingsChanged = { + onProfileUpdated() + } + ) + } + + // Show detected physical controllers + DetectedControllersCard() + + // Controller selector or add button + if (controllers.isEmpty()) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "No controller configuration", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + // Add default controller configuration + val controller = profile.addController("*") + controller.name = "Default Physical Controller" + profile.save() + + // Update local state to refresh UI immediately + controllers = profile.getControllers() + selectedControllerId = controller.id + + onProfileUpdated() + } + ) { + Text("Add Default Controller Config") + } + } + } + } else { + // Show controller info + val selectedController = controllers.find { it.id == selectedControllerId } ?: controllers.first() + + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Controller: ${selectedController.name}", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "ID: ${selectedController.id}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Quick Presets Section + BindingPresetsSection( + profile = profile, + controllerId = selectedController.id, + onProfileUpdated = { + // Use in-memory controllers (already saved to disk by preset function) + controllers = profile.getControllers() + bindingsRefreshKey++ + onProfileUpdated() + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Show bindings - use key to force recomposition + key(bindingsRefreshKey) { + ControllerBindingsSection( + profile = profile, + controllerId = selectedController.id, + onBindingClick = { keyCode, buttonName -> + showBindingDialog = Pair(keyCode, buttonName) + }, + onProfileUpdated = { + bindingsRefreshKey++ + onProfileUpdated() + } + ) + } + } + } + + // Binding dialog + showBindingDialog?.let { (keyCode, buttonName) -> + val controller = profile.getController(selectedControllerId ?: "*") + val currentBinding = controller?.controllerBindings?.find { + it.keyCodeForAxis == keyCode + }?.binding + + ControllerBindingDialog( + buttonName = buttonName, + currentBinding = currentBinding, + onDismiss = { showBindingDialog = null }, + onBindingSelected = { binding: com.winlator.inputcontrols.Binding? -> + controller?.let { + // Remove existing binding for this keyCode + it.controllerBindings.removeIf { b -> b.keyCodeForAxis == keyCode } + + // Add new binding if not null + if (binding != null) { + val newBinding = com.winlator.inputcontrols.ExternalControllerBinding() + newBinding.setKeyCode(keyCode) + newBinding.binding = binding + it.controllerBindings.add(newBinding) + } + + profile.save() + // Use in-memory controllers (already saved to disk) + controllers = profile.getControllers() + bindingsRefreshKey++ + onProfileUpdated() + } + showBindingDialog = null + } + ) + } + } +} + +/** + * Shows all button bindings for a controller + */ + +@Composable +internal fun ControllerBindingsSection( + profile: ControlsProfile, + controllerId: String, + onBindingClick: (Int, String) -> Unit, + onProfileUpdated: () -> Unit +) { + Text( + text = "Button Mappings", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(vertical = 8.dp) + ) + + val controller = profile.getController(controllerId) + + // Standard gamepad buttons + val buttonMappings = listOf( + 96 to "A Button", + 97 to "B Button", + 98 to "X Button", + 99 to "Y Button", + 100 to "L1 (LB)", + 101 to "R1 (RB)", + 102 to "L2 (LT)", + 103 to "R2 (RT)", + 106 to "L3 (Left Stick Click)", + 107 to "R3 (Right Stick Click)", + 108 to "Select/Back", + 109 to "Start", + 19 to "D-Pad Up", + 20 to "D-Pad Down", + 21 to "D-Pad Left", + 22 to "D-Pad Right" + ) + + // Analog stick axes (matches ExternalControllerBinding constants) + val analogMappings = listOf( + -3 to "Left Stick Up", // AXIS_Y_NEGATIVE + -4 to "Left Stick Down", // AXIS_Y_POSITIVE + -1 to "Left Stick Left", // AXIS_X_NEGATIVE + -2 to "Left Stick Right", // AXIS_X_POSITIVE + -7 to "Right Stick Up", // AXIS_RZ_NEGATIVE + -8 to "Right Stick Down", // AXIS_RZ_POSITIVE + -5 to "Right Stick Left", // AXIS_Z_NEGATIVE + -6 to "Right Stick Right" // AXIS_Z_POSITIVE + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + buttonMappings.forEach { (keyCode, buttonName) -> + val binding = controller?.controllerBindings?.find { it.keyCodeForAxis == keyCode } + + BindingListItem( + buttonName = buttonName, + binding = binding?.binding, + onClick = { onBindingClick(keyCode, buttonName) } + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Analog Sticks", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(vertical = 8.dp) + ) + + analogMappings.forEach { (keyCode, buttonName) -> + val binding = controller?.controllerBindings?.find { it.keyCodeForAxis == keyCode } + + BindingListItem( + buttonName = buttonName, + binding = binding?.binding, + onClick = { onBindingClick(keyCode, buttonName) } + ) + } + } +} + + +@Composable +internal fun BindingListItem( + buttonName: String, + binding: com.winlator.inputcontrols.Binding?, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = buttonName, + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = binding?.toString() ?: "Not mapped", + style = MaterialTheme.typography.bodySmall, + color = if (binding != null) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + } + } +} + +/** + * Shows detected physical controllers + */ + +@Composable +internal fun DetectedControllersCard() { + // Get detected physical controllers + val detectedControllers = remember { + com.winlator.inputcontrols.ExternalController.getControllers() + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (detectedControllers.isNotEmpty()) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "Detected Physical Controllers", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + if (detectedControllers.isEmpty()) { + Text( + text = "No physical controllers detected. Please connect a controller.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + detectedControllers.forEach { controller -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = controller.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = "Device ID: ${controller.deviceId}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Connected", + tint = MaterialTheme.colorScheme.primary + ) + } + HorizontalDivider() + } + } + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ProfilePickerDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ProfilePickerDialog.kt new file mode 100644 index 000000000..acf508961 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ProfilePickerDialog.kt @@ -0,0 +1,267 @@ +package app.gamenative.ui.component.dialog + +import android.content.Context +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Public +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.winlator.container.Container +import com.winlator.inputcontrols.ControlsProfile +import com.winlator.inputcontrols.InputControlsManager + +/** + * Dialog for selecting input controls profile + * + * Features: + * - Shows global profiles + profiles locked to current container by default + * - Toggle to show profiles from other games + * - Displays lock status with container name + * - Shows templates separately (not selectable) + */ +@Composable +fun ProfilePickerDialog( + context: Context, + container: Container?, + currentProfileId: Int, + onProfileSelected: (Int) -> Unit, + onDismiss: () -> Unit +) { + val inputControlsManager = remember { InputControlsManager(context) } + var showAllProfiles by remember { mutableStateOf(false) } + + // Get filtered profiles based on toggle state + val selectableProfiles = remember(showAllProfiles, container) { + if (showAllProfiles || container == null) { + // Show all profiles (excluding templates) + inputControlsManager.getProfiles(true) + } else { + // Show global + this game's locked profiles + inputControlsManager.getProfilesForContainer(container.id.toString()) + } + } + + // Get templates separately (always shown, not selectable) + val templates = remember { + inputControlsManager.templates + } + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.8f), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Header + Text( + text = "Select Input Profile", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + + // Toggle for showing all profiles + if (container != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Show profiles from other games", + style = MaterialTheme.typography.bodyMedium + ) + Switch( + checked = showAllProfiles, + onCheckedChange = { showAllProfiles = it } + ) + } + HorizontalDivider() + } + + // Scrollable profile list + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Selectable profiles + items(selectableProfiles) { profile -> + ProfileCard( + profile = profile, + isSelected = profile.id == currentProfileId, + isSelectable = true, + container = container, + onClick = { + onProfileSelected(profile.id) + onDismiss() + } + ) + } + + // Templates section (shown separately, not selectable) + if (templates.isNotEmpty()) { + item { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Templates (use 'Create from Template' to use these)", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + + items(templates) { template -> + ProfileCard( + profile = template, + isSelected = false, + isSelectable = false, + container = null, + onClick = { /* Templates not selectable */ } + ) + } + } + } + + // Close button + Button( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth() + ) { + Text("Close") + } + } + } + } +} + +@Composable +private fun ProfileCard( + profile: ControlsProfile, + isSelected: Boolean, + isSelectable: Boolean, + container: Container?, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .then( + if (isSelectable) { + Modifier.clickable(onClick = onClick) + } else { + Modifier + } + ), + colors = CardDefaults.cardColors( + containerColor = when { + isSelected -> MaterialTheme.colorScheme.primaryContainer + !isSelectable -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + else -> MaterialTheme.colorScheme.surfaceVariant + } + ), + elevation = CardDefaults.cardElevation( + defaultElevation = if (isSelected) 4.dp else 2.dp + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Lock/Global icon + if (profile.isLockedToGame) { + Icon( + Icons.Default.Lock, + contentDescription = "Game-locked profile", + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(20.dp) + ) + } else { + Icon( + Icons.Default.Public, + contentDescription = "Global profile", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + } + + Text( + text = profile.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal + ) + } + + // Show container name if locked + if (profile.isLockedToGame) { + val containerName = getContainerDisplayName(profile.lockedToContainer, container) + Text( + text = "Locked to: $containerName", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Template indicator + if (profile.isTemplate) { + Text( + text = "Template", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary + ) + } + } + + // Selection indicator + if (isSelected) { + Icon( + Icons.Default.Check, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + } + } + } +} + +/** + * Helper function to get display name for a container + */ +private fun getContainerDisplayName(lockedToContainer: String?, currentContainer: Container?): String { + if (lockedToContainer == null) return "Unknown" + + // If this is the current container, show "This Game" + if (currentContainer != null && lockedToContainer == currentContainer.id.toString()) { + return "This Game (${currentContainer.name})" + } + + // Otherwise, try to extract a readable name from the container ID + // For now, just return the ID - can be enhanced later to look up actual container names + return "Game #$lockedToContainer" +} diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/UnifiedProfileCreationDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/UnifiedProfileCreationDialog.kt new file mode 100644 index 000000000..83d1f9593 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/dialog/UnifiedProfileCreationDialog.kt @@ -0,0 +1,836 @@ +package app.gamenative.ui.component.dialog + +import android.content.Context +import android.widget.Toast +import android.util.Log +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.winlator.container.Container +import com.winlator.inputcontrols.ControlsProfile +import com.winlator.inputcontrols.InputControlsManager + +/** + * Unified dialog for creating new profiles + * + * Features: + * - Three tabs: Templates, Global Profiles, Game-Locked Profiles + * - Search functionality for each tab + * - Options to create blank or copy from selected profile + * - Input field for new profile name + * - Checkbox to lock to current game + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UnifiedProfileCreationDialog( + context: Context, + container: Container?, + onProfileCreated: (ControlsProfile) -> Unit, + onDismiss: () -> Unit +) { + val inputControlsManager = remember { InputControlsManager(context) } + val configuration = LocalConfiguration.current + val isLandscape = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE + + // Tab selection: 0 = Templates, 1 = Global Profiles, 2 = Game-Locked Profiles + var selectedTab by rememberSaveable { mutableStateOf(0) } + var searchQuery by rememberSaveable { mutableStateOf("") } + + // Get profiles based on selected tab + val allProfiles = remember(selectedTab) { + when (selectedTab) { + 0 -> inputControlsManager.templates // Templates + 1 -> inputControlsManager.globalProfiles.filter { !it.isTemplate } // Global profiles (non-templates) + 2 -> inputControlsManager.getProfiles(false).filter { it.isLockedToGame } // Game-locked profiles + else -> emptyList() + } + } + + // Filter profiles by search query + val profiles = remember(allProfiles, searchQuery) { + if (searchQuery.isBlank()) { + allProfiles + } else { + allProfiles.filter { it.name.contains(searchQuery, ignoreCase = true) } + } + } + + var selectedProfile by remember { mutableStateOf(null) } + var profileName by rememberSaveable { mutableStateOf("") } + var lockToGame by rememberSaveable { mutableStateOf(container != null) } // Default to locked if container provided + var isCreating by remember { mutableStateOf(false) } + var createMode by rememberSaveable { mutableStateOf(null) } // null = not chosen yet, BLANK, COPY, or IMPORT + var showFileBrowser by remember { mutableStateOf(false) } + + // Handle file selection from in-app browser + fun handleFileImport(file: java.io.File) { + try { + // Read file content directly + val content = file.readText() + + // Parse and import + val jsonData = org.json.JSONObject(content) + inputControlsManager.getProfiles() // Ensure profiles are loaded + val importedProfile = inputControlsManager.importProfile(jsonData) + + if (importedProfile != null) { + // Apply game lock if specified + if (lockToGame && container != null) { + importedProfile.setLockedToContainer(container.id.toString()) + importedProfile.save() + } + + Toast.makeText( + context, + "Profile '${importedProfile.name}' imported successfully", + Toast.LENGTH_LONG + ).show() + + // Notify parent to refresh the list and close dialog + onProfileCreated(importedProfile) + onDismiss() + } else { + Toast.makeText( + context, + "Failed to import profile: Invalid format", + Toast.LENGTH_LONG + ).show() + } + } catch (e: Exception) { + Toast.makeText( + context, + "Error importing profile: ${e.message}", + Toast.LENGTH_LONG + ).show() + e.printStackTrace() + } + } + + // Reset selection when changing tabs + LaunchedEffect(selectedTab) { + selectedProfile = null + searchQuery = "" + } + + // Auto-generate profile name when source profile is selected (for copy mode) + LaunchedEffect(selectedProfile, createMode, container) { + if (createMode == CreateMode.COPY && selectedProfile != null) { + val source = selectedProfile!! + // Strip "Template" from the source name if it's a template + val sourceNameWithoutTemplate = source.name.replace(Regex("\\s*[Tt]emplate\\s*"), " ").trim() + + val baseName = if (container != null) { + if (source.isTemplate) { + // Use template name without "Template" word + "${container.name} - $sourceNameWithoutTemplate" + } else { + "${container.name} Profile" + } + } else { + // No container - creating global profile + if (source.isTemplate) { + // Use template name without "Template" word + sourceNameWithoutTemplate.ifEmpty { "New Profile" } + } else { + "New Profile" + } + } + + // Find next available number if name exists + val allExistingProfiles = inputControlsManager.getProfiles(false) + var finalName = baseName + var counter = 2 + + while (allExistingProfiles.any { it.name == finalName }) { + finalName = "$baseName $counter" + counter++ + } + + profileName = finalName + } else if (createMode == CreateMode.BLANK) { + // Auto-generate name for blank profile + val allExistingProfiles = inputControlsManager.getProfiles(false) + val baseName = if (container != null) { + "${container.name} Profile" + } else { + "New Profile" + } + var finalName = baseName + var counter = 2 + + while (allExistingProfiles.any { it.name == finalName }) { + finalName = "$baseName $counter" + counter++ + } + + profileName = finalName + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true, + dismissOnClickOutside = false, + decorFitsSystemWindows = false + ) + ) { + Card( + modifier = Modifier + .fillMaxWidth(0.95f) + .fillMaxHeight(if (isLandscape) 0.95f else 0.9f), // Use more height in landscape + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier.fillMaxSize() + ) { + // Header - more compact in landscape + Row( + modifier = Modifier + .fillMaxWidth() + .padding(if (isLandscape) 8.dp else 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(if (isLandscape) 20.dp else 24.dp) + ) + Column { + Text( + text = "Create New Profile", + style = if (isLandscape) MaterialTheme.typography.titleMedium else MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + if (container != null) { + Text( + text = "For: ${container.name}", + style = if (isLandscape) MaterialTheme.typography.labelMedium else MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, "Close") + } + } + + HorizontalDivider() + + // Show mode selection dialog first if mode not chosen + if (createMode == null) { + // Mode selection screen - more compact in landscape + Column( + modifier = Modifier + .fillMaxSize() + .padding(if (isLandscape) 12.dp else 24.dp), + verticalArrangement = Arrangement.spacedBy(if (isLandscape) 8.dp else 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (!isLandscape) { + Spacer(modifier = Modifier.weight(0.5f)) + } + + Text( + text = "How would you like to create the profile?", + style = if (isLandscape) MaterialTheme.typography.titleMedium else MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(if (isLandscape) 4.dp else 16.dp)) + + // Create Blank option - more compact in landscape + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { createMode = CreateMode.BLANK }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(if (isLandscape) 12.dp else 20.dp), + horizontalArrangement = Arrangement.spacedBy(if (isLandscape) 12.dp else 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.NoteAdd, + contentDescription = null, + modifier = Modifier.size(if (isLandscape) 32.dp else 48.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = "Create Blank Profile", + style = if (isLandscape) MaterialTheme.typography.titleMedium else MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text( + text = "Start with an empty profile to customize from scratch", + style = if (isLandscape) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // Copy from Existing option - more compact in landscape + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { createMode = CreateMode.COPY }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(if (isLandscape) 12.dp else 20.dp), + horizontalArrangement = Arrangement.spacedBy(if (isLandscape) 12.dp else 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.FileCopy, + contentDescription = null, + modifier = Modifier.size(if (isLandscape) 32.dp else 48.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = "Copy from Existing", + style = if (isLandscape) MaterialTheme.typography.titleMedium else MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text( + text = "Copy from templates, global profiles, or game-locked profiles", + style = if (isLandscape) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // Import Profile option - more compact in landscape + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { + showFileBrowser = true + }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer + ), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(if (isLandscape) 12.dp else 20.dp), + horizontalArrangement = Arrangement.spacedBy(if (isLandscape) 12.dp else 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Upload, + contentDescription = null, + modifier = Modifier.size(if (isLandscape) 32.dp else 48.dp), + tint = MaterialTheme.colorScheme.onTertiaryContainer + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = "Import Profile", + style = if (isLandscape) MaterialTheme.typography.titleMedium else MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text( + text = "Import a profile from a .icp file", + style = if (isLandscape) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + if (!isLandscape) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } else { + // Mode has been chosen, show the appropriate UI + + // Profile name and settings section - more compact in landscape + Column( + modifier = Modifier + .fillMaxWidth() + .padding(if (isLandscape) 8.dp else 16.dp), + verticalArrangement = Arrangement.spacedBy(if (isLandscape) 8.dp else 12.dp) + ) { + // Profile name input + OutlinedTextField( + value = profileName, + onValueChange = { profileName = it }, + label = { Text("New Profile Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isCreating + ) + + // Lock to game checkbox (only show if container is provided) + if (container != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Lock, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(if (isLandscape) 16.dp else 20.dp) + ) + Column { + Text( + text = "Lock to ${container.name}", + style = if (isLandscape) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + if (!isLandscape) { + Text( + text = "Only show this profile for this game", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + Checkbox( + checked = lockToGame, + onCheckedChange = { lockToGame = it }, + enabled = !isCreating + ) + } + } + } + + HorizontalDivider() + + // Show profile selection UI only in COPY mode + if (createMode == CreateMode.COPY) { + // Tabs for profile categories + TabRow( + selectedTabIndex = selectedTab, + modifier = Modifier.fillMaxWidth() + ) { + Tab( + selected = selectedTab == 0, + onClick = { selectedTab = 0 }, + text = { Text("Templates", style = MaterialTheme.typography.labelMedium) } + ) + Tab( + selected = selectedTab == 1, + onClick = { selectedTab = 1 }, + text = { Text("Global", style = MaterialTheme.typography.labelMedium) } + ) + Tab( + selected = selectedTab == 2, + onClick = { selectedTab = 2 }, + text = { Text("Game-Locked", style = MaterialTheme.typography.labelMedium) } + ) + } + + // Search field + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { Text("Search profiles...") }, + leadingIcon = { + Icon( + Icons.Default.Search, + contentDescription = "Search", + modifier = Modifier.size(20.dp) + ) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon( + Icons.Default.Clear, + contentDescription = "Clear", + modifier = Modifier.size(20.dp) + ) + } + } + }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + enabled = !isCreating + ) + + // Profile list + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (profiles.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = when (selectedTab) { + 0 -> if (searchQuery.isBlank()) "No templates available" else "No templates found" + 1 -> if (searchQuery.isBlank()) "No global profiles available" else "No global profiles found" + 2 -> if (searchQuery.isBlank()) "No game-locked profiles available" else "No game-locked profiles found" + else -> "No profiles available" + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + profiles.forEach { profile -> + ProfileCard( + profile = profile, + isSelected = profile == selectedProfile, + container = container, + onClick = { selectedProfile = profile }, + enabled = !isCreating, + isLandscape = isLandscape + ) + } + } + } + } else { + // Blank mode - just show some info text + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.NoteAdd, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = "Create a blank profile", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = "Start with an empty profile to customize from scratch", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + HorizontalDivider() + + // Action buttons (only show when mode is selected) - more compact in landscape + if (createMode != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(if (isLandscape) 8.dp else 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.weight(1f), + enabled = !isCreating + ) { + Text("Cancel") + } + + Button( + onClick = { + val name = profileName.trim() + + // Validation + if (name.isEmpty()) { + Toast.makeText(context, "Please enter a profile name", Toast.LENGTH_SHORT).show() + return@Button + } + + if (createMode == CreateMode.COPY && selectedProfile == null) { + Toast.makeText(context, "Please select a profile to copy", Toast.LENGTH_SHORT).show() + return@Button + } + + isCreating = true + + try { + val lockedToContainer = if (lockToGame && container != null) { + container.id.toString() + } else { + null + } + + val newProfile = if (createMode == CreateMode.BLANK) { + // Create blank profile + val profile = inputControlsManager.createProfile(name) + profile.setLockedToContainer(lockedToContainer) + profile.save() + profile + } else { + // Clone selected profile + inputControlsManager.cloneProfile( + selectedProfile!!, + name, + lockedToContainer + ) + } + + Toast.makeText( + context, + "Profile '$name' created successfully", + Toast.LENGTH_SHORT + ).show() + + onProfileCreated(newProfile) + onDismiss() + } catch (e: Exception) { + Toast.makeText( + context, + "Failed to create profile: ${e.message}", + Toast.LENGTH_LONG + ).show() + isCreating = false + } + }, + modifier = Modifier.weight(1f), + enabled = !isCreating && profileName.trim().isNotEmpty() && + (createMode == CreateMode.BLANK || selectedProfile != null) + ) { + if (isCreating) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text("Create") + } + } + } + } // End createMode != null + } // End createMode == null else + } + } + } + + // Show in-app file browser dialog + if (showFileBrowser) { + InAppFileBrowserDialog( + context = context, + onFileSelected = { file -> + showFileBrowser = false + handleFileImport(file) + }, + onDismiss = { + showFileBrowser = false + } + ) + } +} + +@Composable +private fun ProfileCard( + profile: ControlsProfile, + isSelected: Boolean, + container: Container?, + onClick: () -> Unit, + enabled: Boolean, + isLandscape: Boolean = false +) { + Card( + modifier = Modifier + .fillMaxWidth() + .then( + if (enabled) { + Modifier.clickable(onClick = onClick) + } else { + Modifier + } + ), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + ), + elevation = CardDefaults.cardElevation( + defaultElevation = if (isSelected) 4.dp else 2.dp + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(if (isLandscape) 10.dp else 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + // Row 1: Profile name with icon + Row( + horizontalArrangement = Arrangement.spacedBy(if (isLandscape) 6.dp else 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Icon based on profile type + if (profile.isTemplate) { + Icon( + Icons.Default.Description, + contentDescription = "Template", + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(if (isLandscape) 16.dp else 20.dp) + ) + } else if (profile.isLockedToGame) { + Icon( + Icons.Default.Lock, + contentDescription = "Game-locked profile", + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(if (isLandscape) 16.dp else 20.dp) + ) + } else { + Icon( + Icons.Default.Public, + contentDescription = "Global profile", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(if (isLandscape) 16.dp else 20.dp) + ) + } + + Text( + text = profile.name, + style = if (isLandscape) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.titleMedium, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal + ) + } + + // Row 2: ID and element count + Text( + text = "ID: ${profile.id} • ${profile.elements.size} elements", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // Row 3: Type/status + if (profile.isTemplate) { + Text( + text = "Template", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary + ) + } else if (profile.isLockedToGame) { + val containerName = getContainerDisplayName(profile.lockedToContainer, container) + Text( + text = "Locked to: $containerName", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.secondary + ) + } else { + Text( + text = "Global profile", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + + if (isSelected) { + Icon( + Icons.Default.Check, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + } + } + } +} + +/** + * Helper function to get display name for a container + */ +private fun getContainerDisplayName(lockedToContainer: String?, currentContainer: Container?): String { + if (lockedToContainer == null) return "Unknown" + + // If this is the current container, show "This Game" + if (currentContainer != null && lockedToContainer == currentContainer.id.toString()) { + return "This Game (${currentContainer.name})" + } + + // Otherwise, try to extract a readable name from the container ID + return "Game #$lockedToContainer" +} + +/** + * Mode for creating profiles + */ +private enum class CreateMode { + BLANK, // Create empty profile + COPY, // Copy from existing profile + IMPORT // Import from file +} diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/UnifiedProfileEditorDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/UnifiedProfileEditorDialog.kt new file mode 100644 index 000000000..72c60b478 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/dialog/UnifiedProfileEditorDialog.kt @@ -0,0 +1,972 @@ +package app.gamenative.ui.component.dialog + +import android.content.Context +import android.view.LayoutInflater +import android.widget.FrameLayout +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.Gamepad +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.TouchApp +import androidx.compose.material.icons.filled.Undo +import androidx.compose.material.icons.filled.Redo +import androidx.compose.material3.* +import androidx.compose.runtime.* +import kotlinx.coroutines.launch +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import kotlinx.coroutines.isActive +import com.winlator.inputcontrols.ControlsProfile +import com.winlator.inputcontrols.InputControlsManager +import com.winlator.widget.InputControlsView +import app.gamenative.R +import app.gamenative.PluviaApp +import app.gamenative.PrefManager +import app.gamenative.events.AndroidEvent +import app.gamenative.ui.enums.Orientation +import java.util.EnumSet + +/** + * Unified Profile Editor Dialog + * + * Allows editing both: + * 1. Virtual controls (on-screen elements via InputControlsView) + * 2. Physical controller bindings (via controller configuration UI) + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UnifiedProfileEditorDialog( + profile: ControlsProfile, + initialTab: Int = 0, + container: com.winlator.container.Container? = null, // Optional - used to detect if in-game or settings + onDismiss: () -> Unit, + onSave: () -> Unit +) { + val context = LocalContext.current + val configuration = LocalConfiguration.current + val coroutineScope = rememberCoroutineScope() + val isInGame = container != null // If container is provided, we're in-game + var selectedTab by remember { mutableStateOf(initialTab) } + var inputControlsView by remember { mutableStateOf(null) } + var showElementEditor by remember { mutableStateOf(null) } + var showSensitivityDialog by remember { mutableStateOf(false) } + var showFloatingToolbar by remember { mutableStateOf(true) } + var isToolbarExpanded by remember { mutableStateOf(true) } + var selectedElement by remember { mutableStateOf(null) } + var showRenameDialog by remember { mutableStateOf(false) } + + // Undo/Redo functionality - stores element position snapshots + data class ElementSnapshot(val element: com.winlator.inputcontrols.ControlElement, val x: Int, val y: Int, val scale: Float) + val undoStack = remember { mutableStateListOf() } + val redoStack = remember { mutableStateListOf() } + val maxStackSize = 20 + var isPerformingUndoRedo by remember { mutableStateOf(false) } + + // Enforce allowed orientations based on context and tab + // - On-screen controls (tab 0): Always respect user's orientation settings from PrefManager + // - Physical controller editor (tab 1): Never lock orientation, allow free rotation + DisposableEffect(selectedTab) { + if (selectedTab == 0) { + // Editing on-screen controls - apply user's orientation lock settings + PluviaApp.events.emit(AndroidEvent.SetAllowedOrientation(PrefManager.allowedOrientation)) + } else { + // Physical controller editor - unlock orientation, allow any rotation + PluviaApp.events.emit(AndroidEvent.SetAllowedOrientation(EnumSet.of(Orientation.UNSPECIFIED))) // Allow all orientations + } + + // ALWAYS hide system UI (status bar and navigation bar) for immersive editing experience + // This applies to both on-screen controls AND physical controller editing + PluviaApp.events.emit(AndroidEvent.SetSystemUIVisibility(false)) + + onDispose { + // Clear inputControlsView reference to prevent memory leaks + inputControlsView = null + + // Restore system UI only when exiting from settings context + // When in-game, keep system UI hidden (it was already hidden before opening editor) + if (!isInGame) { + PluviaApp.events.emit(AndroidEvent.SetSystemUIVisibility(true)) + } + + // When exiting the editor, reset orientation based on context + if (!isInGame) { + // In settings - reset to portrait only + PluviaApp.events.emit(AndroidEvent.SetAllowedOrientation(EnumSet.of(Orientation.PORTRAIT))) + } + // If in-game, the XServerScreen will manage orientation when this dialog closes + } + } + + // Track element state changes for undo + var lastTrackedElement by remember { mutableStateOf(null) } + var lastTrackedX by remember { mutableStateOf(null) } + var lastTrackedY by remember { mutableStateOf(null) } + var lastTrackedScale by remember { mutableStateOf(null) } + + // Position from previous poll cycle (used to detect movement between polls) + var lastSeenX by remember { mutableStateOf(null) } + var lastSeenY by remember { mutableStateOf(null) } + var lastSeenScale by remember { mutableStateOf(null) } + + // Floating toolbar position state for on-screen controls - initialized to center top + // Calculate center position: (screenWidth - toolbarWidth) / 2 + // Toolbar width is max 280dp, so we offset by -140dp from center + val screenWidthPx = configuration.screenWidthDp * context.resources.displayMetrics.density + val toolbarWidthPx = 280 * context.resources.displayMetrics.density + val initialOffsetX = (screenWidthPx - toolbarWidthPx) / 2 + val initialOffsetY = 16f // 16px from top for a cleaner look + + var toolbarOffsetX by remember { mutableStateOf(initialOffsetX) } + var toolbarOffsetY by remember { mutableStateOf(initialOffsetY) } + + // Load profile fresh each time + val currentProfile = remember(profile.id) { + InputControlsManager(context).getProfile(profile.id) ?: profile + } + + // Function to save element state for undo + fun saveElementStateForUndo(element: com.winlator.inputcontrols.ControlElement, x: Int? = null, y: Int? = null, scale: Float? = null) { + val snapshot = ElementSnapshot( + element, + x ?: element.x.toInt(), + y ?: element.y.toInt(), + scale ?: element.scale + ) + undoStack.add(snapshot) + // Clear redo stack when new change is made (can't redo after new action) + redoStack.clear() + // Limit stack size + if (undoStack.size > maxStackSize) { + undoStack.removeAt(0) + } + } + + // Function to perform undo + fun performUndo() { + if (undoStack.isNotEmpty()) { + isPerformingUndoRedo = true + val snapshot = undoStack.removeAt(undoStack.size - 1) + + // Save current state to redo stack before undoing + val currentState = ElementSnapshot( + snapshot.element, + snapshot.element.x.toInt(), + snapshot.element.y.toInt(), + snapshot.element.scale + ) + redoStack.add(currentState) + if (redoStack.size > maxStackSize) { + redoStack.removeAt(0) + } + + // Apply the undo + snapshot.element.setX(snapshot.x) + snapshot.element.setY(snapshot.y) + snapshot.element.setScale(snapshot.scale) + + inputControlsView?.invalidate() + + // Reset flag immediately and update tracking state + // We do this synchronously to ensure the tracking coroutine sees the correct state + isPerformingUndoRedo = false + + // Update BOTH tracking baseline and last seen position to prevent re-tracking + lastTrackedElement = snapshot.element + lastTrackedX = snapshot.x + lastTrackedY = snapshot.y + lastTrackedScale = snapshot.scale + lastSeenX = snapshot.x + lastSeenY = snapshot.y + lastSeenScale = snapshot.scale + } + } + + // Function to perform redo + fun performRedo() { + if (redoStack.isNotEmpty()) { + isPerformingUndoRedo = true + val snapshot = redoStack.removeAt(redoStack.size - 1) + + // Save current state to undo stack before redoing + val currentState = ElementSnapshot( + snapshot.element, + snapshot.element.x.toInt(), + snapshot.element.y.toInt(), + snapshot.element.scale + ) + undoStack.add(currentState) + if (undoStack.size > maxStackSize) { + undoStack.removeAt(0) + } + + // Apply the redo + snapshot.element.setX(snapshot.x) + snapshot.element.setY(snapshot.y) + snapshot.element.setScale(snapshot.scale) + + inputControlsView?.invalidate() + + // Reset flag immediately and update tracking state + // We do this synchronously to ensure the tracking coroutine sees the correct state + isPerformingUndoRedo = false + + // Update BOTH tracking baseline and last seen position to prevent re-tracking + lastTrackedElement = snapshot.element + lastTrackedX = snapshot.x + lastTrackedY = snapshot.y + lastTrackedScale = snapshot.scale + lastSeenX = snapshot.x + lastSeenY = snapshot.y + lastSeenScale = snapshot.scale + } + } + + // Poll for selected element changes and track position/scale changes + // We save the element state when the user STARTS moving it, not during the drag + // This creates a snapshot of the position BEFORE the drag, so undo restores the pre-drag position + LaunchedEffect(inputControlsView) { + var isTracking = false + var trackedElement: com.winlator.inputcontrols.ControlElement? = null + // Note: lastSeenX/Y/Scale and lastTrackedX/Y/Scale are now state variables in outer scope + // so they can be updated from performUndo()/performRedo() to prevent re-tracking + + try { + while (isActive) { // Check if coroutine is still active to prevent memory leaks + kotlinx.coroutines.delay(100) // Check every 100ms + inputControlsView?.let { view -> + val currentSelected = view.selectedElement + selectedElement = currentSelected + + // Skip tracking if we're performing undo/redo to avoid creating new snapshots + if (isPerformingUndoRedo) { + return@let + } + + // Track element position changes for undo + if (currentSelected != null) { + val currentX = currentSelected.x.toInt() + val currentY = currentSelected.y.toInt() + val currentScale = currentSelected.scale + + // When element selection changes, update the baseline position + if (lastTrackedElement != currentSelected) { + // Brand new element selected - update baseline + lastTrackedElement = currentSelected + lastTrackedX = currentX + lastTrackedY = currentY + lastTrackedScale = currentScale + lastSeenX = currentX + lastSeenY = currentY + lastSeenScale = currentScale + // Reset tracking state since this is a new element + isTracking = false + } else { + // Same element - check if it's moving + val isMoving = lastSeenX != null && lastSeenY != null && + (currentX != lastSeenX || currentY != lastSeenY || currentScale != lastSeenScale) + + if (isMoving && !isTracking) { + // Movement just started - save snapshot with the baseline position BEFORE movement + // Use lastTrackedX/Y/Scale which holds the position before movement started + saveElementStateForUndo(currentSelected, lastTrackedX, lastTrackedY, lastTrackedScale) + isTracking = true + trackedElement = currentSelected + } else if (!isMoving && isTracking) { + // Movement stopped - update baseline to final position + lastTrackedX = currentX + lastTrackedY = currentY + lastTrackedScale = currentScale + isTracking = false + trackedElement = null + } + + // Always update last seen position + lastSeenX = currentX + lastSeenY = currentY + lastSeenScale = currentScale + } + } else { + // No element selected - reset tracking state + isTracking = false + trackedElement = null + lastTrackedElement = null + lastSeenX = null + lastSeenY = null + lastSeenScale = null + } + } + } + } finally { + // Cleanup when coroutine is cancelled + } + } + + Dialog( + onDismissRequest = { + // Don't save on dismiss - only on explicit save + undoStack.clear() // Clear temp undo log on dismiss + redoStack.clear() // Clear temp redo log on dismiss + onDismiss() + }, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true, + dismissOnClickOutside = false, + decorFitsSystemWindows = false + ) + ) { + // Apply system UI hiding directly to the Dialog's window for full immersive mode + val dialogWindow = (LocalView.current.parent as? DialogWindowProvider)?.window + SideEffect { + dialogWindow?.let { window -> + // Use WindowCompat and WindowInsetsControllerCompat for proper system UI hiding + WindowCompat.setDecorFitsSystemWindows(window, false) + val insetsController = WindowCompat.getInsetsController(window, window.decorView) + insetsController?.let { controller -> + // Hide both status bars and navigation bars + controller.hide(WindowInsetsCompat.Type.systemBars()) + // Use BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE for immersive sticky mode + controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } + } + + // Full screen Box for canvas + Box(modifier = Modifier.fillMaxSize()) { + // Don't apply padding for On-Screen Controls tab to avoid black bars + when (selectedTab) { + 0 -> { + // Virtual Controls Editor Tab - Full screen without any padding + Box(modifier = Modifier.fillMaxSize()) { + // AndroidView for InputControlsView + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + val view = InputControlsView(ctx) + view.setProfile(currentProfile) + view.setEditMode(true) + // Show touchscreen controls in edit mode so elements are visible + view.setShowTouchscreenControls(true) + view.setOverlayOpacity(1.0f) // Full opacity for clean white visibility in edit mode + view.invalidate() // Force redraw with new opacity + inputControlsView = view + view + } + ) + + // Compact collapsible floating toolbar + if (showFloatingToolbar) { + Surface( + modifier = Modifier + .offset { IntOffset(toolbarOffsetX.toInt(), toolbarOffsetY.toInt()) } + .padding(8.dp) + .widthIn(max = 280.dp), + shape = MaterialTheme.shapes.medium, + color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.95f), + shadowElevation = 8.dp + ) { + Column( + modifier = Modifier + .padding(horizontal = 8.dp, vertical = 4.dp) + .widthIn(max = 280.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Always-visible header: Drag handle + Profile name + Save/Close + Expand button + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Drag handle + Icon( + Icons.Default.DragHandle, + contentDescription = "Drag", + modifier = Modifier + .size(16.dp) + .pointerInput(Unit) { + detectDragGestures { change, dragAmount -> + change.consume() + toolbarOffsetX += dragAmount.x + toolbarOffsetY += dragAmount.y + } + }, + tint = androidx.compose.ui.graphics.Color.DarkGray + ) + + // Profile name (clickable to rename) + Text( + text = currentProfile.name, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + color = androidx.compose.ui.graphics.Color.Black, + modifier = Modifier + .weight(1f) + .widthIn(max = 120.dp) + .clickable { showRenameDialog = true } + ) + + // Physical Controller switch button - always visible + IconButton( + onClick = { + android.app.AlertDialog.Builder(context) + .setTitle("Physical Controller") + .setMessage("Save before switching?") + .setPositiveButton("Save & Switch") { _, _ -> + currentProfile.save() + selectedTab = 1 + } + .setNegativeButton("Switch") { _, _ -> + selectedTab = 1 + } + .setNeutralButton("Cancel", null) + .show() + }, + modifier = Modifier.size(32.dp) + ) { + Icon( + Icons.Default.Gamepad, + "Physical Controller", + modifier = Modifier.size(18.dp), + tint = androidx.compose.ui.graphics.Color.DarkGray + ) + } + + // Save button - always visible + IconButton( + onClick = { + currentProfile.save() + undoStack.clear() // Clear temp undo log on save + redoStack.clear() // Clear temp redo log on save + android.widget.Toast.makeText( + context, + "Saved", + android.widget.Toast.LENGTH_SHORT + ).show() + onSave() + }, + modifier = Modifier.size(32.dp) + ) { + Icon( + Icons.Default.Save, + "Save", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + + // Close button - always visible + IconButton( + onClick = { + undoStack.clear() // Clear temp undo log on cancel/back + redoStack.clear() // Clear temp redo log on cancel/back + onDismiss() + }, + modifier = Modifier.size(32.dp) + ) { + Icon( + Icons.Default.Close, + "Close", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.error + ) + } + + // Expand/Collapse button + IconButton( + onClick = { isToolbarExpanded = !isToolbarExpanded }, + modifier = Modifier.size(32.dp) + ) { + Icon( + if (isToolbarExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (isToolbarExpanded) "Collapse" else "Expand", + modifier = Modifier.size(18.dp), + tint = androidx.compose.ui.graphics.Color.DarkGray + ) + } + } + + // Expanded state: Show all buttons + if (isToolbarExpanded) { + HorizontalDivider( + modifier = Modifier.padding(vertical = 4.dp), + color = androidx.compose.ui.graphics.Color.Gray.copy(alpha = 0.3f) + ) + + // Primary actions row: Edit, Add, Delete, Settings, Undo + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Edit + IconButton( + onClick = { + if (selectedElement != null) { + showElementEditor = selectedElement + } else { + android.widget.Toast.makeText( + context, + "Select an element first", + android.widget.Toast.LENGTH_SHORT + ).show() + } + }, + enabled = selectedElement != null, + modifier = Modifier.size(36.dp) + ) { + Icon( + Icons.Default.Edit, + "Edit", + modifier = Modifier.size(20.dp), + tint = if (selectedElement != null) + androidx.compose.ui.graphics.Color.DarkGray + else + androidx.compose.ui.graphics.Color.LightGray + ) + } + + // Add + IconButton( + onClick = { + inputControlsView?.let { view -> + showAddElementDialog(context, view) + } + }, + modifier = Modifier.size(36.dp) + ) { + Icon( + Icons.Default.Add, + "Add", + modifier = Modifier.size(20.dp), + tint = androidx.compose.ui.graphics.Color.DarkGray + ) + } + + // Delete + IconButton( + onClick = { + if (selectedElement != null) { + android.app.AlertDialog.Builder(context) + .setTitle("Delete Element") + .setMessage("Delete this element?") + .setPositiveButton("Delete") { _, _ -> + currentProfile.removeElement(selectedElement!!) + inputControlsView?.invalidate() + } + .setNegativeButton("Cancel", null) + .show() + } else { + android.widget.Toast.makeText( + context, + "Select an element first", + android.widget.Toast.LENGTH_SHORT + ).show() + } + }, + modifier = Modifier.size(36.dp) + ) { + Icon( + Icons.Default.Delete, + "Delete", + modifier = Modifier.size(20.dp), + tint = androidx.compose.ui.graphics.Color.DarkGray + ) + } + + // Settings + IconButton( + onClick = { showSensitivityDialog = true }, + modifier = Modifier.size(36.dp) + ) { + Icon( + Icons.Default.Settings, + "Settings", + modifier = Modifier.size(20.dp), + tint = androidx.compose.ui.graphics.Color.DarkGray + ) + } + + // Undo + IconButton( + onClick = { + performUndo() + android.widget.Toast.makeText( + context, + "Undo", + android.widget.Toast.LENGTH_SHORT + ).show() + }, + enabled = undoStack.isNotEmpty(), + modifier = Modifier.size(36.dp) + ) { + Icon( + Icons.Default.Undo, + "Undo", + modifier = Modifier.size(20.dp), + tint = if (undoStack.isNotEmpty()) + androidx.compose.ui.graphics.Color.DarkGray + else + androidx.compose.ui.graphics.Color.LightGray + ) + } + + // Redo + IconButton( + onClick = { + performRedo() + android.widget.Toast.makeText( + context, + "Redo", + android.widget.Toast.LENGTH_SHORT + ).show() + }, + enabled = redoStack.isNotEmpty(), + modifier = Modifier.size(36.dp) + ) { + Icon( + Icons.Default.Redo, + "Redo", + modifier = Modifier.size(20.dp), + tint = if (redoStack.isNotEmpty()) + androidx.compose.ui.graphics.Color.DarkGray + else + androidx.compose.ui.graphics.Color.LightGray + ) + } + } + } + } + } + } + } + } + 1 -> { + // Physical Controllers Configuration Tab + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + PhysicalControllerConfigSection( + profile = currentProfile, + onSwitchToOnScreen = { + // Show confirmation dialog before switching + android.app.AlertDialog.Builder(context) + .setTitle("Switch to On-Screen Controls") + .setMessage("Do you want to save changes before switching to on-screen controls editor?") + .setPositiveButton("Save & Switch") { _, _ -> + currentProfile.save() + selectedTab = 0 // Switch to on-screen controls tab + } + .setNegativeButton("Switch Without Saving") { _, _ -> + selectedTab = 0 + } + .setNeutralButton("Cancel", null) + .show() + }, + onSave = { + currentProfile.save() + undoStack.clear() // Clear temp undo log on save + redoStack.clear() // Clear temp redo log on save + android.widget.Toast.makeText( + context, + "Profile saved", + android.widget.Toast.LENGTH_SHORT + ).show() + onSave() + }, + onDismiss = onDismiss, + onProfileUpdated = { + // Profile was updated, refresh the view + inputControlsView?.let { view -> + val refreshedProfile = InputControlsManager(context).getProfile(currentProfile.id) + if (refreshedProfile != null) { + view.setProfile(refreshedProfile) + } + } + }, + onRenameProfile = { + showRenameDialog = true + } + ) + } + } + } + } + } + + // Show Element Editor Dialog + showElementEditor?.let { element -> + inputControlsView?.let { view -> + ElementEditorDialog( + element = element, + view = view, + onDismiss = { showElementEditor = null }, + onSave = { + showElementEditor = null + view.invalidate() + } + ) + } + } + + // Show Sensitivity Settings Dialog + if (showSensitivityDialog) { + androidx.compose.ui.window.Dialog( + onDismissRequest = { showSensitivityDialog = false } + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + shadowElevation = 8.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Dialog title + Text( + text = "On-Screen Controls Settings", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + + Text( + text = "Configure dead zones and sensitivity for on-screen controls. These settings only affect virtual touch controls, not physical controllers.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + HorizontalDivider() + + // Sensitivity settings component + app.gamenative.ui.component.settings.SensitivitySettingsSection( + profile = currentProfile, + isPhysicalController = false, + onSettingsChanged = { + inputControlsView?.invalidate() + } + ) + + // Touchpad gesture settings + app.gamenative.ui.component.settings.TouchpadGestureSettings( + profile = currentProfile, + onSettingsChanged = { + inputControlsView?.invalidate() + } + ) + + // Mouse and touch behavior settings + app.gamenative.ui.component.settings.MouseTouchBehaviorSettings( + profile = currentProfile, + onSettingsChanged = { + inputControlsView?.invalidate() + } + ) + + // Close button + Button( + onClick = { showSensitivityDialog = false }, + modifier = Modifier.align(Alignment.End) + ) { + Text("Close") + } + } + } + } + } + + // Show Rename Profile Dialog + if (showRenameDialog) { + var newName by remember { mutableStateOf(currentProfile.name) } + var errorMessage by remember { mutableStateOf(null) } + + androidx.compose.ui.window.Dialog( + onDismissRequest = { + showRenameDialog = false + errorMessage = null + } + ) { + Surface( + modifier = Modifier + .fillMaxWidth(0.9f) + .padding(16.dp), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + shadowElevation = 8.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Dialog title + Text( + text = "Rename Profile", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + + // Name input field + OutlinedTextField( + value = newName, + onValueChange = { + newName = it + errorMessage = null + }, + label = { Text("Profile Name") }, + singleLine = true, + isError = errorMessage != null, + supportingText = if (errorMessage != null) { + { Text(errorMessage!!, color = MaterialTheme.colorScheme.error) } + } else null, + modifier = Modifier.fillMaxWidth() + ) + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = { + showRenameDialog = false + errorMessage = null + }) { + Text("Cancel") + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + // Validate the name + val validation = InputControlsManager.validateProfileName(newName) + if (validation != null) { + errorMessage = validation + } else { + // Check if name already exists (excluding current profile) + val manager = InputControlsManager(context) + val existingProfile = manager.profiles.find { + it.name == newName && it.id != currentProfile.id + } + if (existingProfile != null) { + errorMessage = "A profile with this name already exists" + } else { + // Update the profile name (will be saved when user clicks Save) + currentProfile.name = newName + showRenameDialog = false + errorMessage = null + Toast.makeText( + context, + "Profile renamed to '$newName'", + Toast.LENGTH_SHORT + ).show() + } + } + } + ) { + Text("Rename") + } + } + } + } + } + } +} + +/** + * Helper function to show dialog for adding a control element + */ +private fun showAddElementDialog(context: Context, view: InputControlsView) { + val profile = view.profile ?: return + + val items = arrayOf( + "D-Pad", + "Button (Circle)", + "Button (Rectangle)", + "Button (Round Rectangle)", + "Analog Stick", + "Trackpad" + ) + + android.app.AlertDialog.Builder(context) + .setTitle("Add Control Element") + .setItems(items) { _, which -> + val type = when (which) { + 0 -> com.winlator.inputcontrols.ControlElement.Type.D_PAD + 1, 2, 3 -> com.winlator.inputcontrols.ControlElement.Type.BUTTON + 4 -> com.winlator.inputcontrols.ControlElement.Type.STICK + 5 -> com.winlator.inputcontrols.ControlElement.Type.TRACKPAD + else -> com.winlator.inputcontrols.ControlElement.Type.BUTTON + } + + val shape = when (which) { + 0, 1, 4, 5 -> com.winlator.inputcontrols.ControlElement.Shape.CIRCLE + 2 -> com.winlator.inputcontrols.ControlElement.Shape.RECT + 3 -> com.winlator.inputcontrols.ControlElement.Shape.ROUND_RECT + else -> com.winlator.inputcontrols.ControlElement.Shape.CIRCLE + } + + // Create new element at center of screen using actual pixel coordinates + val element = com.winlator.inputcontrols.ControlElement(view) + element.setType(type) + element.setShape(shape) + + // Use actual screen pixel coordinates (not Short.MAX_VALUE) + element.setX(view.width / 2) + element.setY(view.height / 2) + element.setScale(1.0f) + + // Set a sensible default binding for buttons (Space key) + // Other types (D-Pad, Stick, Trackpad) already have defaults from reset() + if (type == com.winlator.inputcontrols.ControlElement.Type.BUTTON) { + element.setBindingAt(0, com.winlator.inputcontrols.Binding.KEY_SPACE) + } + + profile.addElement(element) + view.invalidate() + } + .show() +} diff --git a/app/src/main/java/app/gamenative/ui/component/settings/MouseTouchBehaviorSettings.kt b/app/src/main/java/app/gamenative/ui/component/settings/MouseTouchBehaviorSettings.kt new file mode 100644 index 000000000..37520fadc --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/settings/MouseTouchBehaviorSettings.kt @@ -0,0 +1,124 @@ +package app.gamenative.ui.component.settings + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Mouse +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.winlator.inputcontrols.ControlsProfile + +/** + * Mouse and touch behavior settings + * + * Controls how mouse input and touch mode work: + * - Disable Touchpad Mouse: Prevents touch input from controlling the mouse cursor + * - Touchscreen Mode: Enables direct touch interaction with games + */ +@Composable +fun MouseTouchBehaviorSettings( + profile: ControlsProfile, + onSettingsChanged: () -> Unit +) { + // State for mouse/touch settings + var disableTouchpadMouse by remember { mutableStateOf(profile.isDisableTouchpadMouse) } + var touchscreenMode by remember { mutableStateOf(profile.isTouchscreenMode) } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Header + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Mouse, "Mouse & Touch Behavior") + Text( + text = "Mouse & Touch Behavior", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + + HorizontalDivider() + + Text( + text = "Configure how mouse and touch input behave when playing games", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // Disable Touchpad Mouse + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Disable Touchpad Mouse", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium + ) + Text( + text = "Prevent touchpad area from controlling mouse cursor (on-screen controls still work)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = disableTouchpadMouse, + onCheckedChange = { newValue -> + disableTouchpadMouse = newValue + profile.setDisableTouchpadMouse(newValue) + profile.save() + onSettingsChanged() + } + ) + } + + // Touchscreen Mode + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Touchscreen Mode", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium + ) + Text( + text = "Enable direct touch interaction with games (touch translates to screen coordinates)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = touchscreenMode, + onCheckedChange = { newValue -> + touchscreenMode = newValue + profile.setTouchscreenMode(newValue) + profile.save() + onSettingsChanged() + } + ) + } + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/component/settings/ProfileSelectionCard.kt b/app/src/main/java/app/gamenative/ui/component/settings/ProfileSelectionCard.kt new file mode 100644 index 000000000..6c63fc661 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/settings/ProfileSelectionCard.kt @@ -0,0 +1,294 @@ +package app.gamenative.ui.component.settings + +import android.content.Context +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.Gamepad +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import app.gamenative.ui.component.dialog.ImportProfileDialog +import app.gamenative.ui.component.dialog.ProfilePickerDialog +import app.gamenative.ui.component.dialog.UnifiedProfileCreationDialog +import com.winlator.container.Container +import com.winlator.inputcontrols.ControlsProfile +import com.winlator.inputcontrols.InputControlsManager + +/** + * Prominent card for selecting and editing the input controls profile + * + * Shows the currently selected profile with options to: + * - Change profile (opens profile picker) + * - Create from template + * - Clone profile + * - Edit profile (opens unified profile editor) + */ +@Composable +fun ProfileSelectionCard( + context: Context, + selectedProfileId: Int, + onProfileSelected: (Int) -> Unit, + onEditProfile: (ControlsProfile) -> Unit, + container: Container? = null +) { + // Always create a fresh InputControlsManager to ensure we get latest profiles + val currentProfile = remember(selectedProfileId) { + val manager = InputControlsManager(context) + manager.getProfile(selectedProfileId) + } + + // Dialog states + var showProfilePicker by remember { mutableStateOf(false) } + var showCreateProfile by remember { mutableStateOf(false) } + var showImportProfile by remember { mutableStateOf(false) } + var showDropdownMenu by remember { mutableStateOf(false) } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Header + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Gamepad, + contentDescription = "Input Profile", + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = "Input Controls Profile", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + + // Current profile display + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Selected Profile", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = currentProfile?.name ?: "No Profile Selected", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Profile operations dropdown + Box(modifier = Modifier.weight(1f)) { + OutlinedButton( + onClick = { showDropdownMenu = true }, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + Icons.Default.MoreVert, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Profile Options") + } + + DropdownMenu( + expanded = showDropdownMenu, + onDismissRequest = { showDropdownMenu = false } + ) { + DropdownMenuItem( + text = { Text("Change Profile") }, + onClick = { + showDropdownMenu = false + showProfilePicker = true + } + ) + DropdownMenuItem( + text = { Text("Create New Profile") }, + onClick = { + showDropdownMenu = false + showCreateProfile = true + } + ) + + HorizontalDivider() + + DropdownMenuItem( + text = { Text("Import Profile") }, + leadingIcon = { + Icon(Icons.Default.Upload, contentDescription = null) + }, + onClick = { + showDropdownMenu = false + showImportProfile = true + } + ) + + DropdownMenuItem( + text = { Text("Export Current Profile") }, + leadingIcon = { + Icon(Icons.Default.FileDownload, contentDescription = null) + }, + onClick = { + showDropdownMenu = false + currentProfile?.let { profile -> + val manager = InputControlsManager(context) + val exportedFile = manager.exportProfile(profile) + if (exportedFile != null) { + android.widget.Toast.makeText( + context, + "Profile exported to:\n${exportedFile.absolutePath}", + android.widget.Toast.LENGTH_LONG + ).show() + } else { + android.widget.Toast.makeText( + context, + "Failed to export profile", + android.widget.Toast.LENGTH_SHORT + ).show() + } + } + }, + enabled = currentProfile != null + ) + + // Lock/Unlock option (only show if in game context) + if (container != null && currentProfile != null) { + HorizontalDivider() + + val isLocked = currentProfile.isLockedToGame + DropdownMenuItem( + text = { + Text(if (isLocked) "Unlock from ${container.name}" else "Lock to ${container.name}") + }, + onClick = { + showDropdownMenu = false + + // Toggle lock status + val newLockStatus = if (isLocked) null else container.id.toString() + currentProfile.setLockedToContainer(newLockStatus) + + // Save the profile + currentProfile.save() + + // Show toast + android.widget.Toast.makeText( + context, + if (isLocked) "Profile unlocked - now available globally" + else "Profile locked to ${container.name}", + android.widget.Toast.LENGTH_SHORT + ).show() + } + ) + } + } + } + + // Edit Profile button + Button( + onClick = { + currentProfile?.let { onEditProfile(it) } + }, + modifier = Modifier.weight(1f), + enabled = currentProfile != null + ) { + Icon( + Icons.Default.Edit, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Edit Profile") + } + } + + // Info text + Text( + text = "This profile controls on-screen controls, physical controller bindings, and input behavior", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + ) + } + } + + // Dialogs + if (showProfilePicker) { + ProfilePickerDialog( + context = context, + container = container, + currentProfileId = selectedProfileId, + onProfileSelected = { profileId -> + onProfileSelected(profileId) + }, + onDismiss = { showProfilePicker = false } + ) + } + + if (showCreateProfile) { + UnifiedProfileCreationDialog( + context = context, + container = container, + onProfileCreated = { newProfile -> + onProfileSelected(newProfile.id) + }, + onDismiss = { showCreateProfile = false } + ) + } + + if (showImportProfile) { + ImportProfileDialog( + context = context, + onProfileImported = { + // Refresh the profile list by forcing recomposition + // The parent will handle this automatically + android.widget.Toast.makeText( + context, + "Profile imported successfully! You can now select it.", + android.widget.Toast.LENGTH_SHORT + ).show() + }, + onDismiss = { showImportProfile = false } + ) + } +} diff --git a/app/src/main/java/app/gamenative/ui/component/settings/SensitivitySettingsSection.kt b/app/src/main/java/app/gamenative/ui/component/settings/SensitivitySettingsSection.kt new file mode 100644 index 000000000..e1b8d1326 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/settings/SensitivitySettingsSection.kt @@ -0,0 +1,297 @@ +package app.gamenative.ui.component.settings + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Gamepad +import androidx.compose.material.icons.filled.TouchApp +import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.winlator.inputcontrols.ControlsProfile + +/** + * Gamer-friendly sensitivity and dead zone settings section + * + * Provides intuitive sliders with real-time preview and preset options + */ +@Composable +fun SensitivitySettingsSection( + profile: ControlsProfile, + isPhysicalController: Boolean, + onSettingsChanged: () -> Unit +) { + val title = if (isPhysicalController) "Physical Controller" else "On-Screen Controls" + val icon = if (isPhysicalController) Icons.Default.Gamepad else Icons.Default.TouchApp + + // Current values + var stickDeadZone by remember { + mutableFloatStateOf( + if (isPhysicalController) profile.physicalStickDeadZone + else profile.virtualStickDeadZone + ) + } + var stickSensitivity by remember { + mutableFloatStateOf( + if (isPhysicalController) profile.physicalStickSensitivity + else profile.virtualStickSensitivity + ) + } + var dpadDeadZone by remember { + mutableFloatStateOf( + if (isPhysicalController) profile.physicalDpadDeadZone + else profile.virtualDpadDeadZone + ) + } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(icon, title) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + + // Reset to defaults button + IconButton( + onClick = { + stickDeadZone = 0.15f + stickSensitivity = 3.0f + dpadDeadZone = if (isPhysicalController) 0.15f else 0.3f + + if (isPhysicalController) { + profile.physicalStickDeadZone = stickDeadZone + profile.physicalStickSensitivity = stickSensitivity + profile.physicalDpadDeadZone = dpadDeadZone + } else { + profile.virtualStickDeadZone = stickDeadZone + profile.virtualStickSensitivity = stickSensitivity + profile.virtualDpadDeadZone = dpadDeadZone + } + profile.save() + onSettingsChanged() + } + ) { + Icon(Icons.Default.RestartAlt, "Reset to Defaults") + } + } + + HorizontalDivider() + + // Stick Dead Zone + SettingSlider( + label = "Stick Dead Zone", + value = stickDeadZone, + valueRange = 0f..0.5f, + steps = 49, + valueText = "${(stickDeadZone * 100).toInt()}%", + description = "Ignore small stick movements (prevents drift)", + onValueChange = { newValue -> + stickDeadZone = newValue + if (isPhysicalController) { + profile.physicalStickDeadZone = newValue + } else { + profile.virtualStickDeadZone = newValue + } + profile.save() + onSettingsChanged() + } + ) + + // Stick Sensitivity + SettingSlider( + label = "Stick Sensitivity", + value = stickSensitivity, + valueRange = 0.5f..5.0f, + steps = 44, + valueText = "${"%.1f".format(stickSensitivity)}x", + description = "How responsive the stick feels (higher = faster)", + onValueChange = { newValue -> + stickSensitivity = newValue + if (isPhysicalController) { + profile.physicalStickSensitivity = newValue + } else { + profile.virtualStickSensitivity = newValue + } + profile.save() + onSettingsChanged() + } + ) + + // D-Pad Dead Zone + SettingSlider( + label = "D-Pad Dead Zone", + value = dpadDeadZone, + valueRange = 0f..0.5f, + steps = 49, + valueText = "${(dpadDeadZone * 100).toInt()}%", + description = "Minimum movement before D-pad activates", + onValueChange = { newValue -> + dpadDeadZone = newValue + if (isPhysicalController) { + profile.physicalDpadDeadZone = newValue + } else { + profile.virtualDpadDeadZone = newValue + } + profile.save() + onSettingsChanged() + } + ) + + // Quick presets + Text( + text = "Quick Presets", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + PresetButton( + text = "Tight", + modifier = Modifier.weight(1f, fill = true), + onClick = { + stickDeadZone = 0.05f + stickSensitivity = 4.0f + dpadDeadZone = 0.1f + applySettings(profile, isPhysicalController, stickDeadZone, stickSensitivity, dpadDeadZone) + onSettingsChanged() + } + ) + PresetButton( + text = "Balanced", + modifier = Modifier.weight(1f, fill = true), + onClick = { + stickDeadZone = 0.15f + stickSensitivity = 3.0f + dpadDeadZone = if (isPhysicalController) 0.15f else 0.3f + applySettings(profile, isPhysicalController, stickDeadZone, stickSensitivity, dpadDeadZone) + onSettingsChanged() + } + ) + PresetButton( + text = "Smooth", + modifier = Modifier.weight(1f, fill = true), + onClick = { + stickDeadZone = 0.25f + stickSensitivity = 2.0f + dpadDeadZone = 0.35f + applySettings(profile, isPhysicalController, stickDeadZone, stickSensitivity, dpadDeadZone) + onSettingsChanged() + } + ) + } + } + } +} + +private fun applySettings( + profile: ControlsProfile, + isPhysical: Boolean, + deadZone: Float, + sensitivity: Float, + dpadDeadZone: Float +) { + if (isPhysical) { + profile.physicalStickDeadZone = deadZone + profile.physicalStickSensitivity = sensitivity + profile.physicalDpadDeadZone = dpadDeadZone + } else { + profile.virtualStickDeadZone = deadZone + profile.virtualStickSensitivity = sensitivity + profile.virtualDpadDeadZone = dpadDeadZone + } + profile.save() +} + +@Composable +private fun SettingSlider( + label: String, + value: Float, + valueRange: ClosedFloatingPointRange, + steps: Int, + valueText: String, + description: String, + onValueChange: (Float) -> Unit +) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium + ) + Text( + text = valueText, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + } + + Slider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + steps = steps, + modifier = Modifier.fillMaxWidth() + ) + + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun PresetButton( + text: String, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + OutlinedButton( + onClick = onClick, + modifier = modifier, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium + ) + } +} diff --git a/app/src/main/java/app/gamenative/ui/component/settings/TouchpadGestureSettings.kt b/app/src/main/java/app/gamenative/ui/component/settings/TouchpadGestureSettings.kt new file mode 100644 index 000000000..72f923b4d --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/settings/TouchpadGestureSettings.kt @@ -0,0 +1,124 @@ +package app.gamenative.ui.component.settings + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.TouchApp +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.winlator.inputcontrols.ControlsProfile + +/** + * Touchpad gesture settings for mouse input + * + * Configures touch gestures for mouse clicks: + * - Single tap = Left click + * - Two-finger tap/hold = Right click + */ +@Composable +fun TouchpadGestureSettings( + profile: ControlsProfile, + onSettingsChanged: () -> Unit +) { + // State for gesture settings + var enableTapToClick by remember { mutableStateOf(profile.isEnableTapToClick) } + var enableTwoFingerRightClick by remember { mutableStateOf(profile.isEnableTwoFingerRightClick) } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Header + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.TouchApp, "Touchpad Gestures") + Text( + text = "Touchpad Gestures", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + + HorizontalDivider() + + Text( + text = "Configure touch gestures for mouse input when using touchpad area (empty screen space)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // Tap to Click + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Tap to Click", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium + ) + Text( + text = "Single tap triggers left mouse click", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = enableTapToClick, + onCheckedChange = { newValue -> + enableTapToClick = newValue + profile.setEnableTapToClick(newValue) + profile.save() + onSettingsChanged() + } + ) + } + + // Two-Finger Right Click + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Two-Finger Right Click", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium + ) + Text( + text = "Two-finger tap/hold triggers right mouse click", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = enableTwoFingerRightClick, + onCheckedChange = { newValue -> + enableTwoFingerRightClick = newValue + profile.setEnableTwoFingerRightClick(newValue) + profile.save() + onSettingsChanged() + } + ) + } + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt index 39e687cef..a3ef55c61 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt @@ -92,6 +92,7 @@ import com.google.android.play.core.splitcompat.SplitCompat import com.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.coil.CoilImage import app.gamenative.utils.SteamUtils +import com.winlator.container.Container import com.winlator.container.ContainerData import com.winlator.xenvironment.ImageFsInstaller import com.winlator.fexcore.FEXCoreManager diff --git a/app/src/main/java/app/gamenative/ui/screen/settings/ControlsEditorDialog.kt b/app/src/main/java/app/gamenative/ui/screen/settings/ControlsEditorDialog.kt new file mode 100644 index 000000000..1062b044e --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/settings/ControlsEditorDialog.kt @@ -0,0 +1,818 @@ +package app.gamenative.ui.screen.settings + +import android.content.Context +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.FileCopy +import androidx.compose.material.icons.filled.Gamepad +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.RestorePage +import androidx.compose.material.icons.filled.TouchApp +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.winlator.inputcontrols.ControlsProfile +import com.winlator.inputcontrols.InputControlsManager +import com.winlator.container.ContainerManager +import org.json.JSONObject + +// Helper function to get element count without loading full elements +private fun getElementCount(context: Context, profile: ControlsProfile): String { + return try { + val file = ControlsProfile.getProfileFile(context, profile.id) + if (file.exists()) { + val json = JSONObject(file.readText()) + val elementsArray = json.optJSONArray("elements") + val count = elementsArray?.length() ?: 0 + android.util.Log.d("ControlsEditorDialog", "Profile ${profile.name} (ID: ${profile.id}) has $count elements") + " • $count elements" + } else { + android.util.Log.w("ControlsEditorDialog", "Profile file not found for ${profile.name} (ID: ${profile.id}): ${file.absolutePath}") + " • File not found" + } + } catch (e: Exception) { + android.util.Log.e("ControlsEditorDialog", "Error reading element count for ${profile.name} (ID: ${profile.id})", e) + " • Error reading file" + } +} + +// Helper function to get container usage count for a profile +private fun getContainerUsageCount(context: Context, profileId: Int): Int { + return try { + val containerManager = ContainerManager(context) + val containers = containerManager.containers + containers.count { container -> + try { + val configFile = container.configFile + if (configFile.exists()) { + val json = JSONObject(configFile.readText()) + json.optInt("controlsProfileId", 4) == profileId + } else { + false + } + } catch (e: Exception) { + false + } + } + } catch (e: Exception) { + android.util.Log.e("ControlsEditorDialog", "Error counting container usage for profile $profileId", e) + 0 + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ControlsEditorDialog( + open: Boolean, + onDismiss: () -> Unit +) { + if (!open) return + + val context = LocalContext.current + var profiles by remember { mutableStateOf(listOf()) } + var showCreateDialog by remember { mutableStateOf(false) } + var showDeleteConfirm by remember { mutableStateOf(null) } + var showDuplicateDialog by remember { mutableStateOf(null) } + var showUnifiedEditor by remember { mutableStateOf?>(null) } // Profile + Tab index + var showEditChoiceDialog by remember { mutableStateOf(null) } + var showUnlockConfirm by remember { mutableStateOf(null) } + + // State for showing templates and game-locked profiles + var showTemplates by remember { mutableStateOf(false) } + var showGameLockedProfiles by remember { mutableStateOf(false) } + + // Refresh profiles function - always create fresh instance to ensure proper state + fun refreshProfiles() { + android.util.Log.d("ControlsEditorDialog", "Refreshing profiles... showTemplates=$showTemplates, showGameLockedProfiles=$showGameLockedProfiles") + + val manager = InputControlsManager(context) + profiles = when { + showTemplates -> { + // Show only templates + manager.getProfiles(false).filter { it.isTemplate() } + } + showGameLockedProfiles -> { + // Show all profiles (global + game-locked) + manager.getProfiles(false).filter { !it.isTemplate() } + } + else -> { + // Default: show only global profiles (exclude templates and game-locked) + manager.globalProfiles + } + } + + android.util.Log.d("ControlsEditorDialog", "Loaded ${profiles.size} profiles:") + + // Log each profile with its file path to identify duplicates + profiles.forEach { profile -> + val file = ControlsProfile.getProfileFile(context, profile.id) + android.util.Log.d("ControlsEditorDialog", " - Profile: name='${profile.name}', id=${profile.id}, isTemplate=${profile.isTemplate()}, isLocked=${profile.isLockedToGame}, file=${file.name}") + } + } + + fun resetTemplateToDefault(profileId: Int) { + android.util.Log.d("ControlsEditorDialog", "Resetting template with ID $profileId to default...") + val profilesDir = InputControlsManager.getProfilesDir(context) + val assetManager = context.assets + + try { + val filename = "controls-$profileId.icp" + val targetFile = java.io.File(profilesDir, filename) + + // Delete existing file + if (targetFile.exists()) { + targetFile.delete() + android.util.Log.d("ControlsEditorDialog", "Deleted $filename") + } + + // Restore fresh copy from assets + val assetPath = "inputcontrols/profiles/$filename" + android.util.Log.d("ControlsEditorDialog", "Restoring $filename from assets to ${targetFile.absolutePath}") + assetManager.open(assetPath).use { input -> + targetFile.outputStream().use { output -> + input.copyTo(output) + } + } + + refreshProfiles() + android.widget.Toast.makeText( + context, + "Template reset to default", + android.widget.Toast.LENGTH_SHORT + ).show() + android.util.Log.d("ControlsEditorDialog", "Template $profileId reset successfully") + } catch (e: Exception) { + android.util.Log.e("ControlsEditorDialog", "Error resetting template $profileId", e) + android.widget.Toast.makeText( + context, + "Error resetting template", + android.widget.Toast.LENGTH_SHORT + ).show() + } + } + + fun ensureTemplatesExist() { + val profilesDir = InputControlsManager.getProfilesDir(context) + val assetManager = context.assets + + try { + // Ensure all 7 template files exist (controls-1 through controls-7) + listOf("controls-1.icp", "controls-2.icp", "controls-3.icp", "controls-4.icp", "controls-5.icp", "controls-6.icp", "controls-7.icp").forEach { filename -> + val targetFile = java.io.File(profilesDir, filename) + + // Only restore if file doesn't exist (don't overwrite user edits) + if (!targetFile.exists()) { + android.util.Log.d("ControlsEditorDialog", "Template $filename missing, restoring from assets") + val assetPath = "inputcontrols/profiles/$filename" + assetManager.open(assetPath).use { input -> + targetFile.outputStream().use { output -> + input.copyTo(output) + } + } + } + } + } catch (e: Exception) { + android.util.Log.e("ControlsEditorDialog", "Error ensuring templates exist", e) + e.printStackTrace() + } + } + + // Load profiles on first composition and ensure templates exist + LaunchedEffect(Unit) { + ensureTemplatesExist() + refreshProfiles() + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text("Controls Profiles") }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + }, + actions = { + IconButton(onClick = { + ensureTemplatesExist() + refreshProfiles() + android.widget.Toast.makeText( + context, + "Profiles refreshed and missing templates restored", + android.widget.Toast.LENGTH_SHORT + ).show() + }) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh List and Restore Templates") + } + IconButton(onClick = { showCreateDialog = true }) { + Icon(Icons.Default.Add, contentDescription = "Create Profile") + } + } + ) + } + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(top = padding.calculateTopPadding()) + .padding(bottom = padding.calculateBottomPadding()) + ) { + // Filter controls as items in the scrollable list + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Display mode text + Text( + text = when { + showTemplates -> "Showing Default Templates" + showGameLockedProfiles -> "Showing All Profiles (including game-locked)" + else -> "Showing Global Profiles" + }, + style = MaterialTheme.typography.titleSmall + ) + + // Toggle for templates + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Show Templates", + style = MaterialTheme.typography.bodyMedium + ) + Switch( + checked = showTemplates, + onCheckedChange = { + showTemplates = it + if (it) showGameLockedProfiles = false // Can't show both at once + refreshProfiles() + } + ) + } + + // Toggle for game-locked profiles (only show when not showing templates) + if (!showTemplates) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Show Game-Locked Profiles", + style = MaterialTheme.typography.bodyMedium + ) + Switch( + checked = showGameLockedProfiles, + onCheckedChange = { + showGameLockedProfiles = it + refreshProfiles() + } + ) + } + } + } + } + + item { + HorizontalDivider() + } + + // Profile items + items(profiles) { profile -> + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + ) { + ProfileListItem( + context = context, + profile = profile, + onEdit = { + android.util.Log.d("ControlsEditorDialog", "Edit clicked for profile: ${profile.name} (ID: ${profile.id})") + showEditChoiceDialog = profile + }, + onExport = { + android.util.Log.d("ControlsEditorDialog", "Export clicked for profile: ${profile.name} (ID: ${profile.id})") + try { + val manager = InputControlsManager(context) + val exportedFile = manager.exportProfile(profile) + if (exportedFile != null) { + android.widget.Toast.makeText( + context, + "Profile exported to: ${exportedFile.absolutePath}", + android.widget.Toast.LENGTH_LONG + ).show() + } else { + android.widget.Toast.makeText( + context, + "Failed to export profile", + android.widget.Toast.LENGTH_SHORT + ).show() + } + } catch (e: Exception) { + android.util.Log.e("ControlsEditorDialog", "Error exporting profile", e) + android.widget.Toast.makeText( + context, + "Error exporting profile: ${e.message}", + android.widget.Toast.LENGTH_LONG + ).show() + } + }, + onDuplicate = { + android.util.Log.d("ControlsEditorDialog", "Duplicate clicked for profile: ${profile.name} (ID: ${profile.id})") + showDuplicateDialog = profile + }, + onDelete = { + android.util.Log.d("ControlsEditorDialog", "Delete clicked for profile: ${profile.name} (ID: ${profile.id})") + showDeleteConfirm = profile + }, + onUnlock = { + android.util.Log.d("ControlsEditorDialog", "Unlock clicked for profile: ${profile.name} (ID: ${profile.id})") + showUnlockConfirm = profile + }, + onRefresh = { + android.util.Log.d("ControlsEditorDialog", "Refreshing after lock/unlock") + refreshProfiles() + }, + onResetTemplate = { + android.util.Log.d("ControlsEditorDialog", "Reset template clicked for: ${profile.name} (ID: ${profile.id})") + android.app.AlertDialog.Builder(context) + .setTitle("Reset Template to Default") + .setMessage("Reset \"${profile.name}\" to the default template? All your changes to this template will be lost.") + .setPositiveButton("Reset") { _, _ -> + resetTemplateToDefault(profile.id) + } + .setNegativeButton("Cancel", null) + .show() + } + ) + } + } + } + } + } + + // Create profile dialog + if (showCreateDialog) { + app.gamenative.ui.component.dialog.UnifiedProfileCreationDialog( + context = context, + container = null, // No container in settings - creating global profiles + onProfileCreated = { newProfile -> + android.util.Log.d("ControlsEditorDialog", "Profile created: ${newProfile.name}, refreshing list") + refreshProfiles() + showCreateDialog = false + }, + onDismiss = { + android.util.Log.d("ControlsEditorDialog", "Create profile dialog dismissed") + showCreateDialog = false + } + ) + } + + // Delete confirmation dialog + showDeleteConfirm?.let { profile -> + AlertDialog( + onDismissRequest = { + android.util.Log.d("ControlsEditorDialog", "Delete confirmation dismissed for: ${profile.name}") + showDeleteConfirm = null + }, + title = { Text("Delete Profile") }, + text = { Text("Are you sure you want to delete \"${profile.name}\"?") }, + confirmButton = { + TextButton( + onClick = { + android.util.Log.d("ControlsEditorDialog", "Deleting profile: ${profile.name} (ID: ${profile.id})") + val manager = InputControlsManager(context) + manager.getProfiles() // Load profiles first to initialize the list + manager.removeProfile(profile) + android.util.Log.d("ControlsEditorDialog", "Profile deleted successfully") + refreshProfiles() + showDeleteConfirm = null + } + ) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onClick = { + android.util.Log.d("ControlsEditorDialog", "Delete cancelled for: ${profile.name}") + showDeleteConfirm = null + }) { + Text("Cancel") + } + } + ) + } + + // Unlock confirmation dialog + showUnlockConfirm?.let { profile -> + AlertDialog( + onDismissRequest = { + android.util.Log.d("ControlsEditorDialog", "Unlock confirmation dismissed for: ${profile.name}") + showUnlockConfirm = null + }, + title = { Text("Unlock Profile from Game") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Are you sure you want to unlock \"${profile.name}\" from Game #${profile.lockedToContainer}?") + Text( + "This will make it a global profile available for all games.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + confirmButton = { + TextButton( + onClick = { + android.util.Log.d("ControlsEditorDialog", "Unlocking profile: ${profile.name} (ID: ${profile.id})") + profile.setLockedToContainer(null) + profile.save() + android.widget.Toast.makeText( + context, + "Profile \"${profile.name}\" is now available globally", + android.widget.Toast.LENGTH_SHORT + ).show() + refreshProfiles() + showUnlockConfirm = null + } + ) { + Text("Unlock") + } + }, + dismissButton = { + TextButton(onClick = { + android.util.Log.d("ControlsEditorDialog", "Unlock cancelled for: ${profile.name}") + showUnlockConfirm = null + }) { + Text("Cancel") + } + } + ) + } + + // Duplicate profile dialog + showDuplicateDialog?.let { profile -> + DuplicateProfileDialog( + originalName = profile.name, + onDismiss = { + android.util.Log.d("ControlsEditorDialog", "Duplicate dialog dismissed for: ${profile.name}") + showDuplicateDialog = null + }, + onDuplicate = { newName -> + android.util.Log.d("ControlsEditorDialog", "Duplicating profile: ${profile.name} as $newName") + val manager = InputControlsManager(context) + manager.getProfiles() // Load profiles first to initialize the list + val newProfile = manager.duplicateProfile(profile) + if (newProfile != null) { + android.util.Log.d("ControlsEditorDialog", "Duplicated profile with ID: ${newProfile.id}") + if (newName.isNotBlank() && newName != newProfile.name) { + newProfile.setName(newName) + newProfile.save() + android.util.Log.d("ControlsEditorDialog", "Renamed duplicated profile to: $newName") + } + } else { + android.util.Log.e("ControlsEditorDialog", "Failed to duplicate profile: ${profile.name}") + } + refreshProfiles() + showDuplicateDialog = null + } + ) + } + + // Edit Choice Dialog + showEditChoiceDialog?.let { profile -> + AlertDialog( + onDismissRequest = { showEditChoiceDialog = null }, + title = { Text("Edit Profile: ${profile.name}") }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "What would you like to edit?", + style = MaterialTheme.typography.bodyMedium + ) + } + }, + confirmButton = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { + android.util.Log.d("ControlsEditorDialog", "Editing on-screen controls for: ${profile.name}") + showUnifiedEditor = Pair(profile, 0) // Tab 0 = On-Screen Controls + showEditChoiceDialog = null + }, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.TouchApp, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp) + ) + Text("Edit On-Screen Controls") + } + + Button( + onClick = { + android.util.Log.d("ControlsEditorDialog", "Editing physical controller for: ${profile.name}") + showUnifiedEditor = Pair(profile, 1) // Tab 1 = Physical Controllers + showEditChoiceDialog = null + }, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.Gamepad, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp) + ) + Text("Edit Physical Controller") + } + } + }, + dismissButton = { + TextButton(onClick = { showEditChoiceDialog = null }) { + Text("Cancel") + } + } + ) + } + + // Unified Profile Editor Dialog + showUnifiedEditor?.let { (profile, initialTab) -> + app.gamenative.ui.component.dialog.UnifiedProfileEditorDialog( + profile = profile, + initialTab = initialTab, + onDismiss = { + android.util.Log.d("ControlsEditorDialog", "Unified editor dismissed for: ${profile.name}") + showUnifiedEditor = null + refreshProfiles() + }, + onSave = { + android.util.Log.d("ControlsEditorDialog", "Profile saved: ${profile.name}") + showUnifiedEditor = null + refreshProfiles() + } + ) + } +} + +@Composable +private fun ProfileListItem( + context: Context, + profile: ControlsProfile, + onEdit: () -> Unit, + onExport: () -> Unit = {}, + onDuplicate: () -> Unit, + onDelete: () -> Unit, + onUnlock: () -> Unit = {}, + onRefresh: () -> Unit = {}, + onResetTemplate: () -> Unit = {} +) { + val isTemplate = profile.isTemplate() + val isLocked = profile.isLockedToGame + val usageCount = remember(profile.id) { getContainerUsageCount(context, profile.id) } + + Card( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + // Profile name with lock icon and badges in one line + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (isLocked) { + Icon( + Icons.Default.Lock, + contentDescription = "Game-locked profile", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp) + ) + } + + Text( + text = profile.name, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis + ) + + // Compact badges + if (isTemplate) { + Surface( + shape = MaterialTheme.shapes.extraSmall, + color = MaterialTheme.colorScheme.primaryContainer + ) { + Text( + text = "Template", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp) + ) + } + } + } + + // Compact info line with all metadata + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "ID: ${profile.id}${getElementCount(context, profile)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (isLocked) { + Text( + text = "• Game #${profile.lockedToContainer}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.secondary + ) + } + + if (usageCount > 0) { + Text( + text = "• $usageCount container${if (usageCount == 1) "" else "s"}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary + ) + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + // Unlock button - only show for locked profiles + if (isLocked) { + IconButton( + onClick = onUnlock, + modifier = Modifier.size(36.dp) + ) { + Icon( + Icons.Default.LockOpen, + contentDescription = "Unlock profile (make global)", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp) + ) + } + } + + // Export button - available for all profiles + IconButton( + onClick = onExport, + modifier = Modifier.size(36.dp) + ) { + Icon( + Icons.Default.Download, + contentDescription = "Export profile", + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(18.dp) + ) + } + + // Edit button - enabled for all profiles including templates + IconButton( + onClick = onEdit, + modifier = Modifier.size(36.dp) + ) { + Icon( + Icons.Default.Edit, + contentDescription = "Edit", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + + // Show different buttons for templates vs regular profiles + if (isTemplate) { + // Reset button for templates + IconButton( + onClick = onResetTemplate, + modifier = Modifier.size(36.dp) + ) { + Icon( + Icons.Default.RestorePage, + contentDescription = "Reset template to default", + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(18.dp) + ) + } + } else { + // Duplicate and Delete buttons for regular profiles + IconButton( + onClick = onDuplicate, + modifier = Modifier.size(36.dp) + ) { + Icon( + Icons.Default.FileCopy, + contentDescription = "Duplicate", + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(18.dp) + ) + } + IconButton( + onClick = onDelete, + modifier = Modifier.size(36.dp) + ) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp) + ) + } + } + } + } + } +} + +@Composable +private fun DuplicateProfileDialog( + originalName: String, + onDismiss: () -> Unit, + onDuplicate: (String) -> Unit +) { + var profileName by remember { mutableStateOf("$originalName (Copy)") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Duplicate Profile") }, + text = { + Column { + Text( + text = "Enter a name for the duplicated profile:", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp) + ) + OutlinedTextField( + value = profileName, + onValueChange = { profileName = it }, + label = { Text("Profile Name") }, + singleLine = true + ) + } + }, + confirmButton = { + TextButton( + onClick = { onDuplicate(profileName) }, + enabled = profileName.isNotBlank() + ) { + Text("Duplicate") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} diff --git a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupEmulation.kt b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupEmulation.kt index 936b7f51b..ac651a07a 100644 --- a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupEmulation.kt +++ b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupEmulation.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.setValue import app.gamenative.ui.component.dialog.Box64PresetsDialog import app.gamenative.ui.component.dialog.ContainerConfigDialog import app.gamenative.ui.component.dialog.OrientationDialog +import app.gamenative.ui.screen.settings.ControlsEditorDialog import app.gamenative.ui.theme.settingsTileColors import app.gamenative.utils.ContainerUtils import com.alorma.compose.settings.ui.SettingsGroup @@ -20,6 +21,7 @@ fun SettingsGroupEmulation() { var showConfigDialog by rememberSaveable { mutableStateOf(false) } var showOrientationDialog by rememberSaveable { mutableStateOf(false) } var showBox64PresetsDialog by rememberSaveable { mutableStateOf(false) } + var showControlsEditor by rememberSaveable { mutableStateOf(false) } OrientationDialog( openDialog = showOrientationDialog, @@ -54,6 +56,19 @@ fun SettingsGroupEmulation() { app.gamenative.ui.screen.settings.ContentsManagerDialog(open = showContentsManager, onDismiss = { showContentsManager = false }) } + if (showControlsEditor) { + ControlsEditorDialog( + open = showControlsEditor, + onDismiss = { showControlsEditor = false } + ) + } + + SettingsMenuLink( + colors = settingsTileColors(), + title = { Text(text = "Controller Profiles") }, + subtitle = { Text(text = "Manage on-screen and physical controller profiles") }, + onClick = { showControlsEditor = true }, + ) SettingsMenuLink( colors = settingsTileColors(), title = { Text(text = "Allowed Orientations") }, diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index 3f9c8d4b6..c393637e8 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -2,9 +2,12 @@ package app.gamenative.ui.screen.xserver import android.app.Activity import android.content.Context +import android.hardware.input.InputManager import android.os.Build import android.util.Log +import android.view.InputDevice import android.view.View +import android.view.ViewGroup import android.view.WindowInsets import android.view.inputmethod.InputMethodManager import android.widget.FrameLayout @@ -37,6 +40,8 @@ import app.gamenative.data.SteamApp import app.gamenative.events.AndroidEvent import app.gamenative.events.SteamEvent import app.gamenative.service.SteamService +import app.gamenative.ui.component.dialog.InGameProfileManagerDialog +import app.gamenative.ui.component.dialog.UnifiedProfileEditorDialog import app.gamenative.ui.data.XServerState import app.gamenative.utils.ContainerUtils import app.gamenative.utils.CustomGameScanner @@ -206,10 +211,123 @@ fun XServerScreen( var win32AppWorkarounds: Win32AppWorkarounds? by remember { mutableStateOf(null) } var isKeyboardVisible = false - var areControlsVisible = false + var areControlsVisible by remember { mutableStateOf(false) } + + // Dialog state for in-game controller management + var showProfileEditor by remember { mutableStateOf(false) } + var showProfileManager by remember { mutableStateOf(false) } + var profileEditorInitialTab by remember { mutableStateOf(0) } + var currentActiveProfile by remember { mutableStateOf(null) } val emulateKeyboardMouse = container.isEmulateKeyboardMouse() + // Helper function to detect if a physical controller is connected + fun isPhysicalControllerConnected(): Boolean { + val inputManager = context.getSystemService(Context.INPUT_SERVICE) as InputManager + val deviceIds = inputManager.inputDeviceIds + + for (deviceId in deviceIds) { + val device = inputManager.getInputDevice(deviceId) + if (device != null) { + val sources = device.sources + // Check if device is a gamepad or joystick + if ((sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD) || + (sources and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK)) { + return true + } + } + } + return false + } + + // Helper function to refresh and apply a new profile + fun applyNewProfile(profile: ControlsProfile) { + try { + val container = ContainerUtils.getContainer(context, appId) + val winHandler = xServerView?.getxServer()?.winHandler + + if (winHandler == null) { + Timber.w("Cannot apply profile: winHandler is null") + return + } + + Timber.d("Applying profile: ${profile.name} (id=${profile.id})") + + // Get a fresh profile instance from the manager to avoid stale data + val manager = InputControlsManager(context) + val freshProfile = manager.getProfile(profile.id) + + if (freshProfile == null) { + Timber.e("Failed to reload profile ${profile.id}") + return + } + + // Always reload elements from disk to get latest changes + PluviaApp.inputControlsView?.let { icView -> + freshProfile.loadElements(icView) + Timber.d("Loaded ${freshProfile.getElements().size} elements for profile ${freshProfile.name}") + + // Set the profile + icView.setProfile(freshProfile) + icView.setShowTouchscreenControls(true) + icView.visibility = View.VISIBLE + + // Re-link touchpad view to ensure mouse input continues working + icView.setTouchpadView(PluviaApp.touchpadView) + + // Ensure touch handling properties are enabled + icView.isClickable = true + icView.isFocusable = true + icView.isFocusableInTouchMode = true + + // Critical z-order fix: Bring InputControlsView to front to receive touch events + // IMPORTANT: DO NOT reorder XServerView (SurfaceView) - it destroys the rendering surface! + // Using bringToFront() is safe and doesn't destroy surfaces + val icParent = icView.parent as? ViewGroup + + icParent?.let { parent -> + // Simply bring InputControlsView to the front (on top of everything) + // This is safe and doesn't destroy any surfaces + icView.bringToFront() + + // Request layout to apply changes + parent.requestLayout() + parent.invalidate() + } + + icView.requestLayout() + icView.invalidate() + + // CRITICAL: Force parent to allow touch event dispatch + icParent?.let { parent -> + parent.isMotionEventSplittingEnabled = true + parent.descendantFocusability = ViewGroup.FOCUS_AFTER_DESCENDANTS + parent.requestDisallowInterceptTouchEvent(false) + } + + // Request focus after layout updates + icView.post { + icView.requestFocus() + } + } + + // Apply touchpad settings from profile + PluviaApp.touchpadView?.setSensitivity(freshProfile.getCursorSpeed() * 1.0f) + PluviaApp.touchpadView?.setPointerButtonLeftEnabled(freshProfile.isEnableTapToClick) + PluviaApp.touchpadView?.setPointerButtonRightEnabled(freshProfile.isEnableTwoFingerRightClick) + PluviaApp.touchpadView?.setTouchscreenMouseDisabled(freshProfile.isDisableTouchpadMouse) + PluviaApp.touchpadView?.setTouchscreenMode(freshProfile.isTouchscreenMode) + + // Update state + areControlsVisible = true + currentActiveProfile = freshProfile + + Timber.d("Successfully applied profile: ${freshProfile.name}") + } catch (e: Exception) { + Timber.e(e, "Failed to apply new profile") + } + } + val gameBack: () -> Unit = gameBack@{ val imeVisible = ViewCompat.getRootWindowInsets(view) ?.isVisible(WindowInsetsCompat.Type.ime()) == true @@ -267,11 +385,39 @@ fun XServerScreen( } else { profiles[2] } - showInputControls(targetProfile, xServerView!!.getxServer().winHandler, container) + showInputControls(targetProfile, xServerView!!.getxServer().winHandler, container, context) } } areControlsVisible = !areControlsVisible - Timber.d("Controls visibility toggled to: $areControlsVisible") + } + + NavigationDialog.ACTION_EDIT_CONTROLS -> { + PostHog.capture(event = "edit_controls_in_game") + + // Get current active profile + val activeProfile = currentActiveProfile ?: run { + // Try to determine from current controls if available + val profiles = PluviaApp.inputControlsManager?.getProfiles(false) ?: listOf() + if (profiles.isNotEmpty()) profiles[0] else null + } + + if (activeProfile != null) { + // Detect if physical controller is connected + val hasPhysicalController = isPhysicalControllerConnected() + + // Set initial tab: 0 = Touch Controls, 1 = Physical Controller + profileEditorInitialTab = if (hasPhysicalController) 1 else 0 + + // Open the unified profile editor dialog + showProfileEditor = true + } else { + Timber.w("No active profile to edit") + } + } + + NavigationDialog.ACTION_MANAGE_PROFILES -> { + PostHog.capture(event = "manage_profiles_in_game") + showProfileManager = true } NavigationDialog.ACTION_EXIT_GAME -> { @@ -290,6 +436,7 @@ fun XServerScreen( } } }, + areControlsVisible ).show() } @@ -622,18 +769,22 @@ fun XServerScreen( setXServer(xServerView.getxServer()) setTouchpadView(PluviaApp.touchpadView) - // Load default profile for now; may be overridden by container settings below + // Load profile from container settings val profiles = PluviaApp.inputControlsManager?.getProfiles(false) ?: listOf() PrefManager.init(context) if (profiles.isNotEmpty()) { val targetProfile = if (container.isEmulateKeyboardMouse()) { + // Use emulation profile for keyboard/mouse emulation mode val profileName = container.id.toString() profiles.firstOrNull { it.name == profileName } ?: ContainerUtils.generateOrUpdateEmulationProfile(context, container) } else { - profiles[2] + // Use the container's selected controlsProfileId + profiles.firstOrNull { it.id == container.controlsProfileId } ?: profiles.getOrNull(2) + } + if (targetProfile != null) { + setProfile(targetProfile) } - setProfile(targetProfile) } // Set overlay opacity from preferences if needed @@ -658,12 +809,15 @@ fun XServerScreen( PluviaApp.inputControlsView?.setProfile(target) PluviaApp.inputControlsView?.invalidate() } else { - // Show on-screen controls if no physical controller is connected (respect current profile) + // Show on-screen controls if no physical controller is connected (using container's profile) if (ExternalController.getController(0) == null) { val profiles2 = PluviaApp.inputControlsManager?.getProfiles(false) ?: listOf() - if (profiles2.size > 2) { - showInputControls(profiles2[2], xServerView.getxServer().winHandler, container) + val selectedProfile = profiles2.firstOrNull { it.id == container.controlsProfileId } + ?: profiles2.getOrNull(2) + if (selectedProfile != null) { + showInputControls(selectedProfile, xServerView.getxServer().winHandler, container, context) areControlsVisible = true + currentActiveProfile = selectedProfile } } } @@ -709,6 +863,54 @@ fun XServerScreen( // // } // } + + // In-game profile editor dialog + if (showProfileEditor && currentActiveProfile != null) { + val containerForEditor = ContainerUtils.getContainer(context, appId) + UnifiedProfileEditorDialog( + profile = currentActiveProfile!!, + initialTab = profileEditorInitialTab, + container = containerForEditor, // Pass container to detect in-game context + onDismiss = { + showProfileEditor = false + // Ensure controls remain visible after dismissing + if (!areControlsVisible && currentActiveProfile != null) { + val container = ContainerUtils.getContainer(context, appId) + val winHandler = xServerView?.getxServer()?.winHandler + if (winHandler != null) { + showInputControls(currentActiveProfile!!, winHandler, container, context) + areControlsVisible = true + } + } + }, + onSave = { + // Profile is automatically saved in the dialog + // Refresh the controls with the updated profile + currentActiveProfile?.let { profile -> + applyNewProfile(profile) + } + showProfileEditor = false + } + ) + } + + // In-game profile manager dialog + if (showProfileManager) { + val container = ContainerUtils.getContainer(context, appId) + InGameProfileManagerDialog( + context = context, + container = container, + currentProfileId = currentActiveProfile?.id, + onProfileSelected = { profile -> + // Apply the selected/created profile immediately + applyNewProfile(profile) + // CRITICAL: Restore controls visibility after profile switch + areControlsVisible = true + showProfileManager = false + }, + onDismiss = { showProfileManager = false } + ) + } } private fun emulateKeyboardMouseOnscreen( @@ -791,14 +993,22 @@ private fun emulateKeyboardMouseOnscreen( return targetProfile } -private fun showInputControls(profile: ControlsProfile, winHandler: WinHandler, container: Container) { +private fun showInputControls(profile: ControlsProfile, winHandler: WinHandler, container: Container, context: Context) { + // Migrate legacy mouse/touch settings from container to profile if needed + ContainerUtils.migrateMouseTouchSettingsToProfile(context, container) + profile.setVirtualGamepad(true) PluviaApp.inputControlsView?.setVisibility(View.VISIBLE) PluviaApp.inputControlsView?.requestFocus() PluviaApp.inputControlsView?.setProfile(profile) PluviaApp.touchpadView?.setSensitivity(profile.getCursorSpeed() * 1.0f) - PluviaApp.touchpadView?.setPointerButtonRightEnabled(false) + // Apply touchpad gesture settings from profile + PluviaApp.touchpadView?.setPointerButtonLeftEnabled(profile.isEnableTapToClick) + PluviaApp.touchpadView?.setPointerButtonRightEnabled(profile.isEnableTwoFingerRightClick) + // Apply mouse and touch behavior settings from profile + PluviaApp.touchpadView?.setTouchscreenMouseDisabled(profile.isDisableTouchpadMouse) + PluviaApp.touchpadView?.setTouchscreenMode(profile.isTouchscreenMode) PluviaApp.inputControlsView?.invalidate() diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index b7d06266d..69c3f4a1b 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -251,6 +251,8 @@ object ContainerUtils { touchscreenMode = touchscreenMode, emulateKeyboardMouse = container.isEmulateKeyboardMouse(), controllerEmulationBindings = container.getControllerEmulationBindings()?.toString() ?: "", + controlsProfileId = container.controlsProfileId, + autoHideControls = container.autoHideControls, csmt = csmt, videoPciDeviceID = videoPciDeviceID, offScreenRenderingMode = offScreenRenderingMode, @@ -381,7 +383,9 @@ object ContainerUtils { container.setInputType(api.ordinal) container.setDinputMapperType(containerData.dinputMapperType) container.setUseDRI3(containerData.useDRI3) - Timber.d("Container set: preferredInputApi=%s, dinputMapperType=0x%02x", api, containerData.dinputMapperType) + container.controlsProfileId = containerData.controlsProfileId + container.autoHideControls = containerData.autoHideControls + Timber.d("Container set: preferredInputApi=%s, dinputMapperType=0x%02x, controlsProfileId=%d, autoHide=%b", api, containerData.dinputMapperType, containerData.controlsProfileId, containerData.autoHideControls) if (saveToDisk) { // If bionic arm64ec, persist FEXCore settings directly @@ -933,4 +937,37 @@ object ContainerUtils { else -> GameSource.STEAM // default fallback } } + + /** + * Migrates legacy container mouse/touch settings to the profile + * + * This handles backwards compatibility for containers that have disableMouseInput + * and touchscreenMode settings stored at container level. These settings have + * been moved to the profile level for better organization. + * + * @param context Android context + * @param container The container to migrate + * @return true if migration was performed, false if already migrated + */ + fun migrateMouseTouchSettingsToProfile(context: Context, container: Container): Boolean { + // Get the profile for this container + val inputControlsManager = InputControlsManager(context) + val profile = inputControlsManager.getProfile(container.controlsProfileId) ?: return false + + // Check if migration is needed - if profile already has non-default values, assume migrated + // Default values are: disableTouchpadMouse = false, touchscreenMode = false + val alreadyMigrated = profile.isDisableTouchpadMouse || profile.isTouchscreenMode + + if (!alreadyMigrated && (container.isDisableMouseInput || container.isTouchscreenMode)) { + // Migrate the settings from container to profile + profile.setDisableTouchpadMouse(container.isDisableMouseInput) + profile.setTouchscreenMode(container.isTouchscreenMode) + profile.save() + + Timber.i("Migrated mouse/touch settings from container ${container.id} to profile ${profile.id}") + return true + } + + return false + } } diff --git a/app/src/main/java/com/winlator/container/Container.java b/app/src/main/java/com/winlator/container/Container.java index 82a5a7dd1..d6942130b 100644 --- a/app/src/main/java/com/winlator/container/Container.java +++ b/app/src/main/java/com/winlator/container/Container.java @@ -118,6 +118,10 @@ public enum XrControllerMapping { private boolean emulateKeyboardMouse = false; // Serialized as JSON object: logical button name -> Binding enum name private JSONObject controllerEmulationBindings; + // Controls profile ID for on-screen controls and physical controller bindings + public int controlsProfileId = 3; + // Auto-hide on-screen controls when physical controller is connected + public boolean autoHideControls = false; private boolean gstreamerWorkaround = false; private boolean forceDlc = false; @@ -655,6 +659,8 @@ public void saveData() { if (controllerEmulationBindings != null) { data.put("controllerEmulationBindings", controllerEmulationBindings); } + data.put("controlsProfileId", controlsProfileId); + data.put("autoHideControls", autoHideControls); // Force DLC setting data.put("forceDlc", forceDlc); @@ -830,6 +836,12 @@ public void loadData(JSONObject data) throws JSONException { case "controllerEmulationBindings": this.controllerEmulationBindings = data.getJSONObject(key); break; + case "controlsProfileId": + this.controlsProfileId = data.getInt(key); + break; + case "autoHideControls": + this.autoHideControls = data.getBoolean(key); + break; case "forceDlc": this.forceDlc = data.getBoolean(key); break; diff --git a/app/src/main/java/com/winlator/container/ContainerData.kt b/app/src/main/java/com/winlator/container/ContainerData.kt index 51d467394..151c72da9 100644 --- a/app/src/main/java/com/winlator/container/ContainerData.kt +++ b/app/src/main/java/com/winlator/container/ContainerData.kt @@ -76,6 +76,10 @@ data class ContainerData( /** Button->Binding name map (JSON string) for emulation UI persistence **/ val controllerEmulationBindings: String = "", val forceDlc: Boolean = false, + /** Controls profile ID for on-screen controls and physical controller bindings **/ + val controlsProfileId: Int = 3, + /** Auto-hide on-screen controls when physical controller is connected **/ + val autoHideControls: Boolean = false, val useLegacyDRM: Boolean = false, ) { companion object { @@ -127,6 +131,8 @@ data class ContainerData( "emulateKeyboardMouse" to state.emulateKeyboardMouse, "controllerEmulationBindings" to state.controllerEmulationBindings, "forceDlc" to state.forceDlc, + "controlsProfileId" to state.controlsProfileId, + "autoHideControls" to state.autoHideControls, "useLegacyDRM" to state.useLegacyDRM, ) }, @@ -177,6 +183,8 @@ data class ContainerData( emulateKeyboardMouse = (savedMap["emulateKeyboardMouse"] as? Boolean) ?: false, controllerEmulationBindings = (savedMap["controllerEmulationBindings"] as? String) ?: "", forceDlc = (savedMap["forceDlc"] as? Boolean) ?: false, + controlsProfileId = (savedMap["controlsProfileId"] as? Int) ?: 3, + autoHideControls = (savedMap["autoHideControls"] as? Boolean) ?: false, useLegacyDRM = (savedMap["useLegacyDRM"] as? Boolean) ?: false, ) }, diff --git a/app/src/main/java/com/winlator/contentdialog/NavigationDialog.java b/app/src/main/java/com/winlator/contentdialog/NavigationDialog.java index a696e7972..db83d40e5 100644 --- a/app/src/main/java/com/winlator/contentdialog/NavigationDialog.java +++ b/app/src/main/java/com/winlator/contentdialog/NavigationDialog.java @@ -25,12 +25,18 @@ public class NavigationDialog extends ContentDialog { public static final int ACTION_KEYBOARD = 1; public static final int ACTION_INPUT_CONTROLS = 2; public static final int ACTION_EXIT_GAME = 3; + public static final int ACTION_EDIT_CONTROLS = 4; + public static final int ACTION_MANAGE_PROFILES = 5; public interface NavigationListener { void onNavigationItemSelected(int itemId); } public NavigationDialog(@NonNull Context context, NavigationListener listener) { + this(context, listener, true); + } + + public NavigationDialog(@NonNull Context context, NavigationListener listener, boolean controlsEnabled) { super(context, R.layout.navigation_dialog); if (getWindow() != null) { getWindow().setBackgroundDrawableResource(R.drawable.navigation_dialog_background); @@ -48,11 +54,19 @@ public NavigationDialog(@NonNull Context context, NavigationListener listener) { } addMenuItem(context, grid, R.drawable.icon_keyboard, R.string.keyboard, ACTION_KEYBOARD, listener); - addMenuItem(context, grid, R.drawable.icon_input_controls, R.string.input_controls, ACTION_INPUT_CONTROLS, listener); + // Use different icon and opacity for toggle based on controls state + int controlsIcon = R.drawable.icon_input_controls; + addMenuItem(context, grid, controlsIcon, R.string.input_controls, ACTION_INPUT_CONTROLS, listener, controlsEnabled); + addMenuItem(context, grid, R.drawable.icon_popup_menu_edit, R.string.edit_controls, ACTION_EDIT_CONTROLS, listener); + addMenuItem(context, grid, R.drawable.icon_gamepad, R.string.manage_profiles, ACTION_MANAGE_PROFILES, listener); addMenuItem(context, grid, R.drawable.icon_exit, R.string.exit_game, ACTION_EXIT_GAME, listener); } private void addMenuItem(Context context, GridLayout grid, int iconRes, int titleRes, int itemId, NavigationListener listener) { + addMenuItem(context, grid, iconRes, titleRes, itemId, listener, true); + } + + private void addMenuItem(Context context, GridLayout grid, int iconRes, int titleRes, int itemId, NavigationListener listener, boolean enabled) { int padding = dpToPx(5, context); LinearLayout layout = new LinearLayout(context); layout.setPadding(padding, padding, padding, padding); @@ -66,7 +80,12 @@ private void addMenuItem(Context context, GridLayout grid, int iconRes, int titl View icon = new View(context); icon.setBackground(AppCompatResources.getDrawable(context, iconRes)); if (icon.getBackground() != null) { - icon.getBackground().setTint(context.getColor(R.color.navigation_dialog_item_color)); + int color = context.getColor(R.color.navigation_dialog_item_color); + // If disabled, apply alpha to make it look crossed out / faded + if (!enabled) { + icon.setAlpha(0.4f); + } + icon.getBackground().setTint(color); } LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(size, size); lp.gravity = Gravity.CENTER_HORIZONTAL; @@ -80,6 +99,9 @@ private void addMenuItem(Context context, GridLayout grid, int iconRes, int titl text.setGravity(Gravity.CENTER); text.setLines(2); text.setTextColor(context.getColor(R.color.navigation_dialog_item_color)); + if (!enabled) { + text.setAlpha(0.4f); + } Typeface tf = ResourcesCompat.getFont(context, R.font.bricolage_grotesque_regular); if (tf != null) { text.setTypeface(tf); diff --git a/app/src/main/java/com/winlator/inputcontrols/Binding.java b/app/src/main/java/com/winlator/inputcontrols/Binding.java index 4598aab92..8775a0e3e 100644 --- a/app/src/main/java/com/winlator/inputcontrols/Binding.java +++ b/app/src/main/java/com/winlator/inputcontrols/Binding.java @@ -8,7 +8,7 @@ import java.util.ArrayList; public enum Binding { - NONE, MOUSE_LEFT_BUTTON, MOUSE_MIDDLE_BUTTON, MOUSE_RIGHT_BUTTON, MOUSE_MOVE_LEFT, MOUSE_MOVE_RIGHT, MOUSE_MOVE_UP, MOUSE_MOVE_DOWN, MOUSE_SCROLL_UP, MOUSE_SCROLL_DOWN, KEY_UP, KEY_RIGHT, KEY_DOWN, KEY_LEFT, KEY_ENTER, KEY_ESC, KEY_BKSP, KEY_DEL, KEY_TAB, KEY_SPACE, KEY_CTRL_L, KEY_CTRL_R, KEY_SHIFT_L, KEY_SHIFT_R, KEY_ALT_L, KEY_ALT_R, KEY_HOME, KEY_PRTSCN, KEY_PG_UP, KEY_PG_DOWN, KEY_CAPS_LOCK, KEY_NUM_LOCK, KEY_0, KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, KEY_7, KEY_8, KEY_9, KEY_A, KEY_B, KEY_C, KEY_D, KEY_E, KEY_F, KEY_G, KEY_H, KEY_I, KEY_J, KEY_K, KEY_L, KEY_M, KEY_N, KEY_O, KEY_P, KEY_Q, KEY_R, KEY_S, KEY_T, KEY_U, KEY_V, KEY_W, KEY_X, KEY_Y, KEY_Z, KEY_BRACKET_LEFT, KEY_BRACKET_RIGHT, KEY_BACKSLASH, KEY_SLASH, KEY_SEMICOLON, KEY_COMMA, KEY_PERIOD, KEY_APOSTROPHE, KEY_KP_ADD, KEY_MINUS, KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6, KEY_F7, KEY_F8, KEY_F9, KEY_F10, KEY_F11, KEY_F12, KEY_KP_0, KEY_KP_1, KEY_KP_2, KEY_KP_3, KEY_KP_4, KEY_KP_5, KEY_KP_6, KEY_KP_7, KEY_KP_8, KEY_KP_9, GAMEPAD_BUTTON_A, GAMEPAD_BUTTON_B, GAMEPAD_BUTTON_X, GAMEPAD_BUTTON_Y, GAMEPAD_BUTTON_L1, GAMEPAD_BUTTON_R1, GAMEPAD_BUTTON_SELECT, GAMEPAD_BUTTON_START, GAMEPAD_BUTTON_L3, GAMEPAD_BUTTON_R3, GAMEPAD_BUTTON_L2, GAMEPAD_BUTTON_R2, GAMEPAD_LEFT_THUMB_UP, GAMEPAD_LEFT_THUMB_RIGHT, GAMEPAD_LEFT_THUMB_DOWN, GAMEPAD_LEFT_THUMB_LEFT, GAMEPAD_RIGHT_THUMB_UP, GAMEPAD_RIGHT_THUMB_RIGHT, GAMEPAD_RIGHT_THUMB_DOWN, GAMEPAD_RIGHT_THUMB_LEFT, GAMEPAD_DPAD_UP, GAMEPAD_DPAD_RIGHT, GAMEPAD_DPAD_DOWN, GAMEPAD_DPAD_LEFT; + NONE, MOUSE_LEFT_BUTTON, MOUSE_MIDDLE_BUTTON, MOUSE_RIGHT_BUTTON, MOUSE_MOVE_LEFT, MOUSE_MOVE_RIGHT, MOUSE_MOVE_UP, MOUSE_MOVE_DOWN, MOUSE_SCROLL_UP, MOUSE_SCROLL_DOWN, KEY_UP, KEY_RIGHT, KEY_DOWN, KEY_LEFT, KEY_ENTER, KEY_ESC, KEY_BKSP, KEY_DEL, KEY_TAB, KEY_SPACE, KEY_CTRL_L, KEY_CTRL_R, KEY_SHIFT_L, KEY_SHIFT_R, KEY_ALT_L, KEY_ALT_R, KEY_HOME, KEY_PRTSCN, KEY_PG_UP, KEY_PG_DOWN, KEY_CAPS_LOCK, KEY_NUM_LOCK, KEY_0, KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, KEY_7, KEY_8, KEY_9, KEY_A, KEY_B, KEY_C, KEY_D, KEY_E, KEY_F, KEY_G, KEY_H, KEY_I, KEY_J, KEY_K, KEY_L, KEY_M, KEY_N, KEY_O, KEY_P, KEY_Q, KEY_R, KEY_S, KEY_T, KEY_U, KEY_V, KEY_W, KEY_X, KEY_Y, KEY_Z, KEY_BRACKET_LEFT, KEY_BRACKET_RIGHT, KEY_BACKSLASH, KEY_SLASH, KEY_SEMICOLON, KEY_COMMA, KEY_PERIOD, KEY_APOSTROPHE, KEY_KP_ADD, KEY_MINUS, KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6, KEY_F7, KEY_F8, KEY_F9, KEY_F10, KEY_F11, KEY_F12, KEY_KP_0, KEY_KP_1, KEY_KP_2, KEY_KP_3, KEY_KP_4, KEY_KP_5, KEY_KP_6, KEY_KP_7, KEY_KP_8, KEY_KP_9, GAMEPAD_BUTTON_A, GAMEPAD_BUTTON_B, GAMEPAD_BUTTON_X, GAMEPAD_BUTTON_Y, GAMEPAD_BUTTON_L1, GAMEPAD_BUTTON_R1, GAMEPAD_BUTTON_SELECT, GAMEPAD_BUTTON_START, GAMEPAD_BUTTON_L3, GAMEPAD_BUTTON_R3, GAMEPAD_BUTTON_L2, GAMEPAD_BUTTON_R2, GAMEPAD_LEFT_THUMB_UP, GAMEPAD_LEFT_THUMB_RIGHT, GAMEPAD_LEFT_THUMB_DOWN, GAMEPAD_LEFT_THUMB_LEFT, GAMEPAD_RIGHT_THUMB_UP, GAMEPAD_RIGHT_THUMB_RIGHT, GAMEPAD_RIGHT_THUMB_DOWN, GAMEPAD_RIGHT_THUMB_LEFT, GAMEPAD_DPAD_UP, GAMEPAD_DPAD_RIGHT, GAMEPAD_DPAD_DOWN, GAMEPAD_DPAD_LEFT, GAMEPAD_LEFT_ANALOG_UP, GAMEPAD_LEFT_ANALOG_DOWN, GAMEPAD_LEFT_ANALOG_LEFT, GAMEPAD_LEFT_ANALOG_RIGHT, GAMEPAD_RIGHT_ANALOG_UP, GAMEPAD_RIGHT_ANALOG_DOWN, GAMEPAD_RIGHT_ANALOG_LEFT, GAMEPAD_RIGHT_ANALOG_RIGHT; public final XKeycode keycode; Binding() { @@ -65,6 +65,14 @@ public String toString() { return "-"; case KEY_KP_ADD: return "+"; + case MOUSE_MOVE_UP: + return "Mouse Up"; + case MOUSE_MOVE_DOWN: + return "Mouse Down"; + case MOUSE_MOVE_LEFT: + return "Mouse Left"; + case MOUSE_MOVE_RIGHT: + return "Mouse Right"; default: return super.toString().replaceAll("^(MOUSE_)|(KEY_)|(GAMEPAD_)", "").replace("KP_", "NUMPAD_").replace("_", " "); } diff --git a/app/src/main/java/com/winlator/inputcontrols/ControlElement.java b/app/src/main/java/com/winlator/inputcontrols/ControlElement.java index 43ba30784..017b5c53a 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ControlElement.java +++ b/app/src/main/java/com/winlator/inputcontrols/ControlElement.java @@ -182,6 +182,8 @@ public void setBindingAt(int index, Binding binding) { boundingBoxNeedsUpdate = true; } bindings[index] = binding; + // Mark bounding box for update since text may have changed + boundingBoxNeedsUpdate = true; } public void setBinding(Binding binding) { @@ -650,13 +652,19 @@ public boolean handleTouchMove(int pointerId, float x, float y) { if (currentPosition == null) currentPosition = new PointF(); currentPosition.x = boundingBox.left + deltaX * radius + radius; currentPosition.y = boundingBox.top + deltaY * radius + radius; - final boolean[] states = {deltaY <= -STICK_DEAD_ZONE, deltaX >= STICK_DEAD_ZONE, deltaY >= STICK_DEAD_ZONE, deltaX <= -STICK_DEAD_ZONE}; + + // Get dead zone and sensitivity from profile + ControlsProfile profile = inputControlsView.getProfile(); + float stickDeadZone = profile != null ? profile.getVirtualStickDeadZone() : STICK_DEAD_ZONE; + float stickSensitivity = profile != null ? profile.getVirtualStickSensitivity() : STICK_SENSITIVITY; + + final boolean[] states = {deltaY <= -stickDeadZone, deltaX >= stickDeadZone, deltaY >= stickDeadZone, deltaX <= -stickDeadZone}; for (byte i = 0; i < 4; i++) { float value = i == 1 || i == 3 ? deltaX : deltaY; Binding binding = getBindingAt(i); if (binding.isGamepad()) { - value = Mathf.clamp(Math.max(0, Math.abs(value) - 0.01f) * Mathf.sign(value) * STICK_SENSITIVITY, -1, 1); + value = Mathf.clamp(Math.max(0, Math.abs(value) - 0.01f) * Mathf.sign(value) * stickSensitivity, -1, 1); inputControlsView.handleInputEvent(binding, true, value); this.states[i] = true; } @@ -703,7 +711,11 @@ else if (binding == Binding.MOUSE_MOVE_UP || binding == Binding.MOUSE_MOVE_DOWN) if (cursorDx != 0 || cursorDy != 0) inputControlsView.getXServer().injectPointerMoveDelta(cursorDx, cursorDy); } else { - final boolean[] states = {deltaY <= -DPAD_DEAD_ZONE, deltaX >= DPAD_DEAD_ZONE, deltaY >= DPAD_DEAD_ZONE, deltaX <= -DPAD_DEAD_ZONE}; + // Get d-pad dead zone from profile + ControlsProfile profile = inputControlsView.getProfile(); + float dpadDeadZone = profile != null ? profile.getVirtualDpadDeadZone() : DPAD_DEAD_ZONE; + + final boolean[] states = {deltaY <= -dpadDeadZone, deltaX >= dpadDeadZone, deltaY >= dpadDeadZone, deltaX <= -dpadDeadZone}; for (byte i = 0; i < 4; i++) { float value = i == 1 || i == 3 ? deltaX : deltaY; diff --git a/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java b/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java index 8e61c8724..5c5b82aed 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java +++ b/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java @@ -1,6 +1,7 @@ package com.winlator.inputcontrols; import android.content.Context; +import android.util.Log; import androidx.annotation.NonNull; @@ -21,6 +22,28 @@ public class ControlsProfile implements Comparable { public final int id; private String name; private float cursorSpeed = 1.0f; + + // Physical controller sensitivity settings + private float physicalStickDeadZone = 0.15f; + private float physicalStickSensitivity = 3.0f; + private float physicalDpadDeadZone = 0.15f; + + // Virtual (on-screen) controls sensitivity settings + private float virtualStickDeadZone = 0.15f; + private float virtualStickSensitivity = 3.0f; + private float virtualDpadDeadZone = 0.3f; + + // Touchpad gesture settings (tap to click, multi-finger gestures) + private boolean enableTapToClick = true; + private boolean enableTwoFingerRightClick = true; + + // Mouse and touch behavior settings + private boolean disableTouchpadMouse = false; + private boolean touchscreenMode = false; + + // Game-specific profile locking (null = global profile, "STEAM_123" = locked to game) + private String lockedToContainer = null; + private final ArrayList elements = new ArrayList<>(); private final ArrayList controllers = new ArrayList<>(); private final List immutableElements = Collections.unmodifiableList(elements); @@ -51,6 +74,103 @@ public void setCursorSpeed(float cursorSpeed) { this.cursorSpeed = cursorSpeed; } + // Physical controller sensitivity getters/setters + public float getPhysicalStickDeadZone() { + return physicalStickDeadZone; + } + + public void setPhysicalStickDeadZone(float physicalStickDeadZone) { + this.physicalStickDeadZone = physicalStickDeadZone; + } + + public float getPhysicalStickSensitivity() { + return physicalStickSensitivity; + } + + public void setPhysicalStickSensitivity(float physicalStickSensitivity) { + this.physicalStickSensitivity = physicalStickSensitivity; + } + + public float getPhysicalDpadDeadZone() { + return physicalDpadDeadZone; + } + + public void setPhysicalDpadDeadZone(float physicalDpadDeadZone) { + this.physicalDpadDeadZone = physicalDpadDeadZone; + } + + // Virtual controls sensitivity getters/setters + public float getVirtualStickDeadZone() { + return virtualStickDeadZone; + } + + public void setVirtualStickDeadZone(float virtualStickDeadZone) { + this.virtualStickDeadZone = virtualStickDeadZone; + } + + public float getVirtualStickSensitivity() { + return virtualStickSensitivity; + } + + public void setVirtualStickSensitivity(float virtualStickSensitivity) { + this.virtualStickSensitivity = virtualStickSensitivity; + } + + public float getVirtualDpadDeadZone() { + return virtualDpadDeadZone; + } + + public void setVirtualDpadDeadZone(float virtualDpadDeadZone) { + this.virtualDpadDeadZone = virtualDpadDeadZone; + } + + // Touchpad gesture getters/setters + public boolean isEnableTapToClick() { + return enableTapToClick; + } + + public void setEnableTapToClick(boolean enableTapToClick) { + this.enableTapToClick = enableTapToClick; + } + + public boolean isEnableTwoFingerRightClick() { + return enableTwoFingerRightClick; + } + + public void setEnableTwoFingerRightClick(boolean enableTwoFingerRightClick) { + this.enableTwoFingerRightClick = enableTwoFingerRightClick; + } + + // Mouse and touch behavior getters/setters + public boolean isDisableTouchpadMouse() { + return disableTouchpadMouse; + } + + public void setDisableTouchpadMouse(boolean disableTouchpadMouse) { + this.disableTouchpadMouse = disableTouchpadMouse; + } + + public boolean isTouchscreenMode() { + return touchscreenMode; + } + + public void setTouchscreenMode(boolean touchscreenMode) { + this.touchscreenMode = touchscreenMode; + } + + // Game locking getters/setters + public String getLockedToContainer() { + return lockedToContainer; + } + + public void setLockedToContainer(String lockedToContainer) { + this.lockedToContainer = lockedToContainer; + } + + public boolean isLockedToGame() { + return lockedToContainer != null && !lockedToContainer.isEmpty(); + } + public boolean isVirtualGamepad() { return virtualGamepad; } @@ -66,7 +186,17 @@ public GamepadState getGamepadState() { public ExternalController addController(String id) { ExternalController controller = getController(id); - if (controller == null) controllers.add(controller = ExternalController.getController(id)); + if (controller == null) { + // Check if controller exists in the static list + controller = ExternalController.getController(id); + // If still null, create a new controller with the given id + if (controller == null) { + controller = new ExternalController(); + controller.setId(id); + controller.setName("Default Physical Controller"); + } + controllers.add(controller); + } controllersLoaded = true; return controller; } @@ -88,6 +218,23 @@ public ExternalController getController(int deviceId) { return null; } + public ArrayList getControllers() { + if (!controllersLoaded) loadControllers(); + return controllers; + } + + public ExternalController getOrCreateController(String id, String name) { + ExternalController controller = getController(id); + if (controller == null) { + controller = new ExternalController(); + controller.setId(id); + controller.setName(name); + controllers.add(controller); + controllersLoaded = true; + } + return controller; + } + @NonNull @Override public String toString() { @@ -103,7 +250,16 @@ public boolean isElementsLoaded() { return elementsLoaded; } - public void save() { + /** + * Force reload elements from disk, discarding any in-memory changes. + * Use this when switching profiles or when you need to discard edits. + */ + public void reloadElements(InputControlsView inputControlsView) { + elementsLoaded = false; // Reset flag to allow reload + loadElements(inputControlsView); + } + + public synchronized void save() { File file = getProfileFile(context, id); try { @@ -112,12 +268,35 @@ public void save() { data.put("name", name); data.put("cursorSpeed", Float.valueOf(cursorSpeed)); + // Save sensitivity settings + data.put("physicalStickDeadZone", Float.valueOf(physicalStickDeadZone)); + data.put("physicalStickSensitivity", Float.valueOf(physicalStickSensitivity)); + data.put("physicalDpadDeadZone", Float.valueOf(physicalDpadDeadZone)); + data.put("virtualStickDeadZone", Float.valueOf(virtualStickDeadZone)); + data.put("virtualStickSensitivity", Float.valueOf(virtualStickSensitivity)); + data.put("virtualDpadDeadZone", Float.valueOf(virtualDpadDeadZone)); + + // Save touchpad gesture settings + data.put("enableTapToClick", Boolean.valueOf(enableTapToClick)); + data.put("enableTwoFingerRightClick", Boolean.valueOf(enableTwoFingerRightClick)); + + // Save mouse and touch behavior settings + data.put("disableTouchpadMouse", Boolean.valueOf(disableTouchpadMouse)); + data.put("touchscreenMode", Boolean.valueOf(touchscreenMode)); + + // Save game locking + if (lockedToContainer != null) { + data.put("lockedToContainer", lockedToContainer); + } + JSONArray elementsJSONArray = new JSONArray(); if (!elementsLoaded && file.isFile()) { JSONObject profileJSONObject = new JSONObject(FileUtils.readString(file)); elementsJSONArray = profileJSONObject.getJSONArray("elements"); } - else for (ControlElement element : elements) elementsJSONArray.put(element.toJSONObject()); + else { + for (ControlElement element : elements) elementsJSONArray.put(element.toJSONObject()); + } data.put("elements", elementsJSONArray); JSONArray controllersJSONArray = new JSONArray(); @@ -128,14 +307,18 @@ public void save() { else { for (ExternalController controller : controllers) { JSONObject controllerJSONObject = controller.toJSONObject(); - if (controllerJSONObject != null) controllersJSONArray.put(controllerJSONObject); + if (controllerJSONObject != null) { + controllersJSONArray.put(controllerJSONObject); + } } } if (controllersJSONArray.length() > 0) data.put("controllers", controllersJSONArray); FileUtils.writeString(file, data.toString()); } - catch (JSONException e) {} + catch (JSONException e) { + Log.e("ControlsProfile", "Error saving profile: " + e.getMessage(), e); + } } public static File getProfileFile(Context context, int id) { @@ -165,12 +348,18 @@ public ArrayList loadControllers() { controllersLoaded = false; File file = getProfileFile(context, id); - if (!file.isFile()) return controllers; + + if (!file.isFile()) { + return controllers; + } try { JSONObject profileJSONObject = new JSONObject(FileUtils.readString(file)); - if (!profileJSONObject.has("controllers")) return controllers; + if (!profileJSONObject.has("controllers")) { + return controllers; + } JSONArray controllersJSONArray = profileJSONObject.getJSONArray("controllers"); + for (int i = 0; i < controllersJSONArray.length(); i++) { JSONObject controllerJSONObject = controllersJSONArray.getJSONObject(i); String id = controllerJSONObject.getString("id"); @@ -179,11 +368,14 @@ public ArrayList loadControllers() { controller.setName(controllerJSONObject.getString("name")); JSONArray controllerBindingsJSONArray = controllerJSONObject.getJSONArray("controllerBindings"); + for (int j = 0; j < controllerBindingsJSONArray.length(); j++) { JSONObject controllerBindingJSONObject = controllerBindingsJSONArray.getJSONObject(j); ExternalControllerBinding controllerBinding = new ExternalControllerBinding(); - controllerBinding.setKeyCode(controllerBindingJSONObject.getInt("keyCode")); - controllerBinding.setBinding(Binding.fromString(controllerBindingJSONObject.getString("binding"))); + int keyCode = controllerBindingJSONObject.getInt("keyCode"); + String bindingName = controllerBindingJSONObject.getString("binding"); + controllerBinding.setKeyCode(keyCode); + controllerBinding.setBinding(Binding.fromString(bindingName)); controller.addControllerBinding(controllerBinding); } controllers.add(controller); @@ -191,22 +383,33 @@ public ArrayList loadControllers() { controllersLoaded = true; } catch (JSONException e) { + Log.e("ControlsProfile", "Error loading controllers: " + e.getMessage(), e); e.printStackTrace(); } return controllers; } - public void loadElements(InputControlsView inputControlsView) { - elements.clear(); - elementsLoaded = false; - virtualGamepad = false; + public synchronized void loadElements(InputControlsView inputControlsView) { + // Prevent reloading if already loaded (to preserve in-memory edits) + if (elementsLoaded) { + return; + } File file = getProfileFile(context, id); - if (!file.isFile()) return; + + if (!file.isFile()) { + elementsLoaded = true; // Mark as loaded to prevent repeated load attempts + return; + } + + // Only clear and reset after confirming file exists + elements.clear(); + virtualGamepad = false; try { JSONObject profileJSONObject = new JSONObject(FileUtils.readString(file)); JSONArray elementsJSONArray = profileJSONObject.getJSONArray("elements"); + for (int i = 0; i < elementsJSONArray.length(); i++) { JSONObject elementJSONObject = elementsJSONArray.getJSONObject(i); ControlElement element = new ControlElement(inputControlsView); @@ -235,6 +438,7 @@ public void loadElements(InputControlsView inputControlsView) { elementsLoaded = true; } catch (JSONException e) { + Log.e("ControlsProfile", "Error loading elements: " + e.getMessage(), e); e.printStackTrace(); } } diff --git a/app/src/main/java/com/winlator/inputcontrols/ExternalController.java b/app/src/main/java/com/winlator/inputcontrols/ExternalController.java index 8a8bdc88b..822ece462 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ExternalController.java +++ b/app/src/main/java/com/winlator/inputcontrols/ExternalController.java @@ -19,10 +19,9 @@ import org.json.JSONException; import org.json.JSONObject; -import java.util.ArrayList; - public class ExternalController { public static final float STICK_DEAD_ZONE = 0.15f; + public static final float STICK_SENSITIVITY = 3.0f; public static final byte IDX_BUTTON_A = 0; public static final byte IDX_BUTTON_B = 1; public static final byte IDX_BUTTON_X = 2; @@ -144,6 +143,10 @@ public int getControllerBindingCount() { return this.controllerBindings.size(); } + public ArrayList getControllerBindings() { + return this.controllerBindings; + } + public JSONObject toJSONObject() { try { if (this.controllerBindings.isEmpty()) { @@ -161,6 +164,7 @@ public JSONObject toJSONObject() { controllerJSONObject.put("controllerBindings", controllerBindingsJSONArray); return controllerJSONObject; } catch (JSONException e) { + Log.e("ExternalController", "Failed to serialize controller to JSON: " + this.name, e); return null; } } @@ -174,24 +178,25 @@ public String toString() { return getDeviceId() + " | " + getName(); } - private void processJoystickInput(MotionEvent event, int historyPos) { + private void processJoystickInput(MotionEvent event, int historyPos, float stickDeadZone, float stickSensitivity) { boolean z = false; - this.state.thumbLX = getCenteredAxis(event, MotionEvent.AXIS_X, historyPos); - this.state.thumbLY = getCenteredAxis(event, MotionEvent.AXIS_Y, historyPos); - this.state.thumbRX = getCenteredAxis(event, MotionEvent.AXIS_Z, historyPos); - this.state.thumbRY = getCenteredAxis(event, MotionEvent.AXIS_RZ, historyPos); + // Apply sensitivity multiplier and clamp to [-1, 1] + this.state.thumbLX = Math.max(-1f, Math.min(1f, getCenteredAxis(event, MotionEvent.AXIS_X, historyPos) * stickSensitivity)); + this.state.thumbLY = Math.max(-1f, Math.min(1f, getCenteredAxis(event, MotionEvent.AXIS_Y, historyPos) * stickSensitivity)); + this.state.thumbRX = Math.max(-1f, Math.min(1f, getCenteredAxis(event, MotionEvent.AXIS_Z, historyPos) * stickSensitivity)); + this.state.thumbRY = Math.max(-1f, Math.min(1f, getCenteredAxis(event, MotionEvent.AXIS_RZ, historyPos) * stickSensitivity)); if (historyPos == -1) { float axisX = getCenteredAxis(event, MotionEvent.AXIS_HAT_X, historyPos); float axisY = getCenteredAxis(event, MotionEvent.AXIS_HAT_Y, historyPos); GamepadState gamepadState = this.state; - gamepadState.dpad[0] = axisY == -1.0f && Math.abs(gamepadState.thumbLY) < STICK_DEAD_ZONE; + gamepadState.dpad[0] = axisY == -1.0f && Math.abs(gamepadState.thumbLY) < stickDeadZone; GamepadState gamepadState2 = this.state; - gamepadState2.dpad[1] = axisX == 1.0f && Math.abs(gamepadState2.thumbLX) < STICK_DEAD_ZONE; + gamepadState2.dpad[1] = axisX == 1.0f && Math.abs(gamepadState2.thumbLX) < stickDeadZone; GamepadState gamepadState3 = this.state; - gamepadState3.dpad[2] = axisY == 1.0f && Math.abs(gamepadState3.thumbLY) < STICK_DEAD_ZONE; + gamepadState3.dpad[2] = axisY == 1.0f && Math.abs(gamepadState3.thumbLY) < stickDeadZone; GamepadState gamepadState4 = this.state; boolean[] zArr = gamepadState4.dpad; - if (axisX == -1.0f && Math.abs(gamepadState4.thumbLX) < STICK_DEAD_ZONE) { + if (axisX == -1.0f && Math.abs(gamepadState4.thumbLX) < stickDeadZone) { z = true; } zArr[3] = z; @@ -244,20 +249,32 @@ private void processXboxTriggerButton(MotionEvent event) { } public boolean updateStateFromMotionEvent(MotionEvent event) { + return updateStateFromMotionEvent(event, STICK_DEAD_ZONE); + } + + public boolean updateStateFromMotionEvent(MotionEvent event, float stickDeadZone) { + return updateStateFromMotionEvent(event, stickDeadZone, STICK_SENSITIVITY); + } + + public boolean updateStateFromMotionEvent(MotionEvent event, float stickDeadZone, float stickSensitivity) { if (isJoystickDevice(event)) { if (triggerType == TRIGGER_IS_AXIS) processTriggerButton(event); else if (triggerType == TRIGGER_IS_BUTTON && isXboxController()) processXboxTriggerButton(event); int historySize = event.getHistorySize(); - for (int i = 0; i < historySize; i++) processJoystickInput(event, i); - processJoystickInput(event, -1); + for (int i = 0; i < historySize; i++) processJoystickInput(event, i, stickDeadZone, stickSensitivity); + processJoystickInput(event, -1, stickDeadZone, stickSensitivity); return true; } return false; } public boolean updateStateFromKeyEvent(KeyEvent event) { + return updateStateFromKeyEvent(event, STICK_DEAD_ZONE); + } + + public boolean updateStateFromKeyEvent(KeyEvent event, float stickDeadZone) { boolean z = false; boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN; int keyCode = event.getKeyCode(); @@ -282,12 +299,12 @@ public boolean updateStateFromKeyEvent(KeyEvent event) { switch (keyCode) { case KeyEvent.KEYCODE_DPAD_UP: GamepadState gamepadState = this.state; - gamepadState.dpad[0] = pressed && Math.abs(gamepadState.thumbLY) < STICK_DEAD_ZONE; + gamepadState.dpad[0] = pressed && Math.abs(gamepadState.thumbLY) < stickDeadZone; return true; case KeyEvent.KEYCODE_DPAD_DOWN: GamepadState gamepadState2 = this.state; boolean[] zArr = gamepadState2.dpad; - if (pressed && Math.abs(gamepadState2.thumbLY) < STICK_DEAD_ZONE) { + if (pressed && Math.abs(gamepadState2.thumbLY) < stickDeadZone) { z = true; } zArr[2] = z; @@ -295,7 +312,7 @@ public boolean updateStateFromKeyEvent(KeyEvent event) { case KeyEvent.KEYCODE_DPAD_LEFT: GamepadState gamepadState3 = this.state; boolean[] zArr2 = gamepadState3.dpad; - if (pressed && Math.abs(gamepadState3.thumbLX) < STICK_DEAD_ZONE) { + if (pressed && Math.abs(gamepadState3.thumbLX) < stickDeadZone) { z = true; } zArr2[3] = z; @@ -303,7 +320,7 @@ public boolean updateStateFromKeyEvent(KeyEvent event) { case KeyEvent.KEYCODE_DPAD_RIGHT: GamepadState gamepadState4 = this.state; boolean[] zArr3 = gamepadState4.dpad; - if (pressed && Math.abs(gamepadState4.thumbLX) < STICK_DEAD_ZONE) { + if (pressed && Math.abs(gamepadState4.thumbLX) < stickDeadZone) { z = true; } zArr3[1] = z; @@ -376,6 +393,9 @@ public static float getCenteredAxis(MotionEvent event, int axis, int historyPos) return 0.0f; } InputDevice device = event.getDevice(); + if (device == null) { + return 0.0f; + } InputDevice.MotionRange range = device.getMotionRange(axis, event.getSource()); if (range != null) { float flat = range.getFlat(); diff --git a/app/src/main/java/com/winlator/inputcontrols/ExternalControllerBinding.java b/app/src/main/java/com/winlator/inputcontrols/ExternalControllerBinding.java index 5e1b8d587..5b3ea8322 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ExternalControllerBinding.java +++ b/app/src/main/java/com/winlator/inputcontrols/ExternalControllerBinding.java @@ -78,11 +78,11 @@ public static int getKeyCodeForAxis(int axis, byte sign) { case MotionEvent.AXIS_X: return sign > 0 ? AXIS_X_POSITIVE : AXIS_X_NEGATIVE; case MotionEvent.AXIS_Y: - return sign > 0 ? AXIS_Y_NEGATIVE : AXIS_Y_POSITIVE; + return sign > 0 ? AXIS_Y_POSITIVE : AXIS_Y_NEGATIVE; case MotionEvent.AXIS_Z: return sign > 0 ? AXIS_Z_POSITIVE : AXIS_Z_NEGATIVE; case MotionEvent.AXIS_RZ: - return sign > 0 ? AXIS_RZ_NEGATIVE : AXIS_RZ_POSITIVE; + return sign > 0 ? AXIS_RZ_POSITIVE : AXIS_RZ_NEGATIVE; case MotionEvent.AXIS_HAT_X: return sign > 0 ? KeyEvent.KEYCODE_DPAD_RIGHT : KeyEvent.KEYCODE_DPAD_LEFT; case MotionEvent.AXIS_HAT_Y: diff --git a/app/src/main/java/com/winlator/inputcontrols/InputControlsManager.java b/app/src/main/java/com/winlator/inputcontrols/InputControlsManager.java index 439678866..1df6b9c7c 100644 --- a/app/src/main/java/com/winlator/inputcontrols/InputControlsManager.java +++ b/app/src/main/java/com/winlator/inputcontrols/InputControlsManager.java @@ -11,6 +11,7 @@ import com.winlator.core.AppUtils; import com.winlator.core.FileUtils; +import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -49,6 +50,123 @@ public ArrayList getProfiles(boolean ignoreTemplates) { return profiles; } + // Get templates only + public ArrayList getTemplates() { + ArrayList allProfiles = getProfiles(false); + ArrayList templates = new ArrayList<>(); + for (ControlsProfile profile : allProfiles) { + if (profile.isTemplate()) { + templates.add(profile); + } + } + return templates; + } + + // Get global profiles only (not locked to any game, not templates) + public ArrayList getGlobalProfiles() { + ArrayList allProfiles = getProfiles(true); // ignore templates + ArrayList globalProfiles = new ArrayList<>(); + for (ControlsProfile profile : allProfiles) { + if (!profile.isLockedToGame()) { + globalProfiles.add(profile); + } + } + return globalProfiles; + } + + // Get profiles for a specific container (global + locked to this container) + public ArrayList getProfilesForContainer(String containerId) { + ArrayList allProfiles = getProfiles(true); // ignore templates + ArrayList containerProfiles = new ArrayList<>(); + for (ControlsProfile profile : allProfiles) { + if (!profile.isLockedToGame() || containerId.equals(profile.getLockedToContainer())) { + containerProfiles.add(profile); + } + } + return containerProfiles; + } + + // Clone a profile with new name and optional game lock + public ControlsProfile cloneProfile(ControlsProfile source, String newName, String lockedToContainer) throws Exception { + // Find next available ID + int maxId = 0; + for (ControlsProfile profile : getProfiles(false)) { + if (profile.id > maxId) maxId = profile.id; + } + int newId = maxId + 1; + + // Create new profile with cloned settings + ControlsProfile newProfile = new ControlsProfile(context, newId); + newProfile.setName(newName); + newProfile.setCursorSpeed(source.getCursorSpeed()); + + // Copy sensitivity settings + newProfile.setPhysicalStickDeadZone(source.getPhysicalStickDeadZone()); + newProfile.setPhysicalStickSensitivity(source.getPhysicalStickSensitivity()); + newProfile.setPhysicalDpadDeadZone(source.getPhysicalDpadDeadZone()); + newProfile.setVirtualStickDeadZone(source.getVirtualStickDeadZone()); + newProfile.setVirtualStickSensitivity(source.getVirtualStickSensitivity()); + newProfile.setVirtualDpadDeadZone(source.getVirtualDpadDeadZone()); + + // Copy touchpad gesture settings + newProfile.setEnableTapToClick(source.isEnableTapToClick()); + newProfile.setEnableTwoFingerRightClick(source.isEnableTwoFingerRightClick()); + + // Copy mouse/touch behavior + newProfile.setDisableTouchpadMouse(source.isDisableTouchpadMouse()); + newProfile.setTouchscreenMode(source.isTouchscreenMode()); + + // Set game lock (can be different from source) + newProfile.setLockedToContainer(lockedToContainer); + + // Read source profile file and copy elements/controllers arrays directly from JSON + // (no need to load elements with InputControlsView) + File sourceFile = ControlsProfile.getProfileFile(context, source.id); + if (sourceFile.exists()) { + try { + org.json.JSONObject sourceJson = new org.json.JSONObject(com.winlator.core.FileUtils.readString(sourceFile)); + if (sourceJson.has("elements")) { + org.json.JSONArray elements = sourceJson.getJSONArray("elements"); + // Save to new profile file with elements + File newFile = ControlsProfile.getProfileFile(context, newId); + org.json.JSONObject newJson = new org.json.JSONObject(); + newJson.put("id", newId); + newJson.put("name", newName); + newJson.put("cursorSpeed", source.getCursorSpeed()); + newJson.put("physicalStickDeadZone", source.getPhysicalStickDeadZone()); + newJson.put("physicalStickSensitivity", source.getPhysicalStickSensitivity()); + newJson.put("physicalDpadDeadZone", source.getPhysicalDpadDeadZone()); + newJson.put("virtualStickDeadZone", source.getVirtualStickDeadZone()); + newJson.put("virtualStickSensitivity", source.getVirtualStickSensitivity()); + newJson.put("virtualDpadDeadZone", source.getVirtualDpadDeadZone()); + newJson.put("enableTapToClick", source.isEnableTapToClick()); + newJson.put("enableTwoFingerRightClick", source.isEnableTwoFingerRightClick()); + newJson.put("disableTouchpadMouse", source.isDisableTouchpadMouse()); + newJson.put("touchscreenMode", source.isTouchscreenMode()); + if (lockedToContainer != null) { + newJson.put("lockedToContainer", lockedToContainer); + } + newJson.put("elements", elements); + if (sourceJson.has("controllers")) { + newJson.put("controllers", sourceJson.getJSONArray("controllers")); + } + com.winlator.core.FileUtils.writeString(newFile, newJson.toString()); + } + } catch (Exception e) { + throw new Exception("Failed to clone profile: " + e.getMessage()); + } + } else { + // No elements to copy, just save basic profile + newProfile.save(); + } + + // Reload profiles to include the new one + profilesLoaded = false; + loadProfiles(false); + + return newProfile; + } + private void copyAssetProfilesIfNeeded() { File profilesDir = InputControlsManager.getProfilesDir(context); if (FileUtils.isEmpty(profilesDir)) { @@ -111,11 +229,60 @@ public void loadProfiles(boolean ignoreTemplates) { public ControlsProfile createProfile(String name) { ControlsProfile profile = new ControlsProfile(context, ++maxProfileId); profile.setName(name); + + // Add default controller configuration + ExternalController defaultController = profile.addController("*"); + defaultController.setName("Default Physical Controller"); + + // Add default button bindings + addDefaultControllerBindings(defaultController); + profile.save(); profiles.add(profile); return profile; } + private void addDefaultControllerBindings(ExternalController controller) { + // Gamepad buttons (KeyEvent keycodes) + addBinding(controller, 96, Binding.GAMEPAD_BUTTON_A); // KEYCODE_BUTTON_A + addBinding(controller, 97, Binding.GAMEPAD_BUTTON_B); // KEYCODE_BUTTON_B + addBinding(controller, 99, Binding.GAMEPAD_BUTTON_X); // KEYCODE_BUTTON_X + addBinding(controller, 100, Binding.GAMEPAD_BUTTON_Y); // KEYCODE_BUTTON_Y + addBinding(controller, 102, Binding.GAMEPAD_BUTTON_L1); // KEYCODE_BUTTON_L1 + addBinding(controller, 103, Binding.GAMEPAD_BUTTON_R1); // KEYCODE_BUTTON_R1 + addBinding(controller, 104, Binding.GAMEPAD_BUTTON_L2); // KEYCODE_BUTTON_L2 + addBinding(controller, 105, Binding.GAMEPAD_BUTTON_R2); // KEYCODE_BUTTON_R2 + addBinding(controller, 106, Binding.GAMEPAD_BUTTON_L3); // KEYCODE_BUTTON_THUMBL + addBinding(controller, 107, Binding.GAMEPAD_BUTTON_R3); // KEYCODE_BUTTON_THUMBR + addBinding(controller, 108, Binding.GAMEPAD_BUTTON_START); // KEYCODE_BUTTON_START + addBinding(controller, 109, Binding.GAMEPAD_BUTTON_SELECT);// KEYCODE_BUTTON_SELECT + + // D-Pad + addBinding(controller, 19, Binding.GAMEPAD_DPAD_UP); // KEYCODE_DPAD_UP + addBinding(controller, 20, Binding.GAMEPAD_DPAD_DOWN); // KEYCODE_DPAD_DOWN + addBinding(controller, 21, Binding.GAMEPAD_DPAD_LEFT); // KEYCODE_DPAD_LEFT + addBinding(controller, 22, Binding.GAMEPAD_DPAD_RIGHT); // KEYCODE_DPAD_RIGHT + + // Left Analog Stick (axis codes) + addBinding(controller, -3, Binding.GAMEPAD_LEFT_ANALOG_UP); // AXIS_Y_NEGATIVE + addBinding(controller, -4, Binding.GAMEPAD_LEFT_ANALOG_DOWN); // AXIS_Y_POSITIVE + addBinding(controller, -1, Binding.GAMEPAD_LEFT_ANALOG_LEFT); // AXIS_X_NEGATIVE + addBinding(controller, -2, Binding.GAMEPAD_LEFT_ANALOG_RIGHT); // AXIS_X_POSITIVE + + // Right Analog Stick (axis codes) + addBinding(controller, -7, Binding.GAMEPAD_RIGHT_ANALOG_UP); // AXIS_RZ_NEGATIVE + addBinding(controller, -8, Binding.GAMEPAD_RIGHT_ANALOG_DOWN); // AXIS_RZ_POSITIVE + addBinding(controller, -5, Binding.GAMEPAD_RIGHT_ANALOG_LEFT); // AXIS_Z_NEGATIVE + addBinding(controller, -6, Binding.GAMEPAD_RIGHT_ANALOG_RIGHT); // AXIS_Z_POSITIVE + } + + private void addBinding(ExternalController controller, int keyCode, Binding binding) { + ExternalControllerBinding controllerBinding = new ExternalControllerBinding(); + controllerBinding.setKeyCode(keyCode); + controllerBinding.setBinding(binding); + controller.addControllerBinding(controllerBinding); + } + public ControlsProfile duplicateProfile(ControlsProfile source) { String newName; for (int i = 1;;i++) { @@ -155,25 +322,67 @@ public void removeProfile(ControlsProfile profile) { public ControlsProfile importProfile(JSONObject data) { try { if (!data.has("id") || !data.has("name")) return null; + + // Ensure profiles are loaded + if (profiles == null || !profilesLoaded) { + loadProfiles(false); + } + + // Load template defaults from controls-3.icp to ensure all required fields exist + JSONObject templateDefaults = loadTemplateDefaults(); + + // Get the original name from the imported profile + String originalName = data.getString("name"); + String uniqueName = originalName; + + // Check for duplicate names and add numbering if needed + int counter = 2; + boolean nameExists = true; + while (nameExists) { + nameExists = false; + for (ControlsProfile profile : profiles) { + if (profile.getName().equals(uniqueName)) { + nameExists = true; + uniqueName = originalName + " (" + counter + ")"; + counter++; + break; + } + } + } + + // Merge with template defaults - ensure all required fields exist + if (templateDefaults != null) { + // Copy all default fields if they don't exist in imported data + if (!data.has("cursorSpeed")) data.put("cursorSpeed", templateDefaults.optDouble("cursorSpeed", 1.0)); + if (!data.has("physicalStickDeadZone")) data.put("physicalStickDeadZone", templateDefaults.optDouble("physicalStickDeadZone", 0.15)); + if (!data.has("physicalStickSensitivity")) data.put("physicalStickSensitivity", templateDefaults.optDouble("physicalStickSensitivity", 3.0)); + if (!data.has("physicalDpadDeadZone")) data.put("physicalDpadDeadZone", templateDefaults.optDouble("physicalDpadDeadZone", 0.15)); + if (!data.has("virtualStickDeadZone")) data.put("virtualStickDeadZone", templateDefaults.optDouble("virtualStickDeadZone", 0.15)); + if (!data.has("virtualStickSensitivity")) data.put("virtualStickSensitivity", templateDefaults.optDouble("virtualStickSensitivity", 3.0)); + if (!data.has("virtualDpadDeadZone")) data.put("virtualDpadDeadZone", templateDefaults.optDouble("virtualDpadDeadZone", 0.3)); + if (!data.has("enableTapToClick")) data.put("enableTapToClick", templateDefaults.optBoolean("enableTapToClick", true)); + if (!data.has("enableTwoFingerRightClick")) data.put("enableTwoFingerRightClick", templateDefaults.optBoolean("enableTwoFingerRightClick", true)); + if (!data.has("disableTouchpadMouse")) data.put("disableTouchpadMouse", templateDefaults.optBoolean("disableTouchpadMouse", false)); + if (!data.has("touchscreenMode")) data.put("touchscreenMode", templateDefaults.optBoolean("touchscreenMode", false)); + + // If controllers array is missing, use template default + if (!data.has("controllers") && templateDefaults.has("controllers")) { + data.put("controllers", templateDefaults.getJSONArray("controllers")); + } + } + + // Update the name in the JSON data if it was changed + if (!uniqueName.equals(originalName)) { + data.put("name", uniqueName); + } + int newId = ++maxProfileId; File newFile = ControlsProfile.getProfileFile(context, newId); data.put("id", newId); FileUtils.writeString(newFile, data.toString()); ControlsProfile newProfile = loadProfile(context, newFile); - int foundIndex = -1; - for (int i = 0; i < profiles.size(); i++) { - ControlsProfile profile = profiles.get(i); - if (profile.getName().equals(newProfile.getName())) { - foundIndex = i; - break; - } - } - - if (foundIndex != -1) { - profiles.set(foundIndex, newProfile); - } - else profiles.add(newProfile); + profiles.add(newProfile); return newProfile; } catch (JSONException e) { @@ -181,14 +390,99 @@ public ControlsProfile importProfile(JSONObject data) { } } + private JSONObject loadTemplateDefaults() { + try { + AssetManager assetManager = context.getAssets(); + InputStream inputStream = assetManager.open("inputcontrols/profiles/controls-3.icp"); + String content = FileUtils.readString(context, "inputcontrols/profiles/controls-3.icp"); + inputStream.close(); + return new JSONObject(content); + } catch (Exception e) { + return null; + } + } + public File exportProfile(ControlsProfile profile) { File downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); - File destination = new File(downloadsDir, "Winlator/profiles/"+profile.getName()+".icp"); + // Sanitize profile name for use in filename + String safeFilename = sanitizeProfileName(profile.getName()); + File destination = new File(downloadsDir, "Winlator/profiles/" + safeFilename + ".icp"); FileUtils.copy(ControlsProfile.getProfileFile(context, profile.id), destination); MediaScannerConnection.scanFile(context, new String[]{destination.getAbsolutePath()}, null, null); return destination.isFile() ? destination : null; } + /** + * Validates a profile name for safety and usability. + * @param name The profile name to validate + * @return null if valid, error message if invalid + */ + public static String validateProfileName(String name) { + if (name == null || name.trim().isEmpty()) { + return "Profile name cannot be empty"; + } + + String trimmed = name.trim(); + + // Check length (max 100 characters for usability) + if (trimmed.length() > 100) { + return "Profile name must be 100 characters or less"; + } + + // Check for filesystem-unsafe characters + String unsafeChars = "/\\:*?\"<>|"; + for (char c : unsafeChars.toCharArray()) { + if (trimmed.indexOf(c) >= 0) { + return "Profile name cannot contain: / \\ : * ? \" < > |"; + } + } + + // Check for control characters or newlines + for (int i = 0; i < trimmed.length(); i++) { + if (Character.isISOControl(trimmed.charAt(i))) { + return "Profile name cannot contain control characters or newlines"; + } + } + + // Check for leading/trailing dots (Windows issue) + if (trimmed.startsWith(".") || trimmed.endsWith(".")) { + return "Profile name cannot start or end with a dot"; + } + + return null; // Valid + } + + /** + * Sanitizes a profile name by removing/replacing unsafe characters. + * Use this for auto-generated names or when validation fails. + */ + public static String sanitizeProfileName(String name) { + if (name == null) return "Unnamed Profile"; + + String sanitized = name.trim(); + + // Replace unsafe characters with underscore + sanitized = sanitized.replaceAll("[/\\\\:*?\"<>|]", "_"); + + // Remove control characters + sanitized = sanitized.replaceAll("\\p{Cntrl}", ""); + + // Remove leading/trailing dots + sanitized = sanitized.replaceAll("^\\.+|\\.+$", ""); + + // Trim to max length + if (sanitized.length() > 100) { + sanitized = sanitized.substring(0, 100).trim(); + } + + // Fallback if everything was removed + if (sanitized.isEmpty()) { + return "Unnamed Profile"; + } + + return sanitized; + } + public static ControlsProfile loadProfile(Context context, File file) { try { return loadProfile(context, new FileInputStream(file)); @@ -203,7 +497,17 @@ public static ControlsProfile loadProfile(Context context, InputStream inStream) int profileId = 0; String profileName = null; float cursorSpeed = Float.NaN; - int fieldsRead = 0; + float physicalStickDeadZone = 0.15f; + float physicalStickSensitivity = 3.0f; + float physicalDpadDeadZone = 0.15f; + float virtualStickDeadZone = 0.15f; + float virtualStickSensitivity = 3.0f; + float virtualDpadDeadZone = 0.3f; + boolean enableTapToClick = true; + boolean enableTwoFingerRightClick = true; + boolean disableTouchpadMouse = false; + boolean touchscreenMode = false; + String lockedToContainer = null; reader.beginObject(); while (reader.hasNext()) { @@ -211,18 +515,47 @@ public static ControlsProfile loadProfile(Context context, InputStream inStream) if (name.equals("id")) { profileId = reader.nextInt(); - fieldsRead++; } else if (name.equals("name")) { profileName = reader.nextString(); - fieldsRead++; } else if (name.equals("cursorSpeed")) { cursorSpeed = (float) reader.nextDouble(); - fieldsRead++; + } + else if (name.equals("physicalStickDeadZone")) { + physicalStickDeadZone = (float) reader.nextDouble(); + } + else if (name.equals("physicalStickSensitivity")) { + physicalStickSensitivity = (float) reader.nextDouble(); + } + else if (name.equals("physicalDpadDeadZone")) { + physicalDpadDeadZone = (float) reader.nextDouble(); + } + else if (name.equals("virtualStickDeadZone")) { + virtualStickDeadZone = (float) reader.nextDouble(); + } + else if (name.equals("virtualStickSensitivity")) { + virtualStickSensitivity = (float) reader.nextDouble(); + } + else if (name.equals("virtualDpadDeadZone")) { + virtualDpadDeadZone = (float) reader.nextDouble(); + } + else if (name.equals("enableTapToClick")) { + enableTapToClick = reader.nextBoolean(); + } + else if (name.equals("enableTwoFingerRightClick")) { + enableTwoFingerRightClick = reader.nextBoolean(); + } + else if (name.equals("disableTouchpadMouse")) { + disableTouchpadMouse = reader.nextBoolean(); + } + else if (name.equals("touchscreenMode")) { + touchscreenMode = reader.nextBoolean(); + } + else if (name.equals("lockedToContainer")) { + lockedToContainer = reader.nextString(); } else { - if (fieldsRead == 3) break; reader.skipValue(); } } @@ -230,6 +563,17 @@ else if (name.equals("cursorSpeed")) { ControlsProfile profile = new ControlsProfile(context, profileId); profile.setName(profileName); profile.setCursorSpeed(cursorSpeed); + profile.setPhysicalStickDeadZone(physicalStickDeadZone); + profile.setPhysicalStickSensitivity(physicalStickSensitivity); + profile.setPhysicalDpadDeadZone(physicalDpadDeadZone); + profile.setVirtualStickDeadZone(virtualStickDeadZone); + profile.setVirtualStickSensitivity(virtualStickSensitivity); + profile.setVirtualDpadDeadZone(virtualDpadDeadZone); + profile.setEnableTapToClick(enableTapToClick); + profile.setEnableTwoFingerRightClick(enableTwoFingerRightClick); + profile.setDisableTouchpadMouse(disableTouchpadMouse); + profile.setTouchscreenMode(touchscreenMode); + profile.setLockedToContainer(lockedToContainer); return profile; } catch (IOException e) { diff --git a/app/src/main/java/com/winlator/widget/InputControlsView.java b/app/src/main/java/com/winlator/widget/InputControlsView.java index 3436078e6..855129177 100644 --- a/app/src/main/java/com/winlator/widget/InputControlsView.java +++ b/app/src/main/java/com/winlator/widget/InputControlsView.java @@ -207,6 +207,10 @@ public synchronized ControlsProfile getProfile() { public synchronized void setProfile(ControlsProfile profile) { if (profile != null) { + // If switching to a different profile, force reload from disk + if (this.profile != profile && profile.isElementsLoaded()) { + profile.reloadElements(this); + } this.profile = profile; deselectAllElements(); } @@ -273,8 +277,10 @@ public int getMaxWidth() { @Override protected void onDetachedFromWindow() { - if (mouseMoveTimer != null) + if (mouseMoveTimer != null) { mouseMoveTimer.cancel(); + mouseMoveTimer = null; + } super.onDetachedFromWindow(); } @@ -295,13 +301,16 @@ public void run() { } } - private void processJoystickInput(ExternalController controller) { + private void processJoystickInput(ExternalController controller, float stickDeadZone, float dpadDeadZone) { ExternalControllerBinding controllerBinding; final int[] axes = {MotionEvent.AXIS_X, MotionEvent.AXIS_Y, MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ, MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y}; final float[] values = {controller.state.thumbLX, controller.state.thumbLY, controller.state.thumbRX, controller.state.thumbRY, controller.state.getDPadX(), controller.state.getDPadY()}; for (byte i = 0; i < axes.length; i++) { - if (Math.abs(values[i]) > ControlElement.STICK_DEAD_ZONE) { + // Use appropriate dead zone based on axis type (d-pad vs stick) + float deadZone = (axes[i] == MotionEvent.AXIS_HAT_X || axes[i] == MotionEvent.AXIS_HAT_Y) ? dpadDeadZone : stickDeadZone; + + if (Math.abs(values[i]) > deadZone) { controllerBinding = controller.getControllerBinding(ExternalControllerBinding.getKeyCodeForAxis(axes[i], Mathf.sign(values[i]))); if (controllerBinding != null) handleInputEvent(controllerBinding.getBinding(), true, values[i]); } @@ -318,16 +327,22 @@ private void processJoystickInput(ExternalController controller) { public boolean onGenericMotionEvent(MotionEvent event) { if (!editMode && profile != null) { ExternalController controller = profile.getController(event.getDeviceId()); - if (controller != null && controller.updateStateFromMotionEvent(event)) { - ExternalControllerBinding controllerBinding; - controllerBinding = controller.getControllerBinding(KeyEvent.KEYCODE_BUTTON_L2); - if (controllerBinding != null) handleInputEvent(controllerBinding.getBinding(), controller.state.isPressed(ExternalController.IDX_BUTTON_L2)); + if (controller != null) { + float stickDeadZone = profile.getPhysicalStickDeadZone(); + float stickSensitivity = profile.getPhysicalStickSensitivity(); + float dpadDeadZone = profile.getPhysicalDpadDeadZone(); - controllerBinding = controller.getControllerBinding(KeyEvent.KEYCODE_BUTTON_R2); - if (controllerBinding != null) handleInputEvent(controllerBinding.getBinding(), controller.state.isPressed(ExternalController.IDX_BUTTON_R2)); + if (controller.updateStateFromMotionEvent(event, stickDeadZone, stickSensitivity)) { + ExternalControllerBinding controllerBinding; + controllerBinding = controller.getControllerBinding(KeyEvent.KEYCODE_BUTTON_L2); + if (controllerBinding != null) handleInputEvent(controllerBinding.getBinding(), controller.state.isPressed(ExternalController.IDX_BUTTON_L2)); - processJoystickInput(controller); - return true; + controllerBinding = controller.getControllerBinding(KeyEvent.KEYCODE_BUTTON_R2); + if (controllerBinding != null) handleInputEvent(controllerBinding.getBinding(), controller.state.isPressed(ExternalController.IDX_BUTTON_R2)); + + processJoystickInput(controller, stickDeadZone, dpadDeadZone); + return true; + } } } return super.onGenericMotionEvent(event); diff --git a/app/src/main/java/com/winlator/winhandler/WinHandler.java b/app/src/main/java/com/winlator/winhandler/WinHandler.java index c869c32b2..823e83848 100644 --- a/app/src/main/java/com/winlator/winhandler/WinHandler.java +++ b/app/src/main/java/com/winlator/winhandler/WinHandler.java @@ -707,7 +707,11 @@ public boolean onGenericMotionEvent(MotionEvent event) { Timber.d("WinHandler.onGenericMotionEvent: adopted controller %s(#%d)", adopted.getName(), adopted.getDeviceId()); } } - if (externalController != null && externalController.getDeviceId() == event.getDeviceId() && (handled = this.currentController.updateStateFromMotionEvent(event))) { + if (externalController != null && externalController.getDeviceId() == event.getDeviceId()) { + ControlsProfile profile = inputControlsView != null ? inputControlsView.getProfile() : null; + float stickDeadZone = profile != null ? profile.getPhysicalStickDeadZone() : ExternalController.STICK_DEAD_ZONE; + float stickSensitivity = profile != null ? profile.getPhysicalStickSensitivity() : ExternalController.STICK_SENSITIVITY; + handled = this.currentController.updateStateFromMotionEvent(event, stickDeadZone, stickSensitivity); if (handled) { sendGamepadState(); sendMemoryFileState(); @@ -737,10 +741,12 @@ public boolean onKeyEvent(KeyEvent event) { if (externalController != null && externalController.getDeviceId() == event.getDeviceId() && event.getRepeatCount() == 0) { int action = event.getAction(); + ControlsProfile profile = inputControlsView != null ? inputControlsView.getProfile() : null; + float stickDeadZone = profile != null ? profile.getPhysicalStickDeadZone() : ExternalController.STICK_DEAD_ZONE; if (action == KeyEvent.ACTION_DOWN) { - handled = this.currentController.updateStateFromKeyEvent(event); + handled = this.currentController.updateStateFromKeyEvent(event, stickDeadZone); } else if (action == KeyEvent.ACTION_UP) { - handled = this.currentController.updateStateFromKeyEvent(event); + handled = this.currentController.updateStateFromKeyEvent(event, stickDeadZone); } sendMemoryFileState(this.currentController, buffer); if (handled) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 35791898e..5d350a9b0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -148,7 +148,10 @@ Exit Game Keyboard On-screen Controller + Edit Controls + Manage Profiles Touchpad Help + Change Profile Failed to save logcat to destination