diff --git a/CLAUDE.md b/CLAUDE.md index a67219f..1860ca2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,16 +9,39 @@ - Follow current Android, Kotlin, and Compose conventions - Boy Scout Rule: leave code better than you found it - One class per file -- Code should read like well-written prose +- Names should make code read like well-written prose (comments do not — see below) - Methods should be short enough that they explain themselves - Methods and composables should be reusable like components + ## Comments -- Default to **no comment**: a well-named class or method is its own documentation -- Never write a comment that restates the code, the signature, or what the next line does — if a comment can be made redundant by renaming or extracting, do that instead -- A genuine "why" (a constraint the code cannot express, e.g. "StateFlow conflation requires a synchronous callback") gets one or two lines, never a paragraph -- New classes get **no KDoc by default**; earn it only with a non-obvious "why" -- Comments describing the physical world ARE welcome and can be detailed: BLE protocol details, byte layouts, timing constraints, and hardware behavior being mirrored (e.g. Switch combo/LED conventions) — these cannot be derived from code -- Litmus test before writing any comment: "could a reader reconstruct this from the code alone?" If yes, delete it +- Default to **no comment**. A name is the documentation; if renaming or extracting would make a + comment redundant, do that instead +- A comment says only what the code cannot: a constraint, a unit, a byte's meaning, what a + workaround works around. Litmus test: "could a reader reconstruct this from the code?" If yes, delete it +- **Plain and terse.** State the fact like a spec sheet, not a story: no narrative, aphorism or + flourish. `// Dolphin ORs its inputs, so any bound source fires the target.` +- **One line is the norm, three the ceiling.** Longer means it is explaining the subject rather + than the line — a derivation, a measurement, a byte layout, an emulator's internals, approaches + that failed. That goes in the doc that owns it, with a one-line pointer: + `/** Axes and signs: docs/dsu-motion.md#motion-frame */` +- **No KDoc on classes, interfaces, use cases or properties by default** — remove one that restates + the name when you touch the file. Keep it only for a non-obvious "why" +- **One home per fact.** Never repeat a doc in a comment, or the same comment in sibling files (two + emulator generators, two ViewModels): point at the doc, or put it on the shared type. Copies drift +- Physical-world facts (BLE protocol, byte layouts, timing, measurements, emulator internals) live in + `protocol.md`, `virtual-gamepad.md`, `dsu-motion.md`; UI decisions in `DESIGN.md`; structure in + `architecture.md` +- Tests: say it in the test name. An inline comment only labels a magic value (`// slot`, `// BUTTON_A 96`) + +## Docs +- Written for someone about to change the code: what is true now, and why. No changelogs, "was X + until Y" stories or ticked-off to-do lists — git holds history +- Lead with the fact. Tables and short bullets over paragraphs; cut preamble and restatement +- Keep a failed approach only when it stops someone retrying it, in a sentence or two +- Date a measurement and name the hardware it came from; nothing else needs a date +- **Docs are part of the change.** When behaviour a doc or the README describes changes, update it + in the same commit +- `README.md` is for players (setup, troubleshooting); implementation detail goes in `docs/` ## SOLID Principles - **Single Responsibility:** each class has one reason to change — if you need "Manager" or "Handler" in the name, it's probably doing too much @@ -49,7 +72,13 @@ ## Conventions - Use `enableEdgeToEdge()` with `WindowInsets.systemBars` for edge-to-edge inset handling -- Avoid hard-coded strings — use string resources where possible +- **User-facing strings never live in a domain module** (pure JVM, no `R.string`). A domain type + carries its identity — the enum entry, the stored id — and presentation names it through an + exhaustive `when`, so a new case without a word fails to build (`MappingLabels`, `LayoutLabels`) +- Not copy, so they stay in code: **wire tokens** an emulator or protocol must match exactly + (`DolphinControls`, `EdenControls`), and **hardware markings** the controller graphics draw + (`JoyconButton.label` — "ZL", "+", "A") +- Avoid hard-coded strings elsewhere too — use string resources where possible - Use theme for dimensions and colors rather than inline literals - Prefer immutable data classes for state - Use `@SuppressLint("MissingPermission")` only on methods guarded by the permission launcher diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 37aa493..00a51cd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,39 +1,25 @@ # Contributing to Joycon2Android -Thanks for your interest in improving Joycon2Android! This guide covers how to get set up, the -standards your change is expected to meet, and how to get it merged. +Thanks for helping improve Joycon2Android! This covers setup, the standards a change must meet, and +how to get it merged. ## Coding standards -**[`CLAUDE.md`](CLAUDE.md) is the project's coding-standards contract, and it applies to everyone — -human or AI.** Read it before writing code. In short, it asks for: - -- **Kotlin, Jetpack Compose, Material 3**, targeting Android API 24+. -- **Clean, self-documenting code** — short, well-named methods; one class per file; reusable - composables. Prefer renaming or extracting over adding a comment. -- **Minimal comments** — no KDoc that restates the signature. A comment earns its place only when - it captures a *why* the code can't express. Comments describing hardware/BLE protocol details - (byte layouts, timing, Switch conventions) are welcome and can be detailed. -- **SOLID and clean architecture** — small focused classes, dependencies injected via constructors, - orchestrators that delegate rather than implement. -- **No hard-coded strings** — use string resources. **No inline dimensions/colors** — use the - theme. -- Immutable data classes for state; `@SuppressLint("MissingPermission")` only on permission-guarded - methods. +**[`CLAUDE.md`](CLAUDE.md) is the coding-standards contract for everyone, human or AI.** Read it +before writing code. In short: -## Architecture - -This is a **Gradle multi-module** app split by **feature × layer** (`domain` / `data` / -`presentation`), and the module graph *enforces* the dependency rules at compile time. Before -making structural changes: +- **Self-documenting code** — short, well-named methods, one class per file, reusable composables. +- **Minimal, terse comments** — only what the code can't say. Protocol details, measurements and + emulator internals go in [`docs/`](docs/README.md), not in comments. +- **SOLID and clean architecture** — small focused classes, constructor injection, orchestrators that + delegate. +- **No hard-coded strings, dimensions or colours** — string resources and the theme. -- Read [`docs/architecture.md`](docs/architecture.md) — the module graph, layers, and dependency - rules. -- Follow [`docs/adding-a-feature.md`](docs/adding-a-feature.md) when adding or changing a feature — - it has step-by-step recipes and explains the convention plugins in `build-logic/`. +## Architecture -Presentation reaches data **only through use cases** — never bypass it. Every module applies one of -the convention plugins; don't hand-roll `android {}` blocks in a module. +A Gradle multi-module app split by feature × layer, whose module graph enforces the dependency rules. +Read [`docs/architecture.md`](docs/architecture.md) before structural changes, and follow +[`docs/adding-a-feature.md`](docs/adding-a-feature.md) when adding or changing a feature. ## Development setup @@ -58,13 +44,8 @@ Run the same checks CI runs, and make sure they pass: ./gradlew build :konsist:test ``` -This compiles every module, runs the unit tests, runs Android lint, and runs the **Konsist** -architecture tests that enforce layer placement. If you moved classes between modules, `:konsist:test` -is what catches a misplacement. - -- **Add tests** for new domain/data logic where practical — the layering exists so each class is - independently testable. -- **Leave the code better than you found it** (the Boy Scout Rule). +That compiles every module and runs the unit tests, Android lint and the Konsist architecture tests. +Add tests for new domain and data logic, and update any doc your change makes stale. ## Pull requests diff --git a/README.md b/README.md index 016e29f..9dbfcd1 100644 --- a/README.md +++ b/README.md @@ -87,9 +87,31 @@ the emulator afterwards — it only reads its config when it starts. | Virtual Gamepad | Eden, Eden Nightly, Dolphin (GameCube) | buttons and sticks | | DSU Motion Server | Eden, Eden Nightly, Dolphin (Wii) | buttons, sticks and motion | -The gamepad button beside **Set up** opens the mapping editor. A single Joy-Con is set up as a Pro -Controller held sideways, so every button works in every game -([why](docs/virtual-gamepad.md#why-theyre-set-up-as-pro-controllers)). +The gamepad button beside **Set up** opens the mapping editor, with a card per connected player — +tap one to open its bindings. A single Joy-Con is set up as a Pro Controller held sideways, so every +button works in every game ([why](docs/virtual-gamepad.md#why-theyre-set-up-as-pro-controllers)). A +target can take **several sources** — tick as many as you like, and any of them fires it. + +- **Layouts.** Each player picks a layout to start from, which resets their changes. The card reads + **Custom** once you change a binding, and the layout's name again if you change it back. +- **Saving.** The save icon beside the name keeps your mapping as a layout for any player holding the + same body. It dims when the mapping already matches a layout. Deleting a layout (the bin in the + dropdown) keeps everyone's buttons; they just read **Custom** until you save again. +- **All players** at the top sets and saves everyone at once. A grip only some bodies fit — + **Mario Kart Wheel**, say — gives the rest the same game's other grip. A saved set remembers who + held which body ("P1 L, P2 R, P3 L/R") and stays greyed out until those players are back. +- **Sideways Wii Remote.** On the Wii console, a lone Joy-Con also gets this switch, for games that + steer by tilting a sideways remote. On, up on the stick is towards the rail; off, towards L or R. + The layout sets it (on for Mario Kart) and you can override it until you next pick a layout. + +The layouts the app ships: + +| Layout | For | +|---|---| +| Wii | The Wii Remote's own arrangement: the trigger under your finger is B, 1 and 2 under the thumb | +| Joy-Con | The same, with the Joy-Con's own B as B, and 1 and 2 on the shoulders so your thumb stays on the stick | +| Mario Kart Wheel | A lone Joy-Con held sideways as a wheel, laid out the way Mario Kart 8 uses one — 2 accelerates, 1 brakes, SR hops and tricks, and SL throws an item alongside the stick. Steers by **tilt**, so it plays as a sideways Wii Remote: the d-pad turns with it and a right Joy-Con aims from its tail | +| Mario Kart Nunchuck | The remote-and-nunchuk scheme, which steers by **stick** instead. A pair splits the halves across the hands, each index finger on the shoulder its controller keeps a trigger on; a lone Joy-Con plays both halves itself, its own stick standing in for the Nunchuk's and its rails carrying C and Z | > [!NOTE] > Auto setup needs Shizuku, and some devices block writing into another app's `Android/data` — use @@ -155,9 +177,30 @@ shoulder buttons. | Gyroscope Roll Left / Right | `Gyro Roll Left` / `Right` | `Gyro Pitch Up` / `Down` | `Gyro Pitch Down` / `Up` | | Gyroscope Yaw Left / Right | `Gyro Yaw Left` / `Right` | same | same | + That restores each Joy-Con's own body, which is what **pointing** wants: the remote's nose is the + shoulder edge, so aim R/ZR (or L/ZL) at the screen. Also set **Total Yaw** to around 60 — + Dolphin's 25 clamps the cursor after ±12.5° of turn, which a hand-held aim overruns. + + **For a game written for a sideways Wii Remote** (Mario Kart Wii and its Wii Wheel) — both + **Mario Kart** layouts write these for you: + + - Give a **right** Joy-Con the *left* column. Sideways, its top edge points where a sideways + remote's tail does, so without that half turn the wheel steers backwards; a left Joy-Con + already matches. Aiming then moves to the tail on that body. + - **Turn the four D-pad bindings a quarter**: put the source you'd bind to Up on **D-Pad/Right**, + Right on Down, Down on Left, Left on Up. A sideways remote's d-pad turns with it, so the + player's up is the remote's right. + - For **tricks and wheelies**, append + ``+ pulse(, 0.6) * max(sin(timer(0.15) * 6.2832), 0) * 50`` to **IMUAccelerometer/Up**, + where `` is ``(\`Gyro Pitch Up\` / 9) & not(pulse(\`Gyro Pitch Down\` / 9, 0.4))`` — + then the same on **/Down** with Up and Down swapped + ([why](docs/dsu-motion.md#tricks-and-wheelies)). Dolphin's own **Shake** group doesn't land + tricks, but you can bind **Shake** to a button. + 5. Under **Swing**, set **Forward** to ``(`Accel Forward` - `Accel Backward`) - smooth((`Accel Forward` - `Accel Backward`), 0.03)``, - **Range** to 7% and **Dead Zone** to 20%. For a single Joy-Con, use `Accel Up` and `Accel Down`. + **Range** to 7% and **Dead Zone** to 20%. For a single Joy-Con use whichever pair its nose reads + above — `Accel Right`/`Left` on a right Joy-Con, and the other way round when it plays sideways. This lets thrusts reach games like Wii Play Billiards ([why](docs/dsu-motion.md#dolphin-wii-remote-mapping)). 6. **Pair only:** under **Nunchuk → Motion Input**, map each accelerometer entry to the left Joy-Con's slot, e.g. `` `DSUClient/3/Joycon2:Accel Up` `` — the highest slot no player uses (3 with one diff --git a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt index a9d8b4e..f5e5cbd 100644 --- a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt +++ b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt @@ -12,12 +12,30 @@ import com.joegec.joycon2android.connection.StartScanUseCase import com.joegec.joycon2android.connection.StopScanUseCase import com.joegec.joycon2android.connection.ViewModePreferences import com.joegec.joycon2android.connection.ViewModePreferencesDataStore +import com.joegec.joycon2android.buttonmapping.ApplyGlobalLayoutUseCase +import com.joegec.joycon2android.buttonmapping.ApplyMappingLayoutUseCase import com.joegec.joycon2android.buttonmapping.ControllerMappingDataStore import com.joegec.joycon2android.buttonmapping.ControllerMappingRepository +import com.joegec.joycon2android.buttonmapping.DeleteCustomLayoutUseCase +import com.joegec.joycon2android.buttonmapping.DeleteGlobalLayoutUseCase import com.joegec.joycon2android.buttonmapping.GetEffectiveControllerMappingUseCase +import com.joegec.joycon2android.buttonmapping.GetSidewaysRemoteUseCase +import com.joegec.joycon2android.buttonmapping.GlobalLayoutDataStore +import com.joegec.joycon2android.buttonmapping.GlobalLayoutRepository +import com.joegec.joycon2android.buttonmapping.ApplyPlayerMappingUseCase import com.joegec.joycon2android.buttonmapping.ObserveControllerMappingUseCase -import com.joegec.joycon2android.buttonmapping.ResetControllerMappingUseCase +import com.joegec.joycon2android.buttonmapping.ObserveGlobalMappingUseCase +import com.joegec.joycon2android.buttonmapping.ObserveSavedLayoutsUseCase +import com.joegec.joycon2android.buttonmapping.ObservePlayerMappingUseCase +import com.joegec.joycon2android.buttonmapping.ObserveSidewaysRemoteUseCase +import com.joegec.joycon2android.buttonmapping.SaveCustomLayoutUseCase +import com.joegec.joycon2android.buttonmapping.SaveGlobalLayoutUseCase +import com.joegec.joycon2android.buttonmapping.SavedLayoutDataStore +import com.joegec.joycon2android.buttonmapping.SavedLayoutRepository import com.joegec.joycon2android.buttonmapping.SetControllerMappingUseCase +import com.joegec.joycon2android.buttonmapping.SetSidewaysRemoteUseCase +import com.joegec.joycon2android.buttonmapping.SidewaysRemoteDataStore +import com.joegec.joycon2android.buttonmapping.SidewaysRemoteRepository import com.joegec.joycon2android.assignment.AssignmentRepository import com.joegec.joycon2android.assignment.ComboAssignmentDetector import com.joegec.joycon2android.assignment.PlayerAssignmentManager @@ -71,12 +89,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.map import java.io.File -/** - * Composition root: owns app-scoped repositories (data) and binds them to use cases - * (domain). Presentation reaches data only through these use cases. Held by - * [JoyconApplication] so the servers/connections outlive any single Activity or the - * foreground service. - */ +/** Composition root: docs/architecture.md#composition-root--appcontainer */ class AppContainer(context: Context) { private val appContext = context.applicationContext @@ -97,10 +110,31 @@ class AppContainer(context: Context) { // --- Controller button mapping (shared by Gamepad and DSU) --- private val controllerMappingRepository: ControllerMappingRepository = ControllerMappingDataStore(appContext) - val observeControllerMapping = ObserveControllerMappingUseCase(controllerMappingRepository) + private val savedLayoutRepository: SavedLayoutRepository = SavedLayoutDataStore(appContext) + private val globalLayoutRepository: GlobalLayoutRepository = GlobalLayoutDataStore(appContext) + private val sidewaysRemoteRepository: SidewaysRemoteRepository = SidewaysRemoteDataStore(appContext) + + private val observeControllerMapping = ObserveControllerMappingUseCase(controllerMappingRepository) + private val observeSidewaysRemote = ObserveSidewaysRemoteUseCase(sidewaysRemoteRepository) + private val observePlayerMapping = + ObservePlayerMappingUseCase(observeControllerMapping, observeSidewaysRemote, savedLayoutRepository) + private val applyPlayerMapping = + ApplyPlayerMappingUseCase(controllerMappingRepository, sidewaysRemoteRepository) + + val observeGlobalMapping = ObserveGlobalMappingUseCase(observePlayerMapping, globalLayoutRepository) + val observeSavedLayouts = ObserveSavedLayoutsUseCase(savedLayoutRepository) val setControllerMapping = SetControllerMappingUseCase(controllerMappingRepository) - val resetControllerMapping = ResetControllerMappingUseCase(controllerMappingRepository) + val setSidewaysRemote = SetSidewaysRemoteUseCase(sidewaysRemoteRepository) + val applyMappingLayout = ApplyMappingLayoutUseCase(savedLayoutRepository, applyPlayerMapping) + val applyGlobalLayout = + ApplyGlobalLayoutUseCase(globalLayoutRepository, applyMappingLayout, applyPlayerMapping) + val saveCustomLayout = SaveCustomLayoutUseCase(savedLayoutRepository, observePlayerMapping) + val saveGlobalLayout = SaveGlobalLayoutUseCase(globalLayoutRepository, observePlayerMapping) + val deleteCustomLayout = DeleteCustomLayoutUseCase(savedLayoutRepository) + val deleteGlobalLayout = DeleteGlobalLayoutUseCase(globalLayoutRepository) + private val getControllerMapping = GetEffectiveControllerMappingUseCase(observeControllerMapping) + private val getSidewaysRemote = GetSidewaysRemoteUseCase(observeSidewaysRemote) // --- DSU --- private val dsuRepository: DsuRepository = DsuServer(scope) @@ -115,8 +149,6 @@ class AppContainer(context: Context) { val setBlockDeviceMotion = SetBlockDeviceMotionUseCase(dsuMotionSettings) // --- Assignment --- - // Cross-feature orchestration that reacts to assignment (gamepad/DSU lifecycle) lives in - // the SessionCoordinator below. val assignmentRepository: AssignmentRepository = PlayerAssignmentManager() // --- Gamepad + privileged access --- @@ -140,6 +172,7 @@ class AppContainer(context: Context) { gamepadDevices = { edenGamepads(appContext) }, gamepadControllerNumbers = { dolphinGamepadIds(appContext) }, getControllerMapping = getControllerMapping, + getSidewaysRemote = getSidewaysRemote, ) val emulatorLauncher = EmulatorLauncher(appContext) @@ -185,7 +218,6 @@ class AppContainer(context: Context) { val assignController = AssignControllerUseCase(sessionCoordinator) val unassignController = UnassignControllerUseCase(sessionCoordinator) - /** Cross-feature shutdown: stop every output and clear assignments/connections. */ fun disconnectAll() { disableGamepad() disableDsu() diff --git a/app/src/main/java/com/joegec/joycon2android/DsuMotionPolicy.kt b/app/src/main/java/com/joegec/joycon2android/DsuMotionPolicy.kt index fa0a3b1..9946589 100644 --- a/app/src/main/java/com/joegec/joycon2android/DsuMotionPolicy.kt +++ b/app/src/main/java/com/joegec/joycon2android/DsuMotionPolicy.kt @@ -9,11 +9,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch -/** - * Applies the DSU motion settings only while DSU runs, so neither the battery cost nor the - * sensor block lingers once it stops. DSU starts off, so launch also clears a block left behind - * by a process that was killed before it could lift it. - */ +/** Motion settings apply only while DSU runs: docs/dsu-motion.md#eden-reads-the-devices-own-motion */ class DsuMotionPolicy( private val scope: CoroutineScope, private val dsuEnabled: Flow, @@ -30,7 +26,7 @@ class DsuMotionPolicy( .collect { setHighConnectionPriority(it) } } scope.launch { - // Re-applied when the shell comes back, since the block can only be set through it. + // The block can only be set through the shell, so re-apply when it returns. combine(dsuEnabled, settings, privilegedShellAvailable) { dsuOn, current, shellUp -> (dsuOn && current.blockDeviceMotion) to shellUp } diff --git a/app/src/main/java/com/joegec/joycon2android/MainActivity.kt b/app/src/main/java/com/joegec/joycon2android/MainActivity.kt index ca9a513..c035ea3 100644 --- a/app/src/main/java/com/joegec/joycon2android/MainActivity.kt +++ b/app/src/main/java/com/joegec/joycon2android/MainActivity.kt @@ -24,9 +24,10 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import com.joegec.joycon2android.buttonmapping.Console -import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.body import com.joegec.joycon2android.buttonmapping.presentation.ControllerMappingScreen import com.joegec.joycon2android.buttonmapping.presentation.ControllerMappingViewModel +import com.joegec.joycon2android.buttonmapping.presentation.MappingActions import com.joegec.joycon2android.dsu.presentation.DsuViewModel import com.joegec.joycon2android.gamepad.presentation.GamepadViewModel import com.joegec.joycon2android.ui.Joycon2ViewModel @@ -90,7 +91,18 @@ class MainActivity : ComponentActivity() { viewModelFactory { initializer { val c = (application as JoyconApplication).container - ControllerMappingViewModel(c.observeControllerMapping, c.setControllerMapping, c.resetControllerMapping) + ControllerMappingViewModel( + c.observeGlobalMapping, + c.observeSavedLayouts, + c.applyMappingLayout, + c.applyGlobalLayout, + c.setControllerMapping, + c.setSidewaysRemote, + c.saveCustomLayout, + c.saveGlobalLayout, + c.deleteCustomLayout, + c.deleteGlobalLayout, + ) } } } @@ -101,8 +113,7 @@ class MainActivity : ComponentActivity() { } override fun onCreate(savedInstanceState: Bundle?) { - // The UI is always dark, so force light bar icons rather than letting them follow the - // device's light/dark mode (which would render dark-on-dark on a light-mode device). + // The UI is always dark; following a light-mode device would draw bar icons dark-on-dark. enableEdgeToEdge( statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), @@ -181,23 +192,36 @@ class MainActivity : ComponentActivity() { @Composable private fun ControllerMappingRoute(console: Console, onBack: () -> Unit) { - val leftMapping by controllerMappingViewModel.mapping(console, JoyconSide.LEFT).collectAsState() - val rightMapping by controllerMappingViewModel.mapping(console, JoyconSide.RIGHT).collectAsState() - val dualMapping by controllerMappingViewModel.mapping(console, JoyconSide.DUAL).collectAsState() + val session by viewModel.uiState.collectAsState() + val players = session.activePlayers + val bodies = players.mapNotNull { it.body() } + val state by controllerMappingViewModel.uiState.collectAsState() - ControllerMappingScreen( - console = console, - leftMapping = leftMapping, - rightMapping = rightMapping, - dualMapping = dualMapping, - onSetMapping = { side, targetKey, sourceId -> - controllerMappingViewModel.setMapping(console, side, targetKey, sourceId) - }, - onResetMapping = { side -> controllerMappingViewModel.resetMapping(console, side) }, - onBack = onBack, - ) + LaunchedEffect(console, bodies) { controllerMappingViewModel.edit(console, bodies) } + + state?.let { + ControllerMappingScreen( + state = it, + players = players, + actions = mappingActions, + onBack = onBack, + ) + } } + private val mappingActions = MappingActions( + selectLayout = { body, layoutId -> controllerMappingViewModel.selectLayout(body, layoutId) }, + selectGlobalLayout = { controllerMappingViewModel.selectGlobalLayout(it) }, + saveLayout = { body, name -> controllerMappingViewModel.saveLayout(body, name) }, + deleteLayout = { layoutId, global -> controllerMappingViewModel.deleteLayout(layoutId, global) }, + setMapping = { body, targetKey, sourceId -> + controllerMappingViewModel.setMapping(body, targetKey, sourceId) + }, + setSidewaysRemote = { body, enabled -> + controllerMappingViewModel.setSidewaysRemoteEnabled(body, enabled) + }, + ) + @Composable private fun MainRoute(onScan: () -> Unit, onOpenMapping: (Console) -> Unit) { val state by viewModel.uiState.collectAsState() @@ -216,8 +240,7 @@ class MainActivity : ComponentActivity() { val permissionDenied by viewModel.permissionDenied.collectAsState() val viewMode by viewModel.viewMode.collectAsState() - // A written emulator config is keyed to the current assignment; once it changes, - // the Done/Failed state is stale, so reset both setup buttons. + // A written config matches one assignment, so Done/Failed goes stale when it changes. val assignmentKey = state.players.map { Triple(it.player.index, it.left?.address, it.right?.address) } diff --git a/app/src/main/java/com/joegec/joycon2android/emulator/EdenDeviceMotionBlocker.kt b/app/src/main/java/com/joegec/joycon2android/emulator/EdenDeviceMotionBlocker.kt index e36e3ff..7901d54 100644 --- a/app/src/main/java/com/joegec/joycon2android/emulator/EdenDeviceMotionBlocker.kt +++ b/app/src/main/java/com/joegec/joycon2android/emulator/EdenDeviceMotionBlocker.kt @@ -8,11 +8,6 @@ import com.joegec.joycon2android.gamepad.privileged.PrivilegedShell import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -/** - * Marks Eden's uid idle in the sensor service, which withholds continuous sensors (gyro, - * accelerometer) from it as if it were in the background. The override lives in system_server - * until it is reset or the device reboots, so it outlives our process if we are killed. - */ class EdenDeviceMotionBlocker( private val readyShell: () -> PrivilegedShell?, ) : DeviceMotionBlocker { diff --git a/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorLauncher.kt b/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorLauncher.kt index e071d5a..3e3caa4 100644 --- a/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorLauncher.kt +++ b/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorLauncher.kt @@ -4,7 +4,6 @@ import android.content.Context import android.content.Intent import android.util.Log -/** Starts an installed emulator so it reloads the config auto setup just wrote. */ class EmulatorLauncher(context: Context) { private val appContext = context.applicationContext diff --git a/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorSetup.kt b/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorSetup.kt index 421f093..c1ace1a 100644 --- a/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorSetup.kt +++ b/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorSetup.kt @@ -4,7 +4,10 @@ import android.content.pm.PackageManager import android.util.Log import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.GetEffectiveControllerMappingUseCase -import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.GetSidewaysRemoteUseCase +import com.joegec.joycon2android.buttonmapping.PlayerBody +import com.joegec.joycon2android.buttonmapping.body +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import com.joegec.joycon2android.dsu.emulator.DolphinDsuConfig import com.joegec.joycon2android.dsu.emulator.DolphinWiimoteConfig import com.joegec.joycon2android.dsu.emulator.EdenDsuConfig @@ -25,12 +28,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull import kotlin.coroutines.resume -/** - * Writes emulator config to match the current player assignment, through the privileged shell - * (Shizuku / wireless debugging). Best-effort: returns false — and the UI falls back to manual - * setup — when no shell is available or the write can't be verified, since some OEM builds deny - * even the shell user access to another app's Android/data. - */ +/** Best-effort: some OEM builds deny even the shell user another app's `Android/data`. */ class EmulatorSetup( private val packageManager: PackageManager, private val acquireShell: (onResult: (PrivilegedShell?) -> Unit) -> Unit, @@ -38,13 +36,28 @@ class EmulatorSetup( private val gamepadDevices: () -> Map, private val gamepadControllerNumbers: () -> Map, private val getControllerMapping: GetEffectiveControllerMappingUseCase, + private val getSidewaysRemote: GetSidewaysRemoteUseCase, ) { - private suspend fun mappingLookup(console: Console): (JoyconSide) -> Map { - val bySide = JoyconSide.entries.associateWith { getControllerMapping(console, it) } - return { side -> bySide.getValue(side) } + // Read up front: the generators are synchronous. + private suspend fun mappingLookup( + console: Console, + players: List, + ): (PlayerBody) -> Map { + val stored = bodiesOf(players).associateWith { getControllerMapping(console, it) } + return { body -> stored[body] ?: MappingPresets.default(console).entries(body.side) } + } + + private suspend fun sidewaysRemoteLookup( + console: Console, + players: List, + ): (PlayerBody) -> Boolean { + val stored = bodiesOf(players).associateWith { getSidewaysRemote(console, it) } + return { body -> stored[body] ?: false } } - /** Installed emulators whose controller mapping the Virtual Gamepad can configure. */ + + private fun bodiesOf(players: List) = players.mapNotNull { it.body() }.distinct() + fun gamepadEmulators(): List = buildList { if (isInstalled(DolphinPaths.PACKAGE)) { add(EmulatorOption(DolphinPaths.PACKAGE, "Dolphin (GameCube)")) @@ -52,7 +65,6 @@ class EmulatorSetup( addAll(edenOptions()) } - /** Installed emulators whose motion input the DSU server can configure. */ fun dsuEmulators(): List = buildList { if (isInstalled(DolphinPaths.PACKAGE)) { add(EmulatorOption(DolphinPaths.PACKAGE, "Dolphin (Wii)")) @@ -72,7 +84,6 @@ class EmulatorSetup( private fun isInstalled(pkg: String) = runCatching { packageManager.getPackageInfo(pkg, 0) }.isSuccess - /** Motion input for the selected emulator (DSU card). */ suspend fun configureDsu( emulatorId: String, players: List, @@ -94,7 +105,7 @@ class EmulatorSetup( val path = EdenDsuConfig.path(emulatorId) val written = shell.writeText( path, - EdenDsuConfig.merge(shell.readText(path), players, mappingLookup(Console.SWITCH_PRO)), + EdenDsuConfig.merge(shell.readText(path), players, mappingLookup(Console.SWITCH_PRO, players)), ) if (written) EmulatorSetupResult.SUCCESS else EmulatorSetupResult.FAILED } @@ -118,14 +129,14 @@ class EmulatorSetup( DolphinWiimoteConfig.merge( shell.readText(DolphinWiimoteConfig.path), players, - mappingLookup(Console.WIIMOTE_NUNCHUK), + sidewaysRemoteLookup(Console.WIIMOTE_NUNCHUK, players), + mappingLookup(Console.WIIMOTE_NUNCHUK, players), ), ) if (dsuOk && wiimoteOk) EmulatorSetupResult.SUCCESS else EmulatorSetupResult.FAILED } - /** Controller mapping for the selected emulator (Gamepad card). */ suspend fun configureGamepad( emulatorId: String, players: List, @@ -143,7 +154,7 @@ class EmulatorSetup( shell.readText(path), players, gamepadDevices(), - mappingLookup(Console.SWITCH_PRO), + mappingLookup(Console.SWITCH_PRO, players), ), ) } else { @@ -151,10 +162,10 @@ class EmulatorSetup( shell.readText(DolphinGcpadConfig.path), players, gamepadControllerNumbers(), - mappingLookup(Console.GAMECUBE), + mappingLookup(Console.GAMECUBE, players), ) val mappingsOk = shell.writeText(DolphinGcpadConfig.path, mappings) - // Dolphin GC ports default to "None"; set them to Standard Controller + // Dolphin's GameCube ports default to None. val core = DolphinGcpadConfig.mergeCore(shell.readText(DolphinGcpadConfig.corePath), players) val coreOk = shell.writeText(DolphinGcpadConfig.corePath, core) mappingsOk && coreOk @@ -162,9 +173,8 @@ class EmulatorSetup( if (written) EmulatorSetupResult.SUCCESS else EmulatorSetupResult.FAILED } - // Shell reads/writes block on native binder/socket calls that coroutine cancellation can't - // interrupt, so run them on a scope that outlives the wait and abandon it on timeout — that - // way the "Setting up…" spinner always resolves instead of pinning if a call never returns. + // Shell calls block in native code that cancellation can't interrupt, so they run on an outer + // scope that is abandoned on timeout; otherwise a hung call pins the spinner. private suspend fun bounded(tag: String, block: suspend () -> EmulatorSetupResult): EmulatorSetupResult { val work = scope.async(Dispatchers.IO) { runCatching { block() } @@ -196,14 +206,8 @@ class EmulatorSetup( } /** - * Clears the way for a write, or reports that the caller must ask first. An emulator flushes its - * in-memory config over ours when it exits, so a write while one is loaded is lost with no error to - * report — the only reliable fix is to stop it first, which costs unsaved progress and therefore - * needs consent. Returns null once the way is clear. - * - * Android keeps a process cached long after the user leaves the app, and `pidof` cannot tell cached - * from running, so this asks whenever a process exists at all. Stopping a cached one costs nothing, - * and the confirmation covers the case where it is live. + * Null once the way is clear, else [EmulatorSetupResult.EMULATOR_RUNNING]. `pidof` can't tell a + * cached process from a live one, so any process at all asks first. */ private fun PrivilegedShell.settle(packageName: String, closeIt: Boolean): EmulatorSetupResult? { if (!hasProcess(packageName)) return null diff --git a/app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadIdentity.kt b/app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadIdentity.kt index ab2c043..ee28068 100644 --- a/app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadIdentity.kt +++ b/app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadIdentity.kt @@ -7,30 +7,7 @@ import android.view.KeyEvent import android.view.MotionEvent import com.joegec.joycon2android.gamepad.emulator.EdenGamepad -/* - * Resolves how each emulator identifies our virtual gamepads, by reading the live input-device - * list rather than deriving a number from the player index. - * - * Every emulator picks its own quantity, and none of them is the player number, so each rule is - * read from the emulator's own source and mirrored here: - * - * - **Dolphin** takes the id in its `Android//` qualifier from - * `InputDevice.getControllerNumber()` — Android's gamepad enumeration counter. - * `ControllerInterface::AddDevice` prefers `GetPreferredId()`, which the Android backend fills - * from `getControllerNumber()`, falling back to a duplicate-name index only for non-gamepads. - * - **Eden** (yuzu lineage) numbers `port` by walking `InputDevice.getDeviceIds()` and counting - * *every* physical game controller it passes, so any built-in pad shifts ours along. See - * `InputHandler.getDevices()`: a controller number already registered is skipped but still - * consumes a port, which edenGamepadPorts reproduces. - * - * A guessed number binds a config to the wrong device or to none, and any handheld with a built-in - * controller already occupies the low numbers — hence read, never derive. - * - * The same goes for the vendor/product ids behind Eden's `guid`: some handheld firmware re-publishes - * an external gamepad under the built-in controller's ids, leaving two devices with our name — so - * every field of a player's identity is taken from one and the same [InputDevice], the last match, - * which is the republished one where that happens. - */ +// Read from the live device list, never derived: docs/virtual-gamepad.md#device-identity private const val PREFIX = "Joy-Con Virtual Gamepad " diff --git a/app/src/main/java/com/joegec/joycon2android/service/Joycon2Service.kt b/app/src/main/java/com/joegec/joycon2android/service/Joycon2Service.kt index 423f845..42812c1 100644 --- a/app/src/main/java/com/joegec/joycon2android/service/Joycon2Service.kt +++ b/app/src/main/java/com/joegec/joycon2android/service/Joycon2Service.kt @@ -16,11 +16,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch -/** - * Keeps the app's BLE connections and outputs alive past the Activity: holds a wake lock - * and promotes to a foreground service (with notification) while a Joy-Con is connected. - * All app state lives in [AppContainer]; the Activity binds this only for that lifetime. - */ +/** Keeps BLE alive past the Activity. Holds no state: that lives in [AppContainer]. */ class Joycon2Service : Service() { inner class LocalBinder : Binder() { @@ -39,8 +35,7 @@ class Joycon2Service : Service() { super.onCreate() wakeLock = PartialWakeLock(this, WAKE_LOCK_TAG) wakeLock.acquire() - // Foreground (and its notification) only while a Joy-Con is actually connected; - // otherwise the bound Activity keeps us alive without a notification + // Foreground only while a Joy-Con is connected; otherwise the bound Activity keeps us alive. serviceScope.launch { container.observeSession().collect { updateForeground(it.anyConnected) } } diff --git a/app/src/main/java/com/joegec/joycon2android/ui/Joycon2ViewModel.kt b/app/src/main/java/com/joegec/joycon2android/ui/Joycon2ViewModel.kt index b6323a1..f29af5f 100644 --- a/app/src/main/java/com/joegec/joycon2android/ui/Joycon2ViewModel.kt +++ b/app/src/main/java/com/joegec/joycon2android/ui/Joycon2ViewModel.kt @@ -40,8 +40,7 @@ class Joycon2ViewModel(application: Application) : AndroidViewModel(application) val viewMode: StateFlow = container.observeViewMode() .stateIn(viewModelScope, SharingStarted.Eagerly, ConnectionViewMode.DETAILED) - // Bound only to keep the service (foreground lifetime) alive; all state is read from - // the app-scoped container, not the binder. + // Bound only for the service's lifetime; state comes from the container, not the binder. private val connection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { bound = true @@ -56,7 +55,6 @@ class Joycon2ViewModel(application: Application) : AndroidViewModel(application) if (permissionHandler.isGranted()) { startAndBind() } - // All state comes from the app-scoped container via its use cases, not the binder viewModelScope.launch { container.observeSession().collect { _uiState.value = it } } @@ -101,10 +99,6 @@ class Joycon2ViewModel(application: Application) : AndroidViewModel(application) viewModelScope.launch { container.setViewMode(mode) } } - /** - * Stops the service entirely — disconnects all devices and removes the notification. - * Called when the user explicitly wants to shut everything down. - */ fun stopService() { container.disconnectAll() val app = getApplication() @@ -114,8 +108,6 @@ class Joycon2ViewModel(application: Application) : AndroidViewModel(application) private fun startAndBind() { val app = getApplication() val intent = Intent(app, Joycon2Service::class.java) - // Bind only — the service promotes itself to foreground once a Joy-Con connects, - // so there's no notification while idle app.bindService(intent, connection, Context.BIND_AUTO_CREATE) } } diff --git a/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt b/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt index d815226..d41773b 100644 --- a/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt +++ b/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt @@ -105,19 +105,12 @@ import com.joegec.joycon2android.ui.theme.TextOnAccent import kotlinx.coroutines.launch import kotlin.math.roundToInt -// Material 3 small top-app-bar container height; the app bar overlays the content, so screens add -// this (plus the status-bar inset) as top clearance rather than the Scaffold reserving it. +// Material 3 small top app bar's container height. private val AppBarHeight = 64.dp -// Landscape packs two players per row, so each detailed controller is shrunk to help a full player -// fit the short landscape height. private const val LandscapePlayerScale = 0.7f -/** - * Lays the content out as if it had 1/[scale] the space, then draws it scaled down and reports the - * smaller size — shrinking the whole controller (buttons, labels, spacing) uniformly while still - * reflowing siblings, unlike a plain graphicsLayer scale which leaves the original bounds behind. - */ +/** Reports the scaled size, so siblings reflow — unlike graphicsLayer, which keeps the original bounds. */ private fun Modifier.scaleLayout(scale: Float): Modifier = layout { measurable, constraints -> fun up(value: Int) = (value / scale).roundToInt() val placeable = measurable.measure( @@ -176,8 +169,7 @@ fun JoyconScreen( val controllerRemovedMessage = stringResource(R.string.snackbar_controller_removed) val playerRemovedTemplate = stringResource(R.string.snackbar_player_removed) - // Unassigning is reachable by tapping the live display, so every removal is offered back as an - // undo (re-assigning the same controllers to the same player) rather than being silent. + // Unassigning is one tap on the live display, so every removal offers an undo. fun offerUndo(message: String, restore: List>) { scope.launch { val result = snackbarHostState.showSnackbar( @@ -226,9 +218,7 @@ fun JoyconScreen( } } }, - // Only reserve the horizontal insets: content passes under both the status bar (as the app - // bar collapses on scroll) and the nav bar. Each screen re-applies those where its own - // content must stay clear of the system bars. + // Edge-to-edge and the overlaid app bar: docs/DESIGN.md#connection-screen-chrome contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), ) { innerPadding -> val screenState = when { @@ -243,9 +233,6 @@ fun JoyconScreen( } } - // The app bar overlays the content instead of reserving space, so the scroll passes behind - // the transparent status bar; each screen adds the bar's height back as top clearance, and - // the bar itself is translated up in lockstep with the scroll so it slides away without a gap. val appBarSpace = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + AppBarHeight val appBarSpacePx = with(LocalDensity.current) { appBarSpace.toPx() } @@ -284,13 +271,12 @@ fun JoyconScreen( ) else -> ScanningContent(state) } - // Lets the last item scroll clear of the nav bar it now passes under + // Content passes under the nav bar, so the last item needs clearance. Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) } } } - // Overlaid so content scrolls behind it and the transparent status bar; collapses on scroll. TopAppBar( title = { AppTitle(state, shizukuAvailable) }, actions = { @@ -339,7 +325,6 @@ private fun AppTitle(state: AppUiState, shizukuAvailable: Boolean) { } } -// Shizuku is the privileged backend for the /dev/uhid access the gamepad needs. @Composable private fun PrivilegedAccessStatus(shizukuAvailable: Boolean) { val color = if (shizukuAvailable) Accent else TextDim @@ -415,8 +400,6 @@ private fun ScanningContent(state: AppUiState) { ErrorBox(text = state.error) } -// The "Looking for Joy-Con 2" card and the sync-button illustration: side by side in landscape, -// stacked in portrait. @Composable private fun ScanningGraphics(landscape: Boolean) { if (landscape) { @@ -551,8 +534,6 @@ private fun ConnectedContent( val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE if (landscape) { - // Two players per row to use the wide landscape space; detailed players are shrunk so a - // full controller is more likely to fit the short height, compact rows fit as-is. state.activePlayers.chunked(2).forEach { rowPlayers -> Row(horizontalArrangement = Arrangement.spacedBy(Dimens.sectionSpacing)) { rowPlayers.forEach { playerState -> @@ -646,8 +627,6 @@ private fun ConnectedContent( } if (landscape) { - // Two columns: the virtual gamepad and its Shizuku dependency on the left, DSU on the - // right — so the Shizuku card always sits directly under the gamepad it belongs to. Row(horizontalArrangement = Arrangement.spacedBy(Dimens.sectionSpacing)) { Column( Modifier.weight(1f), @@ -683,8 +662,6 @@ private fun ConnectedContent( ErrorBox(text = state.error) if (landscape) { - // Disconnect on the left, Scan on the right; Disconnect keeps its half when a scan is in - // progress and the Scan button drops out. Row(horizontalArrangement = Arrangement.spacedBy(Dimens.sectionSpacing)) { DisconnectAllButton(onDisconnectAll, Modifier.weight(1f)) if (!state.scanning) { diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt index 9537299..e923d2c 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt @@ -3,7 +3,6 @@ import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure -/** Adds Compose to an Android library module. */ class AndroidLibraryComposeConventionPlugin : Plugin { override fun apply(target: Project) = with(target) { pluginManager.apply("joycon.android.library") diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt index 09a4042..82e01eb 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -4,11 +4,7 @@ import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure -/** - * Android library module targeting minSdk 24 / Java 11, matching the app. AGP 9 provides - * Kotlin built-in, so the Kotlin plugin is not applied separately (doing so collides on - * the `kotlin` extension). - */ +/** AGP 9 has Kotlin built in; applying the Kotlin plugin as well collides on the `kotlin` extension. */ class AndroidLibraryConventionPlugin : Plugin { override fun apply(target: Project) = with(target) { pluginManager.apply("com.android.library") diff --git a/build-logic/convention/src/main/kotlin/KotlinJvmConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KotlinJvmConventionPlugin.kt index ee542f0..f4dc841 100644 --- a/build-logic/convention/src/main/kotlin/KotlinJvmConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KotlinJvmConventionPlugin.kt @@ -6,7 +6,6 @@ import org.gradle.kotlin.dsl.configure import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension -/** Pure-Kotlin module (domain / model) targeting the JVM at Java 11. */ class KotlinJvmConventionPlugin : Plugin { override fun apply(target: Project) = with(target) { pluginManager.apply("org.jetbrains.kotlin.jvm") diff --git a/build.gradle.kts b/build.gradle.kts index b546c74..020183f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,3 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.compose) apply false diff --git a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ControllerMappingDataStore.kt b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ControllerMappingDataStore.kt index 7ac37e4..d1558c6 100644 --- a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ControllerMappingDataStore.kt +++ b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ControllerMappingDataStore.kt @@ -9,34 +9,43 @@ import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -private val Context.controllerMappingDataStore: DataStore by - preferencesDataStore(name = "controller_mapping") +private val Context.controllerMappingDataStore: DataStore by preferencesDataStore( + name = "controller_mapping", + produceMigrations = { listOf(PerPlayerMigration(::perPlayerMappingNames)) }, +) + +// A stored override used to be keyed CONSOLE|SIDE|TARGET, for every player at once. +private fun perPlayerMappingNames(legacyName: String): List? { + val (console, side, target) = segmentsOf(legacyName).takeIf { it.size == 3 } ?: return null + return everyPlayerKey(consoleNamed(console) ?: return null, sideNamed(side) ?: return null, target) +} class ControllerMappingDataStore(context: Context) : ControllerMappingRepository { private val dataStore = context.applicationContext.controllerMappingDataStore - override fun observe(console: Console, side: JoyconSide): Flow> = + override fun observe(console: Console, body: PlayerBody): Flow> = dataStore.data.map { prefs -> - val prefix = keyPrefix(console, side) + val prefix = keyPrefix(console, body) prefs.asMap().entries .filter { (key, _) -> key.name.startsWith(prefix) } .associate { (key, value) -> key.name.removePrefix(prefix) to value.toString() } } - override suspend fun set(console: Console, side: JoyconSide, targetKey: String, sourceId: String) { - dataStore.edit { it[preferenceKey(console, side, targetKey)] = sourceId } + override suspend fun set(console: Console, body: PlayerBody, targetKey: String, sourceId: String) { + dataStore.edit { it[preferenceKey(console, body, targetKey)] = sourceId } } - override suspend fun clear(console: Console, side: JoyconSide) { - val prefix = keyPrefix(console, side) + override suspend fun replace(console: Console, body: PlayerBody, entries: Map) { + val prefix = keyPrefix(console, body) dataStore.edit { prefs -> prefs.asMap().keys.filter { it.name.startsWith(prefix) }.forEach { prefs.remove(it) } + entries.forEach { (targetKey, sourceId) -> prefs[stringPreferencesKey(prefix + targetKey)] = sourceId } } } - private fun keyPrefix(console: Console, side: JoyconSide) = "${console.name}|${side.name}|" + private fun keyPrefix(console: Console, body: PlayerBody) = "${bodyKeyPrefix(console, body)}|" - private fun preferenceKey(console: Console, side: JoyconSide, targetKey: String) = - stringPreferencesKey(keyPrefix(console, side) + targetKey) + private fun preferenceKey(console: Console, body: PlayerBody, targetKey: String) = + stringPreferencesKey(keyPrefix(console, body) + targetKey) } diff --git a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayoutDataStore.kt b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayoutDataStore.kt new file mode 100644 index 0000000..001e958 --- /dev/null +++ b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayoutDataStore.kt @@ -0,0 +1,26 @@ +package com.joegec.joycon2android.buttonmapping + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.globalLayoutDataStore: DataStore by preferencesDataStore(name = "global_layouts") + +class GlobalLayoutDataStore(context: Context) : GlobalLayoutRepository { + + private val documents = JsonDocumentStore( + context.applicationContext.globalLayoutDataStore, + encode = GlobalLayout::toJson, + decode = ::globalLayoutOf, + ) + + override fun observe(): Flow> = + documents.observe().map { layouts -> layouts.sortedBy { it.name } } + + override suspend fun save(layout: GlobalLayout) = documents.save(layout.id, layout) + + override suspend fun delete(layoutId: String) = documents.delete(layoutId) +} diff --git a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JsonDocumentStore.kt b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JsonDocumentStore.kt new file mode 100644 index 0000000..fc02f28 --- /dev/null +++ b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JsonDocumentStore.kt @@ -0,0 +1,26 @@ +package com.joegec.joycon2android.buttonmapping + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +internal class JsonDocumentStore( + private val dataStore: DataStore, + private val encode: (T) -> String, + private val decode: (id: String, json: String) -> T?, +) { + fun observe(): Flow> = dataStore.data.map { prefs -> + prefs.asMap().entries.mapNotNull { (key, value) -> decode(key.name, value.toString()) } + } + + suspend fun save(id: String, document: T) { + dataStore.edit { it[stringPreferencesKey(id)] = encode(document) } + } + + suspend fun delete(id: String) { + dataStore.edit { it.remove(stringPreferencesKey(id)) } + } +} diff --git a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LayoutJson.kt b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LayoutJson.kt new file mode 100644 index 0000000..c6d970f --- /dev/null +++ b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LayoutJson.kt @@ -0,0 +1,68 @@ +package com.joegec.joycon2android.buttonmapping + +import com.joegec.joycon2android.model.PlayerNumber +import org.json.JSONArray +import org.json.JSONObject + +private const val NAME = "name" +private const val CONSOLE = "console" +private const val SIDE = "side" +private const val SIDEWAYS_REMOTE = "sidewaysRemote" +private const val BINDINGS = "bindings" +private const val BODIES = "bodies" +private const val PLAYER = "player" +private const val ENTRIES = "entries" + +internal fun SavedLayout.toJson(): String = JSONObject() + .put(NAME, name) + .put(CONSOLE, console.name) + .put(SIDE, side.name) + .put(SIDEWAYS_REMOTE, sidewaysRemote) + .put(BINDINGS, JSONObject(bindings)) + .toString() + +/** Null for a document this build can no longer read, so one bad entry can't take the list with it. */ +internal fun savedLayoutOf(id: String, json: String): SavedLayout? = runCatching { + val document = JSONObject(json) + SavedLayout( + id = id, + name = document.getString(NAME), + console = Console.valueOf(document.getString(CONSOLE)), + side = JoyconSide.valueOf(document.getString(SIDE)), + bindings = document.getJSONObject(BINDINGS).toStringMap(), + sidewaysRemote = document.optBoolean(SIDEWAYS_REMOTE), + ) +}.getOrNull() + +internal fun GlobalLayout.toJson(): String = JSONObject() + .put(NAME, name) + .put(CONSOLE, console.name) + .put(BODIES, JSONArray(bodies.map { it.toJson() })) + .toString() + +internal fun globalLayoutOf(id: String, json: String): GlobalLayout? = runCatching { + val document = JSONObject(json) + GlobalLayout( + id = id, + name = document.getString(NAME), + console = Console.valueOf(document.getString(CONSOLE)), + bodies = document.getJSONArray(BODIES).objects().map { it.toSnapshot() }, + ) +}.getOrNull() + +private fun PlayerLayoutSnapshot.toJson() = JSONObject() + .put(PLAYER, body.player.name) + .put(SIDE, body.side.name) + .put(SIDEWAYS_REMOTE, sidewaysRemote) + .put(ENTRIES, JSONObject(entries)) + +private fun JSONObject.toSnapshot() = PlayerLayoutSnapshot( + body = PlayerBody(PlayerNumber.valueOf(getString(PLAYER)), JoyconSide.valueOf(getString(SIDE))), + entries = getJSONObject(ENTRIES).toStringMap(), + sidewaysRemote = optBoolean(SIDEWAYS_REMOTE), +) + +private fun JSONObject.toStringMap(): Map = + keys().asSequence().associateWith { getString(it) } + +private fun JSONArray.objects(): List = (0 until length()).map { getJSONObject(it) } diff --git a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PerPlayerKeys.kt b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PerPlayerKeys.kt new file mode 100644 index 0000000..44b4560 --- /dev/null +++ b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PerPlayerKeys.kt @@ -0,0 +1,28 @@ +package com.joegec.joycon2android.buttonmapping + +import com.joegec.joycon2android.model.PlayerNumber + +private const val SEPARATOR = "|" + +internal fun bodyKeyPrefix(console: Console, body: PlayerBody) = + listOf(console.name, body.player.name, body.side.name).joinToString(SEPARATOR) + +internal fun segmentsOf(name: String) = name.split(SEPARATOR) + +internal fun consoleNamed(name: String) = Console.entries.firstOrNull { it.name == name } + +internal fun sideNamed(name: String) = JoyconSide.entries.firstOrNull { it.name == name } + +/** Every body a console-wide setting now belongs to, in the new key's shape. */ +internal fun everyBodyKey(console: Console, target: String? = null): List = + PlayerNumber.entries.flatMap { player -> + JoyconSide.entries.map { side -> + listOfNotNull(console.name, player.name, side.name, target).joinToString(SEPARATOR) + } + } + +/** Every player's copy of a setting that already names the body it belongs to. */ +internal fun everyPlayerKey(console: Console, side: JoyconSide, target: String): List = + PlayerNumber.entries.map { player -> + listOf(console.name, player.name, side.name, target).joinToString(SEPARATOR) + } diff --git a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PerPlayerMigration.kt b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PerPlayerMigration.kt new file mode 100644 index 0000000..12dcd31 --- /dev/null +++ b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PerPlayerMigration.kt @@ -0,0 +1,37 @@ +package com.joegec.joycon2android.buttonmapping + +import androidx.datastore.core.DataMigration +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey + +/** Hands an older build's console-wide value to every player, which is what it meant. */ +internal class PerPlayerMigration( + private val perPlayerNames: (legacyName: String) -> List?, +) : DataMigration { + + override suspend fun shouldMigrate(currentData: Preferences) = + currentData.asMap().keys.any { perPlayerNames(it.name) != null } + + override suspend fun migrate(currentData: Preferences): Preferences { + val migrated = currentData.toMutablePreferences() + currentData.asMap().forEach { (key, value) -> + val names = perPlayerNames(key.name) ?: return@forEach + names.forEach { name -> migrated.put(name, value) } + migrated.remove(key) + } + return migrated + } + + override suspend fun cleanUp() = Unit +} + +// Preferences keys carry their value type, and only these two are ever stored here. +@Suppress("UNCHECKED_CAST") +private fun MutablePreferences.put(name: String, value: Any) { + when (value) { + is String -> this[stringPreferencesKey(name)] = value + is Boolean -> this[booleanPreferencesKey(name)] = value + } +} diff --git a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayoutDataStore.kt b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayoutDataStore.kt new file mode 100644 index 0000000..a2b3ee3 --- /dev/null +++ b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayoutDataStore.kt @@ -0,0 +1,26 @@ +package com.joegec.joycon2android.buttonmapping + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.savedLayoutDataStore: DataStore by preferencesDataStore(name = "saved_layouts") + +class SavedLayoutDataStore(context: Context) : SavedLayoutRepository { + + private val documents = JsonDocumentStore( + context.applicationContext.savedLayoutDataStore, + encode = SavedLayout::toJson, + decode = ::savedLayoutOf, + ) + + override fun observe(): Flow> = + documents.observe().map { layouts -> layouts.sortedBy { it.name } } + + override suspend fun save(layout: SavedLayout) = documents.save(layout.id, layout) + + override suspend fun delete(layoutId: String) = documents.delete(layoutId) +} diff --git a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteDataStore.kt b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteDataStore.kt new file mode 100644 index 0000000..3dfd302 --- /dev/null +++ b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteDataStore.kt @@ -0,0 +1,36 @@ +package com.joegec.joycon2android.buttonmapping + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.sidewaysRemoteDataStore: DataStore by preferencesDataStore( + name = "sideways_remote", + produceMigrations = { listOf(PerPlayerMigration(::perPlayerSidewaysNames)) }, +) + +// The switch used to be keyed by the console alone, for every player and body at once. +private fun perPlayerSidewaysNames(legacyName: String): List? = + segmentsOf(legacyName).singleOrNull() + ?.let(::consoleNamed) + ?.let { everyBodyKey(it) } + +class SidewaysRemoteDataStore(context: Context) : SidewaysRemoteRepository { + + private val dataStore = context.applicationContext.sidewaysRemoteDataStore + + override fun observe(console: Console, body: PlayerBody): Flow = + dataStore.data.map { it[preferenceKey(console, body)] } + + override suspend fun set(console: Console, body: PlayerBody, enabled: Boolean) { + dataStore.edit { it[preferenceKey(console, body)] = enabled } + } + + private fun preferenceKey(console: Console, body: PlayerBody) = + booleanPreferencesKey(bodyKeyPrefix(console, body)) +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyGlobalLayoutUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyGlobalLayoutUseCase.kt new file mode 100644 index 0000000..fc6781d --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyGlobalLayoutUseCase.kt @@ -0,0 +1,19 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.first + +/** A layout goes to every player; a saved set restores each player's own frozen bindings. */ +class ApplyGlobalLayoutUseCase( + private val globalLayouts: GlobalLayoutRepository, + private val applyMappingLayout: ApplyMappingLayoutUseCase, + private val applyPlayerMapping: ApplyPlayerMappingUseCase, +) { + suspend operator fun invoke(console: Console, bodies: List, layoutId: String) { + val saved = globalLayouts.observe().first().firstOrNull { it.id == layoutId } + if (saved == null) { + bodies.forEach { applyMappingLayout(console, it, layoutId) } + } else { + saved.bodies.forEach { applyPlayerMapping(console, it.body, it.entries, it.sidewaysRemote) } + } + } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyMappingLayoutUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyMappingLayoutUseCase.kt new file mode 100644 index 0000000..00da5f3 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyMappingLayoutUseCase.kt @@ -0,0 +1,18 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.first + +class ApplyMappingLayoutUseCase( + private val savedLayouts: SavedLayoutRepository, + private val applyPlayerMapping: ApplyPlayerMappingUseCase, +) { + suspend operator fun invoke(console: Console, body: PlayerBody, layoutId: String) { + val layout = MappingLayouts.byId(console, body.side, layoutId, savedLayouts.observe().first()) + applyPlayerMapping( + console, + body, + MappingLayouts.entriesOf(console, body.side, layout), + layout.sidewaysRemote, + ) + } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyPlayerMappingUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyPlayerMappingUseCase.kt new file mode 100644 index 0000000..044af1e --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyPlayerMappingUseCase.kt @@ -0,0 +1,16 @@ +package com.joegec.joycon2android.buttonmapping + +class ApplyPlayerMappingUseCase( + private val mappingRepository: ControllerMappingRepository, + private val sidewaysRemoteRepository: SidewaysRemoteRepository, +) { + suspend operator fun invoke( + console: Console, + body: PlayerBody, + entries: Map, + sidewaysRemote: Boolean, + ) { + mappingRepository.replace(console, body, entries) + sidewaysRemoteRepository.set(console, body, sidewaysRemote) + } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/Console.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/Console.kt index 1b93669..7133c02 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/Console.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/Console.kt @@ -1,8 +1,4 @@ package com.joegec.joycon2android.buttonmapping -/** A distinct controller shape an emulator can present to the user, independent of which emulator. */ -enum class Console(val displayName: String) { - GAMECUBE("Gamecube"), - WIIMOTE_NUNCHUK("Wiimote & Nunchuck"), - SWITCH_PRO("Joycons"), -} +/** A controller shape an emulator presents, whichever emulator it is. */ +enum class Console { GAMECUBE, WIIMOTE_NUNCHUK, SWITCH_PRO } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ControllerMappingRepository.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ControllerMappingRepository.kt index d5e018f..554306d 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ControllerMappingRepository.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ControllerMappingRepository.kt @@ -2,14 +2,11 @@ package com.joegec.joycon2android.buttonmapping import kotlinx.coroutines.flow.Flow -/** - * Stores the user's overrides to the default Joy-Con button mapping, keyed by console shape and - * body. Values are opaque strings (a [MappingSource] id, or a legacy whole-stick [StickSource] - * name) — this layer knows nothing about what a key or value means, only how to - * persist it; [DefaultControllerMappings] and the use cases give them meaning. - */ +/** Values are opaque strings ([sourceIdsOf]); the layouts and use cases give them meaning. */ interface ControllerMappingRepository { - fun observe(console: Console, side: JoyconSide): Flow> - suspend fun set(console: Console, side: JoyconSide, targetKey: String, sourceId: String) - suspend fun clear(console: Console, side: JoyconSide) + fun observe(console: Console, body: PlayerBody): Flow> + suspend fun set(console: Console, body: PlayerBody, targetKey: String, sourceId: String) + + /** Swaps a body's whole mapping in one write, so a layout never lands half-applied. */ + suspend fun replace(console: Console, body: PlayerBody, entries: Map) } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DefaultControllerMappings.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DefaultControllerMappings.kt deleted file mode 100644 index 0ecec2f..0000000 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DefaultControllerMappings.kt +++ /dev/null @@ -1,200 +0,0 @@ -package com.joegec.joycon2android.buttonmapping - -import com.joegec.joycon2android.buttonmapping.target.GameCubeButton -import com.joegec.joycon2android.buttonmapping.target.GameCubeStick -import com.joegec.joycon2android.buttonmapping.target.SwitchProButton -import com.joegec.joycon2android.buttonmapping.target.SwitchProStick -import com.joegec.joycon2android.buttonmapping.target.WiimoteButton -import com.joegec.joycon2android.buttonmapping.target.WiimoteStick -import com.joegec.joycon2android.model.JoyconButton -import com.joegec.joycon2android.model.JoyconButton.A -import com.joegec.joycon2android.model.JoyconButton.B -import com.joegec.joycon2android.model.JoyconButton.Capture -import com.joegec.joycon2android.model.JoyconButton.Down -import com.joegec.joycon2android.model.JoyconButton.Home -import com.joegec.joycon2android.model.JoyconButton.L -import com.joegec.joycon2android.model.JoyconButton.LS -import com.joegec.joycon2android.model.JoyconButton.Left -import com.joegec.joycon2android.model.JoyconButton.Minus -import com.joegec.joycon2android.model.JoyconButton.Plus -import com.joegec.joycon2android.model.JoyconButton.R -import com.joegec.joycon2android.model.JoyconButton.RS -import com.joegec.joycon2android.model.JoyconButton.Right -import com.joegec.joycon2android.model.JoyconButton.SlLeft -import com.joegec.joycon2android.model.JoyconButton.SlRight -import com.joegec.joycon2android.model.JoyconButton.SrLeft -import com.joegec.joycon2android.model.JoyconButton.SrRight -import com.joegec.joycon2android.model.JoyconButton.Up -import com.joegec.joycon2android.model.JoyconButton.X -import com.joegec.joycon2android.model.JoyconButton.Y -import com.joegec.joycon2android.model.JoyconButton.ZL -import com.joegec.joycon2android.model.JoyconButton.ZR -import com.joegec.joycon2android.buttonmapping.StickSource.LEFT_STICK -import com.joegec.joycon2android.buttonmapping.StickSource.RIGHT_STICK - -/** - * The assignments a fresh install starts from, per target console and per Joy-Con body, for any - * target the user has never customized. - */ -object DefaultControllerMappings { - - fun gameCubeButtons(side: JoyconSide): Map = when (side) { - JoyconSide.DUAL -> mapOf( - GameCubeButton.A to A, - GameCubeButton.B to B, - GameCubeButton.X to X, - GameCubeButton.Y to Y, - GameCubeButton.Z to R, - GameCubeButton.Start to Plus, - GameCubeButton.TriggerL to L, - GameCubeButton.TriggerR to R, - GameCubeButton.DPadUp to Up, - GameCubeButton.DPadDown to Down, - GameCubeButton.DPadLeft to Left, - GameCubeButton.DPadRight to Right, - ) - JoyconSide.LEFT -> mapOf( - GameCubeButton.A to Down, - GameCubeButton.B to Left, - GameCubeButton.X to Right, - GameCubeButton.Y to Up, - GameCubeButton.Z to Capture, - GameCubeButton.Start to Minus, - GameCubeButton.TriggerL to SlLeft, - GameCubeButton.TriggerR to SrLeft, - ) - JoyconSide.RIGHT -> mapOf( - GameCubeButton.A to X, - GameCubeButton.B to A, - GameCubeButton.X to Y, - GameCubeButton.Y to B, - GameCubeButton.Z to Home, - GameCubeButton.Start to Plus, - GameCubeButton.TriggerL to SlRight, - GameCubeButton.TriggerR to SrRight, - ) - } - - fun gameCubeSticks(side: JoyconSide): Map = when (side) { - JoyconSide.DUAL -> mapOf( - GameCubeStick.MainStick to LEFT_STICK, - GameCubeStick.CStick to RIGHT_STICK, - ) - JoyconSide.LEFT -> mapOf(GameCubeStick.MainStick to LEFT_STICK) - JoyconSide.RIGHT -> mapOf(GameCubeStick.MainStick to RIGHT_STICK) - } - - fun switchProButtons(side: JoyconSide): Map = when (side) { - JoyconSide.DUAL -> mapOf( - SwitchProButton.A to A, - SwitchProButton.B to B, - SwitchProButton.X to X, - SwitchProButton.Y to Y, - SwitchProButton.L to L, - SwitchProButton.R to R, - SwitchProButton.ZL to ZL, - SwitchProButton.ZR to ZR, - SwitchProButton.Plus to Plus, - SwitchProButton.Minus to Minus, - SwitchProButton.Home to Home, - SwitchProButton.Capture to Capture, - SwitchProButton.LStickClick to LS, - SwitchProButton.RStickClick to RS, - SwitchProButton.DPadUp to Up, - SwitchProButton.DPadDown to Down, - SwitchProButton.DPadLeft to Left, - SwitchProButton.DPadRight to Right, - ) - // Held sideways, the rail buttons are the shoulder pair, as they are on a real Switch. - // ZL/ZR stay unbound: the body's own shoulders point away from the player in that grip, so - // there is nothing honest to put there — the user can bind them if they want them. - JoyconSide.LEFT -> mapOf( - SwitchProButton.A to Down, - SwitchProButton.B to Left, - SwitchProButton.X to Right, - SwitchProButton.Y to Up, - SwitchProButton.L to SlLeft, - SwitchProButton.R to SrLeft, - SwitchProButton.Minus to Minus, - SwitchProButton.LStickClick to LS, - SwitchProButton.Capture to Capture, - ) - JoyconSide.RIGHT -> mapOf( - SwitchProButton.A to X, - SwitchProButton.B to A, - SwitchProButton.X to Y, - SwitchProButton.Y to B, - SwitchProButton.L to SlRight, - SwitchProButton.R to SrRight, - SwitchProButton.Plus to Plus, - SwitchProButton.Home to Home, - SwitchProButton.LStickClick to RS, - ) - } - - fun switchProSticks(side: JoyconSide): Map = when (side) { - JoyconSide.DUAL -> mapOf( - SwitchProStick.LStick to LEFT_STICK, - SwitchProStick.RStick to RIGHT_STICK, - ) - JoyconSide.LEFT -> mapOf(SwitchProStick.LStick to LEFT_STICK) - JoyconSide.RIGHT -> mapOf(SwitchProStick.LStick to RIGHT_STICK) - } - - fun wiimoteButtons(side: JoyconSide): Map = when (side) { - JoyconSide.DUAL -> mapOf( - WiimoteButton.A to A, - WiimoteButton.B to ZR, - WiimoteButton.One to Y, - WiimoteButton.Two to B, - WiimoteButton.Home to Home, - WiimoteButton.Plus to Plus, - WiimoteButton.Minus to X, - WiimoteButton.DPadUp to Up, - WiimoteButton.DPadDown to Down, - WiimoteButton.DPadLeft to Left, - WiimoteButton.DPadRight to Right, - WiimoteButton.NunchukC to L, - WiimoteButton.NunchukZ to ZL, - ) - JoyconSide.LEFT -> mapOf( - WiimoteButton.A to Down, - WiimoteButton.B to ZL, - WiimoteButton.One to Up, - WiimoteButton.Two to Left, - WiimoteButton.Home to Capture, - WiimoteButton.Plus to Right, - WiimoteButton.Minus to Minus, - ) - JoyconSide.RIGHT -> mapOf( - WiimoteButton.A to A, - WiimoteButton.B to ZR, - WiimoteButton.One to Y, - WiimoteButton.Two to B, - WiimoteButton.Home to Home, - WiimoteButton.Plus to Plus, - WiimoteButton.Minus to X, - ) - } - - fun wiimoteSticks(side: JoyconSide): Map = when (side) { - JoyconSide.DUAL -> mapOf(WiimoteStick.NunchukStick to LEFT_STICK) - else -> emptyMap() - } - - // A sideways Joy-Con has no d-pad left once its cluster becomes the face buttons, so its stick - // steers the Wii Remote's d-pad instead. - fun wiimoteDPadSticks(side: JoyconSide): Map { - val stick = when (side) { - JoyconSide.DUAL -> return emptyMap() - JoyconSide.LEFT -> LEFT_STICK - JoyconSide.RIGHT -> RIGHT_STICK - } - return mapOf( - WiimoteButton.DPadUp to MappingSource.Stick(stick, StickDirection.UP), - WiimoteButton.DPadDown to MappingSource.Stick(stick, StickDirection.DOWN), - WiimoteButton.DPadLeft to MappingSource.Stick(stick, StickDirection.LEFT), - WiimoteButton.DPadRight to MappingSource.Stick(stick, StickDirection.RIGHT), - ) - } -} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DefaultMappingEntries.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DefaultMappingEntries.kt deleted file mode 100644 index b615f5d..0000000 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DefaultMappingEntries.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.joegec.joycon2android.buttonmapping - -import com.joegec.joycon2android.model.JoyconButton - -/** The shipped defaults for a console/body, in the repository's opaque string form. */ -fun defaultMappingEntries(console: Console, side: JoyconSide): Map = when (console) { - Console.GAMECUBE -> DefaultControllerMappings.gameCubeButtons(side).buttonEntries() + - DefaultControllerMappings.gameCubeSticks(side).stickEntries() - Console.WIIMOTE_NUNCHUK -> DefaultControllerMappings.wiimoteButtons(side).buttonEntries() + - DefaultControllerMappings.wiimoteDPadSticks(side).sourceEntries() + - DefaultControllerMappings.wiimoteSticks(side).stickEntries() - Console.SWITCH_PRO -> DefaultControllerMappings.switchProButtons(side).buttonEntries() + - DefaultControllerMappings.switchProSticks(side).stickEntries() -} - -private fun > Map.buttonEntries(): Map = - entries.associate { (target, button) -> target.name to button.name } - -private fun > Map.sourceEntries(): Map = - entries.associate { (target, source) -> target.name to source.id } - -private fun > Map.stickEntries(): Map = - entries.flatMap { (target, stick) -> - MappingSource.directionsOf(stick).map { target.directionKey(it.direction) to it.id } - }.toMap() diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DeleteCustomLayoutUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DeleteCustomLayoutUseCase.kt new file mode 100644 index 0000000..21cbe26 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DeleteCustomLayoutUseCase.kt @@ -0,0 +1,5 @@ +package com.joegec.joycon2android.buttonmapping + +class DeleteCustomLayoutUseCase(private val savedLayouts: SavedLayoutRepository) { + suspend operator fun invoke(layoutId: String) = savedLayouts.delete(layoutId) +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DeleteGlobalLayoutUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DeleteGlobalLayoutUseCase.kt new file mode 100644 index 0000000..e8aad87 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DeleteGlobalLayoutUseCase.kt @@ -0,0 +1,5 @@ +package com.joegec.joycon2android.buttonmapping + +class DeleteGlobalLayoutUseCase(private val globalLayouts: GlobalLayoutRepository) { + suspend operator fun invoke(layoutId: String) = globalLayouts.delete(layoutId) +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt index 3b86887..acbe3df 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt @@ -3,10 +3,7 @@ package com.joegec.joycon2android.buttonmapping import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.SidewaysMapper -/** - * The button the relay actually reports for this physical button. A lone Joy-Con is rotated - * sideways by [SidewaysMapper] before its input leaves the app, so its d-pad arrives as face buttons. - */ +/** What the relay reports for this button once [SidewaysMapper] has rotated a lone Joy-Con. */ fun JoyconButton.emittedFor(side: JoyconSide): JoyconButton? { val emittedId = when (side) { JoyconSide.DUAL -> id @@ -20,14 +17,32 @@ fun JoyconButton.emittedFor(side: JoyconSide): JoyconButton? { fun MappingSource.Stick.emittedStick(side: JoyconSide): StickSource = if (side == JoyconSide.DUAL) stick else StickSource.LEFT_STICK -/** - * The stick a target can read as a whole — keeping its analog range — when all four of its - * directions follow the same emitted stick the natural way round; null when they're rearranged, - * partly unbound or mixed with buttons, which leaves each direction to be bound on its own. - */ -fun Map.wholeEmittedStick(side: JoyconSide): StickSource? { +/** The relay reports a lone Joy-Con's up as its rail; off a sideways remote, up is its L/R edge. */ +fun MappingSource.Stick.emittedDirection(side: JoyconSide, sidewaysRemote: Boolean): StickDirection = when { + sidewaysRemote -> direction + side == JoyconSide.LEFT -> UPRIGHT_LEFT.getValue(direction) + side == JoyconSide.RIGHT -> UPRIGHT_RIGHT.getValue(direction) + else -> direction +} + +private val UPRIGHT_LEFT = mapOf( + StickDirection.UP to StickDirection.LEFT, + StickDirection.RIGHT to StickDirection.UP, + StickDirection.DOWN to StickDirection.RIGHT, + StickDirection.LEFT to StickDirection.DOWN, +) + +private val UPRIGHT_RIGHT = mapOf( + StickDirection.UP to StickDirection.RIGHT, + StickDirection.LEFT to StickDirection.UP, + StickDirection.DOWN to StickDirection.LEFT, + StickDirection.RIGHT to StickDirection.DOWN, +) + +/** Non-null only when all four directions follow one stick the natural way round, so it stays analog. */ +fun Map>.wholeEmittedStick(side: JoyconSide): StickSource? { val sticks = StickDirection.entries.map { direction -> - val source = this[direction] as? MappingSource.Stick ?: return null + val source = this[direction]?.singleOrNull() as? MappingSource.Stick ?: return null if (source.direction != direction) return null source.emittedStick(side) } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GetEffectiveControllerMappingUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GetEffectiveControllerMappingUseCase.kt index 50f6cd4..5ca1cf5 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GetEffectiveControllerMappingUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GetEffectiveControllerMappingUseCase.kt @@ -6,6 +6,6 @@ import kotlinx.coroutines.flow.first class GetEffectiveControllerMappingUseCase( private val observeControllerMapping: ObserveControllerMappingUseCase, ) { - suspend operator fun invoke(console: Console, side: JoyconSide): Map = - observeControllerMapping(console, side).first() + suspend operator fun invoke(console: Console, body: PlayerBody): Map = + observeControllerMapping(console, body).first() } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GetSidewaysRemoteUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GetSidewaysRemoteUseCase.kt new file mode 100644 index 0000000..9c1d4dd --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GetSidewaysRemoteUseCase.kt @@ -0,0 +1,9 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.first + +/** One-shot read, for the emulator-config generators at "Set up" time. */ +class GetSidewaysRemoteUseCase(private val observeSidewaysRemote: ObserveSidewaysRemoteUseCase) { + suspend operator fun invoke(console: Console, body: PlayerBody): Boolean = + observeSidewaysRemote(console, body).first() +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayout.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayout.kt new file mode 100644 index 0000000..f5901a7 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayout.kt @@ -0,0 +1,11 @@ +package com.joegec.joycon2android.buttonmapping + +/** Restores only onto the same players holding the same bodies it was saved from. */ +data class GlobalLayout( + val id: String, + val name: String, + val console: Console, + val bodies: List, +) { + fun fits(bodies: List): Boolean = this.bodies.map { it.body }.toSet() == bodies.toSet() +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayoutRepository.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayoutRepository.kt new file mode 100644 index 0000000..5577d73 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayoutRepository.kt @@ -0,0 +1,9 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.Flow + +interface GlobalLayoutRepository { + fun observe(): Flow> + suspend fun save(layout: GlobalLayout) + suspend fun delete(layoutId: String) +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMapping.kt new file mode 100644 index 0000000..999fa49 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMapping.kt @@ -0,0 +1,25 @@ +package com.joegec.joycon2android.buttonmapping + +import com.joegec.joycon2android.buttonmapping.preset.MappingPreset + +/** Agrees only while every player does: on a saved set, one layout, or one [LayoutFamily]. */ +data class GlobalMapping( + val players: List, + val savedLayouts: List, +) { + val bodies: List get() = players.map { it.body } + + val matchingSaved: GlobalLayout? + get() = savedLayouts.firstOrNull { it.bodies == players.map(PlayerMapping::snapshot) } + + val sharedLayout: MappingLayout? + get() = players.takeIf { it.isNotEmpty() }?.map { it.layout }?.distinct()?.singleOrNull() + + val sharedFamily: LayoutFamily? + get() = players.takeIf { it.isNotEmpty() } + ?.map { (it.layout as? MappingPreset)?.family } + ?.distinct() + ?.singleOrNull() + + val selectedId: String? get() = matchingSaved?.id ?: sharedLayout?.id +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JoyconSide.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JoyconSide.kt index 63faf1f..84eda56 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JoyconSide.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JoyconSide.kt @@ -1,8 +1,4 @@ package com.joegec.joycon2android.buttonmapping -/** Which physical body a mapping applies to: a lone Joy-Con of one side, or a full controller. */ -enum class JoyconSide(val displayName: String) { - LEFT("Left Joy-Con"), - RIGHT("Right Joy-Con"), - DUAL("Dual Joy-Cons / Pro Controller"), -} +/** A lone Joy-Con of one side, or a full controller (pair or Pro). */ +enum class JoyconSide { LEFT, RIGHT, DUAL } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LayoutFamily.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LayoutFamily.kt new file mode 100644 index 0000000..3715f82 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LayoutFamily.kt @@ -0,0 +1,4 @@ +package com.joegec.joycon2android.buttonmapping + +/** One game's layouts in different grips; setting one gives each player the grip their body fits. */ +enum class LayoutFamily { MARIO_KART } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LegacyStickRoutes.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LegacyStickRoutes.kt index a326c69..76d1b33 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LegacyStickRoutes.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LegacyStickRoutes.kt @@ -1,10 +1,6 @@ package com.joegec.joycon2android.buttonmapping -/** - * Older versions stored a target stick as one entry naming a whole physical stick - * (`MainStick = LEFT_STICK`). Expands those into the four direction entries used now; a direction - * the user has since set on its own keeps its explicit choice. - */ +/** Expands an older whole-stick entry (`MainStick = LEFT_STICK`) into four; an explicit direction wins. */ internal fun Map.withLegacyStickRoutesExpanded(): Map { val legacy = filterValues { value -> StickSource.entries.any { it.name == value } } val expanded = legacy.flatMap { (target, stickName) -> diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingConversions.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingConversions.kt index 85383d8..cfa25f6 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingConversions.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingConversions.kt @@ -5,22 +5,18 @@ fun Enum<*>.directionKey(direction: StickDirection): String = stickDirectionKey( internal fun stickDirectionKey(targetName: String, direction: StickDirection) = "${targetName}_${direction.name}" -/** - * Recovers a typed target -> source map from the repository's opaque string map, silently dropping - * entries whose key isn't a [T] or whose value isn't a known source — a stale or "None"-selected - * entry simply produces no binding rather than a crash. - */ -inline fun > Map.toSourceMap(): Map = +/** Drops unknown keys and stale sources, so a bad entry binds nothing rather than crashing. */ +inline fun > Map.toSourceMap(): Map> = mapNotNull { (key, value) -> val target = enumValues().firstOrNull { it.name == key } ?: return@mapNotNull null - val source = MappingSource.fromId(value) ?: return@mapNotNull null - target to source + val sources = value.toMappingSources().ifEmpty { return@mapNotNull null } + target to sources }.toMap() /** Same recovery as [toSourceMap], for the four direction entries of each target stick. */ -inline fun > Map.toStickDirectionMap(): Map> = +inline fun > Map.toStickDirectionMap(): Map>> = enumValues().associateWith { target -> StickDirection.entries.mapNotNull { direction -> - this[target.directionKey(direction)]?.let(MappingSource::fromId)?.let { direction to it } + this[target.directionKey(direction)]?.toMappingSources()?.ifEmpty { null }?.let { direction to it } }.toMap() }.filterValues { it.isNotEmpty() } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayout.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayout.kt new file mode 100644 index 0000000..5b83e6b --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayout.kt @@ -0,0 +1,11 @@ +package com.joegec.joycon2android.buttonmapping + +/** Shipped or saved; entries are in the repository's string form. docs/architecture.md#button-mapping */ +interface MappingLayout { + val id: String + + /** docs/dsu-motion.md#playing-as-a-sideways-wii-remote */ + val sidewaysRemote: Boolean get() = false + + fun entries(side: JoyconSide): Map +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayouts.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayouts.kt new file mode 100644 index 0000000..ea79107 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayouts.kt @@ -0,0 +1,44 @@ +package com.joegec.joycon2android.buttonmapping + +import com.joegec.joycon2android.buttonmapping.preset.MappingPreset +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets + +object MappingLayouts { + + fun forBody(console: Console, side: JoyconSide, saved: List): List = + MappingPresets.forConsole(console).filter { side in it.sides } + + saved.filter { it.console == console && it.side == side } + + /** Layered over the console default, so a target the layout omits is still bound. */ + fun entriesOf(console: Console, side: JoyconSide, layout: MappingLayout): Map = + MappingPresets.default(console).entries(side) + layout.entries(side) + + /** Matched by bindings, never a stored choice: docs/architecture.md#button-mapping. Null is "Custom". */ + fun matching( + console: Console, + side: JoyconSide, + entries: Map, + sidewaysRemote: Boolean, + saved: List, + ): MappingLayout? = forBody(console, side, saved).firstOrNull { + it.sidewaysRemote == sidewaysRemote && entriesOf(console, side, it) == entries + } + + /** Falls back to the family's other grip, then the console default (as a deleted layout does). */ + fun byId(console: Console, side: JoyconSide, id: String?, saved: List): MappingLayout = + forBody(console, side, saved).firstOrNull { it.id == id } + ?: familyMember(console, side, id) + ?: MappingPresets.default(console) + + private fun familyMember(console: Console, side: JoyconSide, id: String?): MappingPreset? { + val presets = MappingPresets.forConsole(console) + val family = presets.firstOrNull { it.id == id }?.family ?: return null + return presets.firstOrNull { it.family == family && side in it.sides } + } + + /** Prefixed so it can never collide with a shipped id. */ + fun newId(): String = "saved-${java.util.UUID.randomUUID()}" + + fun nextName(prefix: String, taken: Collection): String = + generateSequence(1) { it + 1 }.map { "$prefix $it" }.first { it !in taken } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingSourceIds.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingSourceIds.kt new file mode 100644 index 0000000..d7f7b68 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingSourceIds.kt @@ -0,0 +1,12 @@ +package com.joegec.joycon2android.buttonmapping + +private const val SOURCE_SEPARATOR = "|" + +/** Any of several sources fires a target, so a value joins their ids; an older single id reads as one. */ +fun sourceIdsOf(value: String): List = value.split(SOURCE_SEPARATOR).filter { it.isNotEmpty() } + +fun sourceIdOf(ids: List): String = ids.joinToString(SOURCE_SEPARATOR) + +fun String.toMappingSources(): List = sourceIdsOf(this).mapNotNull(MappingSource::fromId) + +fun List.toSourceId(): String = sourceIdOf(map { it.id }) diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCase.kt index f64bfa7..e49a42b 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCase.kt @@ -1,13 +1,14 @@ package com.joegec.joycon2android.buttonmapping +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -/** The mapping actually in effect for a console/body: stored overrides layered on the defaults. */ +/** Layered over the console's default layout, so every target is answered. */ class ObserveControllerMappingUseCase(private val repository: ControllerMappingRepository) { - operator fun invoke(console: Console, side: JoyconSide): Flow> = - repository.observe(console, side).map { stored -> - defaultMappingEntries(console, side) + + operator fun invoke(console: Console, body: PlayerBody): Flow> = + repository.observe(console, body).map { stored -> + MappingPresets.default(console).entries(body.side) + stored.withLegacyButtonNamesRenamed().withLegacyStickRoutesExpanded() } } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveGlobalMappingUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveGlobalMappingUseCase.kt new file mode 100644 index 0000000..0266ca3 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveGlobalMappingUseCase.kt @@ -0,0 +1,23 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +class ObserveGlobalMappingUseCase( + private val observePlayerMapping: ObservePlayerMappingUseCase, + private val globalLayouts: GlobalLayoutRepository, +) { + operator fun invoke(console: Console, bodies: List): Flow = + combine(everyPlayer(console, bodies), forConsole(console)) { players, saved -> + GlobalMapping(players, saved) + } + + private fun everyPlayer(console: Console, bodies: List): Flow> = + if (bodies.isEmpty()) flowOf(emptyList()) + else combine(bodies.map { observePlayerMapping(console, it) }) { it.toList() } + + private fun forConsole(console: Console) = + globalLayouts.observe().map { layouts -> layouts.filter { it.console == console } } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObservePlayerMappingUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObservePlayerMappingUseCase.kt new file mode 100644 index 0000000..121b0a4 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObservePlayerMappingUseCase.kt @@ -0,0 +1,24 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +class ObservePlayerMappingUseCase( + private val observeControllerMapping: ObserveControllerMappingUseCase, + private val observeSidewaysRemote: ObserveSidewaysRemoteUseCase, + private val savedLayouts: SavedLayoutRepository, +) { + operator fun invoke(console: Console, body: PlayerBody): Flow = + combine( + observeControllerMapping(console, body), + observeSidewaysRemote(console, body), + savedLayouts.observe(), + ) { entries, sidewaysRemote, saved -> + PlayerMapping( + body = body, + entries = entries, + sidewaysRemote = sidewaysRemote, + layout = MappingLayouts.matching(console, body.side, entries, sidewaysRemote, saved), + ) + } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSavedLayoutsUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSavedLayoutsUseCase.kt new file mode 100644 index 0000000..58b8454 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSavedLayoutsUseCase.kt @@ -0,0 +1,9 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class ObserveSavedLayoutsUseCase(private val savedLayouts: SavedLayoutRepository) { + operator fun invoke(console: Console): Flow> = + savedLayouts.observe().map { layouts -> layouts.filter { it.console == console } } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSidewaysRemoteUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSidewaysRemoteUseCase.kt new file mode 100644 index 0000000..6700d6a --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSidewaysRemoteUseCase.kt @@ -0,0 +1,11 @@ +package com.joegec.joycon2android.buttonmapping + +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** Falls back to the console's default layout. */ +class ObserveSidewaysRemoteUseCase(private val repository: SidewaysRemoteRepository) { + operator fun invoke(console: Console, body: PlayerBody): Flow = + repository.observe(console, body).map { it ?: MappingPresets.default(console).sidewaysRemote } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBody.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBody.kt new file mode 100644 index 0000000..b2e5c10 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBody.kt @@ -0,0 +1,17 @@ +package com.joegec.joycon2android.buttonmapping + +import com.joegec.joycon2android.model.PlayerNumber +import com.joegec.joycon2android.model.PlayerState + +/** What every mapping is stored against. */ +data class PlayerBody(val player: PlayerNumber, val side: JoyconSide) + +/** Null while the player holds nothing. A Pro Controller has a pair's button set, so it maps as one. */ +fun PlayerState.body(): PlayerBody? = joyconSide()?.let { PlayerBody(player, it) } + +fun PlayerState.joyconSide(): JoyconSide? = when { + hasPro || hasFullController -> JoyconSide.DUAL + left != null -> JoyconSide.LEFT + right != null -> JoyconSide.RIGHT + else -> null +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerLayoutSnapshot.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerLayoutSnapshot.kt new file mode 100644 index 0000000..ab10938 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerLayoutSnapshot.kt @@ -0,0 +1,7 @@ +package com.joegec.joycon2android.buttonmapping + +data class PlayerLayoutSnapshot( + val body: PlayerBody, + val entries: Map, + val sidewaysRemote: Boolean, +) diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMapping.kt new file mode 100644 index 0000000..9b6c778 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMapping.kt @@ -0,0 +1,11 @@ +package com.joegec.joycon2android.buttonmapping + +data class PlayerMapping( + val body: PlayerBody, + val entries: Map, + val sidewaysRemote: Boolean, + /** Null is "Custom". */ + val layout: MappingLayout?, +) { + fun snapshot() = PlayerLayoutSnapshot(body, entries, sidewaysRemote) +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ResetControllerMappingUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ResetControllerMappingUseCase.kt deleted file mode 100644 index b79119a..0000000 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ResetControllerMappingUseCase.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.joegec.joycon2android.buttonmapping - -/** Discards every override for a console/body, reverting it to the shipped defaults. */ -class ResetControllerMappingUseCase(private val repository: ControllerMappingRepository) { - suspend operator fun invoke(console: Console, side: JoyconSide) = repository.clear(console, side) -} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveCustomLayoutUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveCustomLayoutUseCase.kt new file mode 100644 index 0000000..b4c3391 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveCustomLayoutUseCase.kt @@ -0,0 +1,23 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.first + +/** Applies nothing: the bindings already match, so the card picks up the name. */ +class SaveCustomLayoutUseCase( + private val savedLayouts: SavedLayoutRepository, + private val observePlayerMapping: ObservePlayerMappingUseCase, +) { + suspend operator fun invoke(console: Console, body: PlayerBody, name: String) { + val current = observePlayerMapping(console, body).first() + savedLayouts.save( + SavedLayout( + id = MappingLayouts.newId(), + name = name, + console = console, + side = body.side, + bindings = current.entries, + sidewaysRemote = current.sidewaysRemote, + ), + ) + } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveGlobalLayoutUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveGlobalLayoutUseCase.kt new file mode 100644 index 0000000..0819de6 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveGlobalLayoutUseCase.kt @@ -0,0 +1,13 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.first + +class SaveGlobalLayoutUseCase( + private val globalLayouts: GlobalLayoutRepository, + private val observePlayerMapping: ObservePlayerMappingUseCase, +) { + suspend operator fun invoke(console: Console, bodies: List, name: String) { + val snapshots = bodies.map { observePlayerMapping(console, it).first().snapshot() } + globalLayouts.save(GlobalLayout(MappingLayouts.newId(), name, console, snapshots)) + } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayout.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayout.kt new file mode 100644 index 0000000..aec39a5 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayout.kt @@ -0,0 +1,13 @@ +package com.joegec.joycon2android.buttonmapping + +data class SavedLayout( + override val id: String, + val name: String, + val console: Console, + val side: JoyconSide, + val bindings: Map, + override val sidewaysRemote: Boolean = false, +) : MappingLayout { + /** Only ever offered back to the body it was saved from, so [side] is always that one. */ + override fun entries(side: JoyconSide) = bindings +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayoutRepository.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayoutRepository.kt new file mode 100644 index 0000000..24e25ed --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayoutRepository.kt @@ -0,0 +1,9 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.Flow + +interface SavedLayoutRepository { + fun observe(): Flow> + suspend fun save(layout: SavedLayout) + suspend fun delete(layoutId: String) +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetControllerMappingUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetControllerMappingUseCase.kt index 9a217f3..ad98490 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetControllerMappingUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetControllerMappingUseCase.kt @@ -1,7 +1,6 @@ package com.joegec.joycon2android.buttonmapping -/** Records the user's choice of physical source for one target button or stick. */ class SetControllerMappingUseCase(private val repository: ControllerMappingRepository) { - suspend operator fun invoke(console: Console, side: JoyconSide, targetKey: String, sourceId: String) = - repository.set(console, side, targetKey, sourceId) + suspend operator fun invoke(console: Console, body: PlayerBody, targetKey: String, sourceId: String) = + repository.set(console, body, targetKey, sourceId) } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetSidewaysRemoteUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetSidewaysRemoteUseCase.kt new file mode 100644 index 0000000..bd3ad1a --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetSidewaysRemoteUseCase.kt @@ -0,0 +1,7 @@ +package com.joegec.joycon2android.buttonmapping + +/** Overrides the layout's answer until a layout is next applied. */ +class SetSidewaysRemoteUseCase(private val repository: SidewaysRemoteRepository) { + suspend operator fun invoke(console: Console, body: PlayerBody, enabled: Boolean) = + repository.set(console, body, enabled) +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteRepository.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteRepository.kt new file mode 100644 index 0000000..fc69cc5 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteRepository.kt @@ -0,0 +1,9 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.Flow + +/** Null until set, meaning the layout decides. */ +interface SidewaysRemoteRepository { + fun observe(console: Console, body: PlayerBody): Flow + suspend fun set(console: Console, body: PlayerBody, enabled: Boolean) +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/StickDirection.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/StickDirection.kt index e3c32f3..f15e084 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/StickDirection.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/StickDirection.kt @@ -1,8 +1,3 @@ package com.joegec.joycon2android.buttonmapping -enum class StickDirection(val displayName: String) { - UP("Up"), - DOWN("Down"), - LEFT("Left"), - RIGHT("Right"), -} +enum class StickDirection { UP, DOWN, LEFT, RIGHT } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/StickSource.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/StickSource.kt index de498c9..d0b3a89 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/StickSource.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/StickSource.kt @@ -1,7 +1,4 @@ package com.joegec.joycon2android.buttonmapping /** A physical analog stick. A lone Joy-Con has just the one on its own side. */ -enum class StickSource(val displayName: String) { - LEFT_STICK("Left Stick"), - RIGHT_STICK("Right Stick"), -} +enum class StickSource { LEFT_STICK, RIGHT_STICK } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/GameCubeMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/GameCubeMapping.kt new file mode 100644 index 0000000..efdbf01 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/GameCubeMapping.kt @@ -0,0 +1,81 @@ +package com.joegec.joycon2android.buttonmapping.preset + +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.StickSource.LEFT_STICK +import com.joegec.joycon2android.buttonmapping.StickSource.RIGHT_STICK +import com.joegec.joycon2android.buttonmapping.StickSource +import com.joegec.joycon2android.buttonmapping.target.GameCubeButton +import com.joegec.joycon2android.buttonmapping.target.GameCubeStick +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.model.JoyconButton.A +import com.joegec.joycon2android.model.JoyconButton.B +import com.joegec.joycon2android.model.JoyconButton.Capture +import com.joegec.joycon2android.model.JoyconButton.Down +import com.joegec.joycon2android.model.JoyconButton.Home +import com.joegec.joycon2android.model.JoyconButton.L +import com.joegec.joycon2android.model.JoyconButton.Left +import com.joegec.joycon2android.model.JoyconButton.Minus +import com.joegec.joycon2android.model.JoyconButton.Plus +import com.joegec.joycon2android.model.JoyconButton.R +import com.joegec.joycon2android.model.JoyconButton.Right +import com.joegec.joycon2android.model.JoyconButton.SlLeft +import com.joegec.joycon2android.model.JoyconButton.SlRight +import com.joegec.joycon2android.model.JoyconButton.SrLeft +import com.joegec.joycon2android.model.JoyconButton.SrRight +import com.joegec.joycon2android.model.JoyconButton.Up +import com.joegec.joycon2android.model.JoyconButton.X +import com.joegec.joycon2android.model.JoyconButton.Y + +object GameCubeMapping : MappingPreset { + override val id = "STANDARD" + override val console = Console.GAMECUBE + + override fun entries(side: JoyconSide) = buttons(side).buttonEntries() + sticks(side).stickEntries() + + private fun buttons(side: JoyconSide): Map = when (side) { + JoyconSide.DUAL -> mapOf( + GameCubeButton.A to A, + GameCubeButton.B to B, + GameCubeButton.X to X, + GameCubeButton.Y to Y, + GameCubeButton.Z to R, + GameCubeButton.Start to Plus, + GameCubeButton.TriggerL to L, + GameCubeButton.TriggerR to R, + GameCubeButton.DPadUp to Up, + GameCubeButton.DPadDown to Down, + GameCubeButton.DPadLeft to Left, + GameCubeButton.DPadRight to Right, + ) + JoyconSide.LEFT -> mapOf( + GameCubeButton.A to Down, + GameCubeButton.B to Left, + GameCubeButton.X to Right, + GameCubeButton.Y to Up, + GameCubeButton.Z to Capture, + GameCubeButton.Start to Minus, + GameCubeButton.TriggerL to SlLeft, + GameCubeButton.TriggerR to SrLeft, + ) + JoyconSide.RIGHT -> mapOf( + GameCubeButton.A to X, + GameCubeButton.B to A, + GameCubeButton.X to Y, + GameCubeButton.Y to B, + GameCubeButton.Z to Home, + GameCubeButton.Start to Plus, + GameCubeButton.TriggerL to SlRight, + GameCubeButton.TriggerR to SrRight, + ) + } + + private fun sticks(side: JoyconSide): Map = when (side) { + JoyconSide.DUAL -> mapOf( + GameCubeStick.MainStick to LEFT_STICK, + GameCubeStick.CStick to RIGHT_STICK, + ) + JoyconSide.LEFT -> mapOf(GameCubeStick.MainStick to LEFT_STICK) + JoyconSide.RIGHT -> mapOf(GameCubeStick.MainStick to RIGHT_STICK) + } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt new file mode 100644 index 0000000..adf8462 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt @@ -0,0 +1,44 @@ +package com.joegec.joycon2android.buttonmapping.preset + +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.target.WiimoteButton +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.model.JoyconButton.B +import com.joegec.joycon2android.model.JoyconButton.Down +import com.joegec.joycon2android.model.JoyconButton.L +import com.joegec.joycon2android.model.JoyconButton.Minus +import com.joegec.joycon2android.model.JoyconButton.R +import com.joegec.joycon2android.model.JoyconButton.Right +import com.joegec.joycon2android.model.JoyconButton.Up +import com.joegec.joycon2android.model.JoyconButton.ZL +import com.joegec.joycon2android.model.JoyconButton.ZR + +/** The Wii layout with B on the Joy-Con's B, and 1 and 2 on the shoulders so the thumb stays on the stick. */ +object JoyconWiiMapping : MappingPreset { + override val id = "JOYCON" + override val console = Console.WIIMOTE_NUNCHUK + + override fun entries(side: JoyconSide) = WiiMapping.entries(side) + buttons(side).buttonEntries() + + private fun buttons(side: JoyconSide): Map = when (side) { + JoyconSide.DUAL -> mapOf( + WiimoteButton.B to B, + WiimoteButton.One to R, + WiimoteButton.Two to ZR, + WiimoteButton.Minus to Minus, + ) + JoyconSide.RIGHT -> mapOf( + WiimoteButton.B to B, + WiimoteButton.One to R, + WiimoteButton.Two to ZR, + ) + JoyconSide.LEFT -> mapOf( + WiimoteButton.A to Right, + WiimoteButton.B to Down, + WiimoteButton.One to L, + WiimoteButton.Two to ZL, + WiimoteButton.Plus to Up, + ) + } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingEntries.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingEntries.kt new file mode 100644 index 0000000..788ecae --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingEntries.kt @@ -0,0 +1,18 @@ +package com.joegec.joycon2android.buttonmapping.preset + +import com.joegec.joycon2android.buttonmapping.MappingSource +import com.joegec.joycon2android.buttonmapping.StickSource +import com.joegec.joycon2android.buttonmapping.directionKey +import com.joegec.joycon2android.buttonmapping.toSourceId +import com.joegec.joycon2android.model.JoyconButton + +internal fun > Map.buttonEntries(): Map = + entries.associate { (target, button) -> target.name to button.name } + +internal fun > Map>.sourceEntries(): Map = + entries.associate { (target, sources) -> target.name to sources.toSourceId() } + +internal fun > Map.stickEntries(): Map = + entries.flatMap { (target, stick) -> + MappingSource.directionsOf(stick).map { target.directionKey(it.direction) to it.id } + }.toMap() diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPreset.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPreset.kt new file mode 100644 index 0000000..c7bfcc4 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPreset.kt @@ -0,0 +1,15 @@ +package com.joegec.joycon2android.buttonmapping.preset + +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.LayoutFamily +import com.joegec.joycon2android.buttonmapping.MappingLayout + +sealed interface MappingPreset : MappingLayout { + val console: Console + + val sides: Set get() = JoyconSide.entries.toSet() + + /** The same game in another grip; see [LayoutFamily]. */ + val family: LayoutFamily? get() = null +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPresets.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPresets.kt new file mode 100644 index 0000000..1a0b8b2 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPresets.kt @@ -0,0 +1,23 @@ +package com.joegec.joycon2android.buttonmapping.preset + +import com.joegec.joycon2android.buttonmapping.Console + +object MappingPresets { + + private val all = listOf( + GameCubeMapping, + WiiMapping, + JoyconWiiMapping, + MarioKartWheelMapping, + MarioKartNunchukMapping, + SwitchProMapping, + ) + + fun forConsole(console: Console): List = all.filter { it.console == console } + + fun default(console: Console): MappingPreset = forConsole(console).first() + + /** Falls back to the default for an id from a build that offered a preset this one doesn't. */ + fun byId(console: Console, id: String?): MappingPreset = + forConsole(console).firstOrNull { it.id == id } ?: default(console) +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt new file mode 100644 index 0000000..64174ad --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt @@ -0,0 +1,97 @@ +package com.joegec.joycon2android.buttonmapping.preset + +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.LayoutFamily +import com.joegec.joycon2android.buttonmapping.MappingSource +import com.joegec.joycon2android.buttonmapping.StickSource +import com.joegec.joycon2android.buttonmapping.StickSource.LEFT_STICK +import com.joegec.joycon2android.buttonmapping.StickSource.RIGHT_STICK +import com.joegec.joycon2android.buttonmapping.target.WiimoteButton +import com.joegec.joycon2android.buttonmapping.target.WiimoteStick +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.model.JoyconButton.A +import com.joegec.joycon2android.model.JoyconButton.B +import com.joegec.joycon2android.model.JoyconButton.Capture +import com.joegec.joycon2android.model.JoyconButton.Down +import com.joegec.joycon2android.model.JoyconButton.Home +import com.joegec.joycon2android.model.JoyconButton.Left +import com.joegec.joycon2android.model.JoyconButton.L +import com.joegec.joycon2android.model.JoyconButton.Minus +import com.joegec.joycon2android.model.JoyconButton.Plus +import com.joegec.joycon2android.model.JoyconButton.R +import com.joegec.joycon2android.model.JoyconButton.Right +import com.joegec.joycon2android.model.JoyconButton.SlLeft +import com.joegec.joycon2android.model.JoyconButton.SlRight +import com.joegec.joycon2android.model.JoyconButton.SrLeft +import com.joegec.joycon2android.model.JoyconButton.SrRight +import com.joegec.joycon2android.model.JoyconButton.X +import com.joegec.joycon2android.model.JoyconButton.Y +import com.joegec.joycon2android.model.JoyconButton.ZL +import com.joegec.joycon2android.model.JoyconButton.ZR + +/** + * Steers by stick. A pair puts B and Z on the upper shoulders, 1 and 2 on the lower; a lone Joy-Con + * plays both halves, its stick as the Nunchuk's and its rails as Z and B. + */ +object MarioKartNunchukMapping : MappingPreset { + override val id = "MARIO_KART_NUNCHUK" + override val console = Console.WIIMOTE_NUNCHUK + override val family = LayoutFamily.MARIO_KART + override val sidewaysRemote = true + + override fun entries(side: JoyconSide) = when (side) { + JoyconSide.DUAL -> WiiMapping.entries(side) + pairButtons() + else -> loneButtons(side) + nunchukStick(side).stickEntries() + } + + private fun pairButtons() = mapOf( + WiimoteButton.One to listOf(ZL), + WiimoteButton.Two to listOf(ZR), + WiimoteButton.B to listOf(R, B), + WiimoteButton.Shake to listOf(R), + WiimoteButton.Minus to listOf(Minus), + WiimoteButton.NunchukC to listOf(X), + WiimoteButton.NunchukZ to listOf(L), + ).sources() + + private fun loneButtons(side: JoyconSide) = (loneFaces(side) + UNBOUND).sources() + + private fun loneFaces(side: JoyconSide) = when (side) { + JoyconSide.LEFT -> mapOf( + WiimoteButton.A to listOf(Down), + WiimoteButton.B to listOf(Left, SrLeft), + WiimoteButton.NunchukC to listOf(Right), + WiimoteButton.NunchukZ to listOf(SlLeft), + WiimoteButton.Plus to listOf(Minus), + WiimoteButton.Home to listOf(Capture), + WiimoteButton.Shake to listOf(SrLeft), + ) + else -> mapOf( + WiimoteButton.A to listOf(X), + WiimoteButton.B to listOf(A, SrRight), + WiimoteButton.NunchukC to listOf(Y), + WiimoteButton.NunchukZ to listOf(SlRight), + WiimoteButton.Plus to listOf(Plus), + WiimoteButton.Home to listOf(Home), + WiimoteButton.Shake to listOf(SrRight), + ) + } + + // Listed rather than omitted, or they keep the console default's bindings ([MappingLayouts.entriesOf]). + private val UNBOUND = listOf( + WiimoteButton.DPadUp, + WiimoteButton.DPadDown, + WiimoteButton.DPadLeft, + WiimoteButton.DPadRight, + WiimoteButton.One, + WiimoteButton.Two, + WiimoteButton.Minus, + ).associateWith { emptyList() } + + private fun nunchukStick(side: JoyconSide): Map = + mapOf(WiimoteStick.NunchukStick to if (side == JoyconSide.LEFT) LEFT_STICK else RIGHT_STICK) + + private fun Map>.sources() = + mapValues { (_, buttons) -> buttons.map(MappingSource::Button) }.sourceEntries() +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWheelMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWheelMapping.kt new file mode 100644 index 0000000..ed79338 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWheelMapping.kt @@ -0,0 +1,65 @@ +package com.joegec.joycon2android.buttonmapping.preset + +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.LayoutFamily +import com.joegec.joycon2android.buttonmapping.MappingSource +import com.joegec.joycon2android.buttonmapping.target.WiimoteButton +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.model.JoyconButton.A +import com.joegec.joycon2android.model.JoyconButton.B +import com.joegec.joycon2android.model.JoyconButton.Capture +import com.joegec.joycon2android.model.JoyconButton.Down +import com.joegec.joycon2android.model.JoyconButton.Home +import com.joegec.joycon2android.model.JoyconButton.Left +import com.joegec.joycon2android.model.JoyconButton.Minus +import com.joegec.joycon2android.model.JoyconButton.Plus +import com.joegec.joycon2android.model.JoyconButton.Right +import com.joegec.joycon2android.model.JoyconButton.SlLeft +import com.joegec.joycon2android.model.JoyconButton.SlRight +import com.joegec.joycon2android.model.JoyconButton.SrLeft +import com.joegec.joycon2android.model.JoyconButton.SrRight +import com.joegec.joycon2android.model.JoyconButton.Up +import com.joegec.joycon2android.model.JoyconButton.X +import com.joegec.joycon2android.model.JoyconButton.Y + +/** Mario Kart 8's sideways layout, so a thumb does the same job in both games; SL also throws an item. */ +object MarioKartWheelMapping : MappingPreset { + override val id = "MARIO_KART_WHEEL" + override val console = Console.WIIMOTE_NUNCHUK + override val family = LayoutFamily.MARIO_KART + override val sides = setOf(JoyconSide.LEFT, JoyconSide.RIGHT) + override val sidewaysRemote = true + + override fun entries(side: JoyconSide) = + buttons(side).buttonEntries() + dPadSticks(side).sourceEntries() + + private fun buttons(side: JoyconSide): Map = when (side) { + JoyconSide.LEFT -> mapOf( + WiimoteButton.A to Right, + WiimoteButton.B to SrLeft, + WiimoteButton.One to Left, + WiimoteButton.Two to Down, + WiimoteButton.Home to Capture, + WiimoteButton.Plus to Minus, + WiimoteButton.Minus to Up, + WiimoteButton.Shake to SrLeft, + ) + else -> mapOf( + WiimoteButton.A to Y, + WiimoteButton.B to SrRight, + WiimoteButton.One to A, + WiimoteButton.Two to X, + WiimoteButton.Home to Home, + WiimoteButton.Plus to Plus, + WiimoteButton.Minus to B, + WiimoteButton.Shake to SrRight, + ) + } + + private fun dPadSticks(side: JoyconSide): Map> { + val fromStick = WiiMapping.dPadSticks(side) + val item = MappingSource.Button(if (side == JoyconSide.LEFT) SlLeft else SlRight) + return fromStick + (WiimoteButton.DPadUp to fromStick.getValue(WiimoteButton.DPadUp) + item) + } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt new file mode 100644 index 0000000..4a6c07f --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt @@ -0,0 +1,94 @@ +package com.joegec.joycon2android.buttonmapping.preset + +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.StickSource +import com.joegec.joycon2android.buttonmapping.StickSource.LEFT_STICK +import com.joegec.joycon2android.buttonmapping.StickSource.RIGHT_STICK +import com.joegec.joycon2android.buttonmapping.target.SwitchProButton +import com.joegec.joycon2android.buttonmapping.target.SwitchProStick +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.model.JoyconButton.A +import com.joegec.joycon2android.model.JoyconButton.B +import com.joegec.joycon2android.model.JoyconButton.Capture +import com.joegec.joycon2android.model.JoyconButton.Down +import com.joegec.joycon2android.model.JoyconButton.Home +import com.joegec.joycon2android.model.JoyconButton.L +import com.joegec.joycon2android.model.JoyconButton.LS +import com.joegec.joycon2android.model.JoyconButton.Left +import com.joegec.joycon2android.model.JoyconButton.Minus +import com.joegec.joycon2android.model.JoyconButton.Plus +import com.joegec.joycon2android.model.JoyconButton.R +import com.joegec.joycon2android.model.JoyconButton.RS +import com.joegec.joycon2android.model.JoyconButton.Right +import com.joegec.joycon2android.model.JoyconButton.SlLeft +import com.joegec.joycon2android.model.JoyconButton.SlRight +import com.joegec.joycon2android.model.JoyconButton.SrLeft +import com.joegec.joycon2android.model.JoyconButton.SrRight +import com.joegec.joycon2android.model.JoyconButton.Up +import com.joegec.joycon2android.model.JoyconButton.X +import com.joegec.joycon2android.model.JoyconButton.Y +import com.joegec.joycon2android.model.JoyconButton.ZL +import com.joegec.joycon2android.model.JoyconButton.ZR + +object SwitchProMapping : MappingPreset { + override val id = "STANDARD" + override val console = Console.SWITCH_PRO + + override fun entries(side: JoyconSide) = buttons(side).buttonEntries() + sticks(side).stickEntries() + + private fun buttons(side: JoyconSide): Map = when (side) { + JoyconSide.DUAL -> mapOf( + SwitchProButton.A to A, + SwitchProButton.B to B, + SwitchProButton.X to X, + SwitchProButton.Y to Y, + SwitchProButton.L to L, + SwitchProButton.R to R, + SwitchProButton.ZL to ZL, + SwitchProButton.ZR to ZR, + SwitchProButton.Plus to Plus, + SwitchProButton.Minus to Minus, + SwitchProButton.Home to Home, + SwitchProButton.Capture to Capture, + SwitchProButton.LStickClick to LS, + SwitchProButton.RStickClick to RS, + SwitchProButton.DPadUp to Up, + SwitchProButton.DPadDown to Down, + SwitchProButton.DPadLeft to Left, + SwitchProButton.DPadRight to Right, + ) + // Rails as shoulders, ZL/ZR unbound: docs/virtual-gamepad.md#sidewaysmapper + JoyconSide.LEFT -> mapOf( + SwitchProButton.A to Down, + SwitchProButton.B to Left, + SwitchProButton.X to Right, + SwitchProButton.Y to Up, + SwitchProButton.L to SlLeft, + SwitchProButton.R to SrLeft, + SwitchProButton.Minus to Minus, + SwitchProButton.LStickClick to LS, + SwitchProButton.Capture to Capture, + ) + JoyconSide.RIGHT -> mapOf( + SwitchProButton.A to X, + SwitchProButton.B to A, + SwitchProButton.X to Y, + SwitchProButton.Y to B, + SwitchProButton.L to SlRight, + SwitchProButton.R to SrRight, + SwitchProButton.Plus to Plus, + SwitchProButton.Home to Home, + SwitchProButton.LStickClick to RS, + ) + } + + private fun sticks(side: JoyconSide): Map = when (side) { + JoyconSide.DUAL -> mapOf( + SwitchProStick.LStick to LEFT_STICK, + SwitchProStick.RStick to RIGHT_STICK, + ) + JoyconSide.LEFT -> mapOf(SwitchProStick.LStick to LEFT_STICK) + JoyconSide.RIGHT -> mapOf(SwitchProStick.LStick to RIGHT_STICK) + } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt new file mode 100644 index 0000000..dd3ddf9 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt @@ -0,0 +1,95 @@ +package com.joegec.joycon2android.buttonmapping.preset + +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.MappingSource +import com.joegec.joycon2android.buttonmapping.StickDirection +import com.joegec.joycon2android.buttonmapping.StickSource +import com.joegec.joycon2android.buttonmapping.StickSource.LEFT_STICK +import com.joegec.joycon2android.buttonmapping.StickSource.RIGHT_STICK +import com.joegec.joycon2android.buttonmapping.target.WiimoteButton +import com.joegec.joycon2android.buttonmapping.target.WiimoteStick +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.model.JoyconButton.A +import com.joegec.joycon2android.model.JoyconButton.B +import com.joegec.joycon2android.model.JoyconButton.Capture +import com.joegec.joycon2android.model.JoyconButton.Down +import com.joegec.joycon2android.model.JoyconButton.Home +import com.joegec.joycon2android.model.JoyconButton.L +import com.joegec.joycon2android.model.JoyconButton.Left +import com.joegec.joycon2android.model.JoyconButton.Minus +import com.joegec.joycon2android.model.JoyconButton.Plus +import com.joegec.joycon2android.model.JoyconButton.Right +import com.joegec.joycon2android.model.JoyconButton.Up +import com.joegec.joycon2android.model.JoyconButton.X +import com.joegec.joycon2android.model.JoyconButton.Y +import com.joegec.joycon2android.model.JoyconButton.ZL +import com.joegec.joycon2android.model.JoyconButton.ZR + +/** The Wii Remote's own layout: the trigger under the finger is B, and 1 and 2 sit under the thumb. */ +object WiiMapping : MappingPreset { + override val id = "WII" + override val console = Console.WIIMOTE_NUNCHUK + + override fun entries(side: JoyconSide) = + buttons(side).buttonEntries() + dPadSticks(side).sourceEntries() + nunchukStick(side).stickEntries() + + internal fun buttons(side: JoyconSide): Map = when (side) { + JoyconSide.DUAL -> mapOf( + WiimoteButton.A to A, + WiimoteButton.B to ZR, + WiimoteButton.One to Y, + WiimoteButton.Two to B, + WiimoteButton.Home to Home, + WiimoteButton.Plus to Plus, + WiimoteButton.Minus to X, + WiimoteButton.DPadUp to Up, + WiimoteButton.DPadDown to Down, + WiimoteButton.DPadLeft to Left, + WiimoteButton.DPadRight to Right, + WiimoteButton.NunchukC to L, + WiimoteButton.NunchukZ to ZL, + ) + JoyconSide.LEFT -> mapOf( + WiimoteButton.A to Right, + WiimoteButton.B to ZL, + WiimoteButton.One to Left, + WiimoteButton.Two to Down, + WiimoteButton.Home to Capture, + WiimoteButton.Plus to Up, + WiimoteButton.Minus to Minus, + ) + JoyconSide.RIGHT -> mapOf( + WiimoteButton.A to A, + WiimoteButton.B to ZR, + WiimoteButton.One to Y, + WiimoteButton.Two to B, + WiimoteButton.Home to Home, + WiimoteButton.Plus to Plus, + WiimoteButton.Minus to X, + ) + } + + // Sideways, the cluster is the face buttons, so the stick drives the d-pad. + internal fun dPadSticks(side: JoyconSide): Map> { + val stick = when (side) { + JoyconSide.DUAL -> return emptyMap() + JoyconSide.LEFT -> LEFT_STICK + JoyconSide.RIGHT -> RIGHT_STICK + } + return mapOf( + WiimoteButton.DPadUp to tilt(stick, StickDirection.UP), + WiimoteButton.DPadDown to tilt(stick, StickDirection.DOWN), + WiimoteButton.DPadLeft to tilt(stick, StickDirection.LEFT), + WiimoteButton.DPadRight to tilt(stick, StickDirection.RIGHT), + ) + } + + internal fun nunchukStick(side: JoyconSide): Map = when (side) { + JoyconSide.DUAL -> mapOf(WiimoteStick.NunchukStick to LEFT_STICK) + else -> emptyMap() + } + + private fun tilt(stick: StickSource, direction: StickDirection) = + listOf(MappingSource.Stick(stick, direction)) +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/GameCubeButton.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/GameCubeButton.kt index 8794cf7..be791f5 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/GameCubeButton.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/GameCubeButton.kt @@ -1,17 +1,16 @@ package com.joegec.joycon2android.buttonmapping.target -/** A GameCube controller's own digital buttons and d-pad directions. */ -enum class GameCubeButton(val displayName: String) { - A("A"), - B("B"), - X("X"), - Y("Y"), - Z("Z"), - Start("Start"), - TriggerL("L"), - TriggerR("R"), - DPadUp("D-Pad Up"), - DPadDown("D-Pad Down"), - DPadLeft("D-Pad Left"), - DPadRight("D-Pad Right"), +enum class GameCubeButton { + A, + B, + X, + Y, + Z, + Start, + TriggerL, + TriggerR, + DPadUp, + DPadDown, + DPadLeft, + DPadRight, } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/GameCubeStick.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/GameCubeStick.kt index e7f823e..8f8fd94 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/GameCubeStick.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/GameCubeStick.kt @@ -1,7 +1,3 @@ package com.joegec.joycon2android.buttonmapping.target -/** A GameCube controller's two analog sticks, only routable when a full body is connected. */ -enum class GameCubeStick(val displayName: String) { - MainStick("Main Stick"), - CStick("C-Stick"), -} +enum class GameCubeStick { MainStick, CStick } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/SwitchProButton.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/SwitchProButton.kt index 73ccb9f..a74caf4 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/SwitchProButton.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/SwitchProButton.kt @@ -1,23 +1,22 @@ package com.joegec.joycon2android.buttonmapping.target -/** A Nintendo Switch Pro Controller's own buttons and d-pad directions. */ -enum class SwitchProButton(val displayName: String) { - A("A"), - B("B"), - X("X"), - Y("Y"), - L("L"), - R("R"), - ZL("ZL"), - ZR("ZR"), - Plus("+"), - Minus("-"), - Home("Home"), - Capture("Capture"), - LStickClick("L-Stick Click"), - RStickClick("R-Stick Click"), - DPadUp("D-Pad Up"), - DPadDown("D-Pad Down"), - DPadLeft("D-Pad Left"), - DPadRight("D-Pad Right"), +enum class SwitchProButton { + A, + B, + X, + Y, + L, + R, + ZL, + ZR, + Plus, + Minus, + Home, + Capture, + LStickClick, + RStickClick, + DPadUp, + DPadDown, + DPadLeft, + DPadRight, } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/SwitchProStick.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/SwitchProStick.kt index adbe23a..e226b71 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/SwitchProStick.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/SwitchProStick.kt @@ -1,7 +1,3 @@ package com.joegec.joycon2android.buttonmapping.target -/** A Pro Controller's two analog sticks, only routable when a full body is connected. */ -enum class SwitchProStick(val displayName: String) { - LStick("L-Stick"), - RStick("R-Stick"), -} +enum class SwitchProStick { LStick, RStick } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/WiimoteButton.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/WiimoteButton.kt index 7e394da..dd73f21 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/WiimoteButton.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/WiimoteButton.kt @@ -1,18 +1,19 @@ package com.joegec.joycon2android.buttonmapping.target -/** A Wii Remote's own buttons plus its Nunchuk's two buttons. */ -enum class WiimoteButton(val displayName: String) { - A("A"), - B("B"), - One("1"), - Two("2"), - Home("Home"), - Plus("+"), - Minus("-"), - DPadUp("D-Pad Up"), - DPadDown("D-Pad Down"), - DPadLeft("D-Pad Left"), - DPadRight("D-Pad Right"), - NunchukC("Nunchuk C"), - NunchukZ("Nunchuk Z"), +/** Includes [Shake], a motion rather than a button, because the editor binds it like one. */ +enum class WiimoteButton { + A, + B, + One, + Two, + Home, + Plus, + Minus, + DPadUp, + DPadDown, + DPadLeft, + DPadRight, + NunchukC, + NunchukZ, + Shake, } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/WiimoteStick.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/WiimoteStick.kt index 0bdc9f4..d3d5c98 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/WiimoteStick.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/WiimoteStick.kt @@ -1,5 +1,3 @@ package com.joegec.joycon2android.buttonmapping.target -enum class WiimoteStick(val displayName: String) { - NunchukStick("Nunchuk Stick"), -} +enum class WiimoteStick { NunchukStick } diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/FakeMappingRepositories.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/FakeMappingRepositories.kt new file mode 100644 index 0000000..e7f28d9 --- /dev/null +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/FakeMappingRepositories.kt @@ -0,0 +1,61 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +private typealias BodyKey = Pair + +internal class FakeControllerMappings : ControllerMappingRepository { + private val stored = MutableStateFlow(emptyMap>()) + + override fun observe(console: Console, body: PlayerBody): Flow> = + stored.map { it[console to body].orEmpty() } + + override suspend fun set(console: Console, body: PlayerBody, targetKey: String, sourceId: String) { + stored.update { it + ((console to body) to (it[console to body].orEmpty() + (targetKey to sourceId))) } + } + + override suspend fun replace(console: Console, body: PlayerBody, entries: Map) { + stored.update { it + ((console to body) to entries) } + } +} + +internal class FakeSidewaysRemotes : SidewaysRemoteRepository { + private val stored = MutableStateFlow(emptyMap()) + + override fun observe(console: Console, body: PlayerBody): Flow = stored.map { it[console to body] } + + override suspend fun set(console: Console, body: PlayerBody, enabled: Boolean) { + stored.update { it + ((console to body) to enabled) } + } +} + +internal class FakeSavedLayouts(vararg initial: SavedLayout) : SavedLayoutRepository { + private val stored = MutableStateFlow(initial.associateBy { it.id }) + + override fun observe(): Flow> = stored.map { it.values.toList() } + + override suspend fun save(layout: SavedLayout) { + stored.update { it + (layout.id to layout) } + } + + override suspend fun delete(layoutId: String) { + stored.update { it - layoutId } + } +} + +internal class FakeGlobalLayouts(vararg initial: GlobalLayout) : GlobalLayoutRepository { + private val stored = MutableStateFlow(initial.associateBy { it.id }) + + override fun observe(): Flow> = stored.map { it.values.toList() } + + override suspend fun save(layout: GlobalLayout) { + stored.update { it + (layout.id to layout) } + } + + override suspend fun delete(layoutId: String) { + stored.update { it - layoutId } + } +} diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMappingTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMappingTest.kt new file mode 100644 index 0000000..ddae634 --- /dev/null +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMappingTest.kt @@ -0,0 +1,144 @@ +package com.joegec.joycon2android.buttonmapping + +import com.joegec.joycon2android.buttonmapping.MappingFixture.Companion.left +import com.joegec.joycon2android.buttonmapping.MappingFixture.Companion.right +import com.joegec.joycon2android.buttonmapping.preset.MarioKartNunchukMapping +import com.joegec.joycon2android.buttonmapping.preset.MarioKartWheelMapping +import com.joegec.joycon2android.buttonmapping.preset.WiiMapping +import com.joegec.joycon2android.model.PlayerNumber +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GlobalMappingTest { + + private val fixture = MappingFixture() + private val first = left() + private val second = right() + private val bodies = listOf(first, second) + + private suspend fun putBothOn(layoutId: String) = + fixture.applyGlobalLayout(fixture.console, bodies, layoutId) + + // Stands in for presentation's naming. + private fun GlobalMapping.agreedName(): String? = + matchingSaved?.name ?: sharedLayout?.id ?: sharedFamily?.name + + private suspend fun savedSet() = fixture.globalMapping(first, second).savedLayouts.single() + + @Test + fun `a layout every player reads as names the session`() = runBlocking { + putBothOn(WiiMapping.id) + + assertEquals(WiiMapping.id, fixture.globalMapping(first, second).agreedName()) + } + + @Test + fun `one player changing leaves the session with no name of its own`() = runBlocking { + putBothOn(WiiMapping.id) + + fixture.setMapping(fixture.console, first, "A", "Up") + + assertNull(fixture.globalMapping(first, second).agreedName()) + } + + @Test + fun `players on different layouts leave the session with no name of its own`() = runBlocking { + fixture.applyLayout(fixture.console, first, MarioKartWheelMapping.id) + fixture.applyLayout(fixture.console, second, WiiMapping.id) + + assertNull(fixture.globalMapping(first, second).agreedName()) + } + + @Test + fun `a saved set names the session again for as long as every player still matches it`() = runBlocking { + putBothOn(WiiMapping.id) + fixture.setMapping(fixture.console, first, "A", "Up") + + fixture.saveGlobalLayout(fixture.console, bodies, "Party") + assertEquals("Party", fixture.globalMapping(first, second).agreedName()) + + fixture.setMapping(fixture.console, second, "A", "Down") + assertNull(fixture.globalMapping(first, second).agreedName()) + } + + @Test + fun `setting a grip nobody but a lone Joy-Con has gives a pair the other grip of the same game`() = runBlocking { + val pair = PlayerBody(PlayerNumber.P3, JoyconSide.DUAL) + val mixed = bodies + pair + + fixture.applyGlobalLayout(fixture.console, mixed, MarioKartWheelMapping.id) + + assertEquals(MarioKartWheelMapping.id, fixture.playerMapping(first).layout?.id) + assertEquals(MarioKartNunchukMapping.id, fixture.playerMapping(pair).layout?.id) + } + + @Test + fun `players on two grips of one game still name the session`() = runBlocking { + val pair = PlayerBody(PlayerNumber.P3, JoyconSide.DUAL) + val mixed = bodies + pair + + fixture.applyGlobalLayout(fixture.console, mixed, MarioKartWheelMapping.id) + + assertEquals(LayoutFamily.MARIO_KART.name, fixture.globalMapping(first, second, pair).agreedName()) + } + + @Test + fun `a saved set fits only the players and bodies it was saved from`() = runBlocking { + fixture.saveGlobalLayout(fixture.console, bodies, "Party") + + val saved = savedSet() + + assertTrue(saved.fits(listOf(second, first))) + assertFalse(saved.fits(listOf(first))) + assertFalse(saved.fits(listOf(first, PlayerBody(PlayerNumber.P2, JoyconSide.DUAL)))) + } + + @Test + fun `a saved set records the bodies it wants, player by player`() = runBlocking { + val three = bodies + PlayerBody(PlayerNumber.P3, JoyconSide.DUAL) + fixture.saveGlobalLayout(fixture.console, three, "Party") + + assertEquals(three, savedSet().bodies.map { it.body }) + } + + @Test + fun `restoring a saved set gives every player back the bindings it froze`() = runBlocking { + fixture.applyLayout(fixture.console, first, MarioKartWheelMapping.id) + fixture.setMapping(fixture.console, first, "A", "Up") + fixture.applyLayout(fixture.console, second, WiiMapping.id) + fixture.saveGlobalLayout(fixture.console, bodies, "Party") + val saved = savedSet() + + putBothOn(WiiMapping.id) + fixture.applyGlobalLayout(fixture.console, bodies, saved.id) + + val restored = fixture.playerMapping(first) + assertEquals("Up", restored.entries["A"]) + assertTrue(restored.sidewaysRemote) + assertEquals(WiiMapping.id, fixture.playerMapping(second).layout?.id) + } + + @Test + fun `a set still restores a deleted layout's bindings, and names them again once it is back`() = runBlocking { + fixture.setMapping(fixture.console, first, "A", "Up") + fixture.saveCustomLayout(fixture.console, first, "My Wheel") + fixture.saveGlobalLayout(fixture.console, bodies, "Party") + val saved = savedSet() + fixture.deleteCustomLayout(fixture.savedLayoutNamed("My Wheel").id) + + putBothOn(WiiMapping.id) + fixture.applyGlobalLayout(fixture.console, bodies, saved.id) + + assertEquals("Up", fixture.playerMapping(first).entries["A"]) + assertNull(fixture.playerMapping(first).layout) + + fixture.saveCustomLayout(fixture.console, first, "My Wheel") + + assertEquals("My Wheel", (fixture.playerMapping(first).layout as? SavedLayout)?.name) + assertEquals("Party", fixture.globalMapping(first, second).agreedName()) + } +} diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/MappingFixture.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/MappingFixture.kt new file mode 100644 index 0000000..fcea5f0 --- /dev/null +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/MappingFixture.kt @@ -0,0 +1,42 @@ +package com.joegec.joycon2android.buttonmapping + +import com.joegec.joycon2android.model.PlayerNumber +import kotlinx.coroutines.flow.first + +/** The editor's use cases wired over in-memory stores, the way the composition root wires them. */ +internal class MappingFixture(val console: Console = Console.WIIMOTE_NUNCHUK) { + private val mappings = FakeControllerMappings() + private val sidewaysRemotes = FakeSidewaysRemotes() + private val savedLayouts = FakeSavedLayouts() + private val globalLayouts = FakeGlobalLayouts() + + private val observeMapping = ObserveControllerMappingUseCase(mappings) + private val observeSideways = ObserveSidewaysRemoteUseCase(sidewaysRemotes) + private val applyPlayerMapping = ApplyPlayerMappingUseCase(mappings, sidewaysRemotes) + + val observePlayerMapping = ObservePlayerMappingUseCase(observeMapping, observeSideways, savedLayouts) + val applyLayout = ApplyMappingLayoutUseCase(savedLayouts, applyPlayerMapping) + val setMapping = SetControllerMappingUseCase(mappings) + val setSidewaysRemote = SetSidewaysRemoteUseCase(sidewaysRemotes) + val saveCustomLayout = SaveCustomLayoutUseCase(savedLayouts, observePlayerMapping) + val deleteCustomLayout = DeleteCustomLayoutUseCase(savedLayouts) + val saveGlobalLayout = SaveGlobalLayoutUseCase(globalLayouts, observePlayerMapping) + val applyGlobalLayout = ApplyGlobalLayoutUseCase(globalLayouts, applyLayout, applyPlayerMapping) + val observeGlobalMapping = ObserveGlobalMappingUseCase(observePlayerMapping, globalLayouts) + val observeSavedLayouts = ObserveSavedLayoutsUseCase(savedLayouts) + + suspend fun playerMapping(body: PlayerBody) = observePlayerMapping(console, body).first() + + suspend fun globalMapping(vararg bodies: PlayerBody) = observeGlobalMapping(console, bodies.toList()).first() + + suspend fun savedLayoutNamed(name: String) = + observeSavedLayouts(console).first().first { it.name == name } + + suspend fun layoutsFor(side: JoyconSide) = + MappingLayouts.forBody(console, side, observeSavedLayouts(console).first()) + + companion object { + fun left(player: PlayerNumber = PlayerNumber.P1) = PlayerBody(player, JoyconSide.LEFT) + fun right(player: PlayerNumber = PlayerNumber.P2) = PlayerBody(player, JoyconSide.RIGHT) + } +} diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayoutsTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayoutsTest.kt new file mode 100644 index 0000000..31c57e7 --- /dev/null +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayoutsTest.kt @@ -0,0 +1,27 @@ +package com.joegec.joycon2android.buttonmapping + +import org.junit.Assert.assertEquals +import org.junit.Test + +class MappingLayoutsTest { + + @Test + fun `the first suggestion is the first number`() { + assertEquals("Custom 1", MappingLayouts.nextName("Custom", emptyList())) + } + + @Test + fun `each suggestion counts past the names already taken`() { + assertEquals("Custom 3", MappingLayouts.nextName("Custom", listOf("Custom 1", "Custom 2"))) + } + + @Test + fun `a number freed by a deleted layout is suggested again`() { + assertEquals("Custom 2", MappingLayouts.nextName("Custom", listOf("Custom 1", "Custom 3"))) + } + + @Test + fun `names of the user's own choosing stand in nobody's way`() { + assertEquals("Custom 1", MappingLayouts.nextName("Custom", listOf("My Wheel", "Customised"))) + } +} diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCaseTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCaseTest.kt index 08797a2..a37b953 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCaseTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCaseTest.kt @@ -1,22 +1,23 @@ package com.joegec.joycon2android.buttonmapping -import kotlinx.coroutines.flow.Flow +import com.joegec.joycon2android.buttonmapping.preset.MarioKartWheelMapping +import com.joegec.joycon2android.model.PlayerNumber import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Test class ObserveControllerMappingUseCaseTest { - private class StoredMapping(private val stored: Map) : ControllerMappingRepository { - override fun observe(console: Console, side: JoyconSide): Flow> = flowOf(stored) - override suspend fun set(console: Console, side: JoyconSide, targetKey: String, sourceId: String) = Unit - override suspend fun clear(console: Console, side: JoyconSide) = Unit - } - - private fun observe(stored: Map, side: JoyconSide = JoyconSide.DUAL) = runBlocking { - ObserveControllerMappingUseCase(StoredMapping(stored))(Console.GAMECUBE, side).first() + private fun observe( + stored: Map, + side: JoyconSide = JoyconSide.DUAL, + console: Console = Console.GAMECUBE, + ) = runBlocking { + val body = PlayerBody(PlayerNumber.P1, side) + val mappings = FakeControllerMappings() + stored.forEach { (target, source) -> mappings.set(console, body, target, source) } + ObserveControllerMappingUseCase(mappings)(console, body).first() } @Test @@ -64,4 +65,16 @@ class ObserveControllerMappingUseCaseTest { assertEquals("", mapping["CStick_LEFT"]) assertEquals(null, MappingSource.fromId(mapping.getValue("CStick_LEFT"))) } + + @Test + fun `applying a layout writes out everything it says, so the layout is no longer needed`() = runBlocking { + val fixture = MappingFixture() + val body = MappingFixture.right(PlayerNumber.P1) + + fixture.applyLayout(fixture.console, body, MarioKartWheelMapping.id) + + val mapping = fixture.playerMapping(body) + assertEquals("X", mapping.entries["Two"]) + assertEquals("RIGHT_STICK_UP|SlRight", mapping.entries["DPadUp"]) + } } diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMappingTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMappingTest.kt new file mode 100644 index 0000000..e2e06e1 --- /dev/null +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMappingTest.kt @@ -0,0 +1,118 @@ +package com.joegec.joycon2android.buttonmapping + +import com.joegec.joycon2android.buttonmapping.MappingFixture.Companion.left +import com.joegec.joycon2android.buttonmapping.MappingFixture.Companion.right +import com.joegec.joycon2android.buttonmapping.preset.MarioKartWheelMapping +import com.joegec.joycon2android.buttonmapping.preset.WiiMapping +import com.joegec.joycon2android.model.PlayerNumber +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PlayerMappingTest { + + private val fixture = MappingFixture() + private val body = left() + + @Test + fun `an untouched player reads as the layout they chose`() = runBlocking { + fixture.applyLayout(fixture.console, body, MarioKartWheelMapping.id) + + assertEquals(MarioKartWheelMapping, fixture.playerMapping(body).layout) + } + + @Test + fun `changing a binding turns it custom, and undoing that change turns it back`() = runBlocking { + fixture.applyLayout(fixture.console, body, MarioKartWheelMapping.id) + val original = MarioKartWheelMapping.entries(body.side).getValue("A") + + fixture.setMapping(fixture.console, body, "A", "Up") + assertNull(fixture.playerMapping(body).layout) + + fixture.setMapping(fixture.console, body, "A", original) + assertEquals(MarioKartWheelMapping.id, fixture.playerMapping(body).layout?.id) + } + + @Test + fun `the sideways-remote switch counts as a change of its own`() = runBlocking { + fixture.applyLayout(fixture.console, body, MarioKartWheelMapping.id) + + fixture.setSidewaysRemote(fixture.console, body, false) + + assertNull(fixture.playerMapping(body).layout) + } + + @Test + fun `one player's change leaves the next player's mapping alone`() = runBlocking { + val other = left(PlayerNumber.P2) + fixture.applyLayout(fixture.console, body, MarioKartWheelMapping.id) + fixture.applyLayout(fixture.console, other, WiiMapping.id) + + fixture.setMapping(fixture.console, body, "A", "Up") + + assertNull(fixture.playerMapping(body).layout) + assertEquals(WiiMapping.id, fixture.playerMapping(other).layout?.id) + } + + @Test + fun `saving names what the player built and offers it to that body`() = runBlocking { + fixture.applyLayout(fixture.console, body, MarioKartWheelMapping.id) + fixture.setMapping(fixture.console, body, "A", "Up") + + fixture.saveCustomLayout(fixture.console, body, "My Wheel") + + val mapping = fixture.playerMapping(body) + assertEquals("My Wheel", (mapping.layout as? SavedLayout)?.name) + assertEquals("Up", mapping.entries["A"]) + assertTrue(fixture.layoutsFor(body.side).any { it is SavedLayout && it.name == "My Wheel" }) + } + + @Test + fun `a layout saved from one body is not offered to another`() = runBlocking { + fixture.saveCustomLayout(fixture.console, body, "My Wheel") + + val other = fixture.layoutsFor(right().side) + + assertFalse(other.any { it is SavedLayout && it.name == "My Wheel" }) + } + + @Test + fun `deleting a layout keeps the bindings of everyone on it and only takes the name`() = runBlocking { + fixture.setMapping(fixture.console, body, "A", "Up") + fixture.saveCustomLayout(fixture.console, body, "My Wheel") + val saved = fixture.savedLayoutNamed("My Wheel") + + fixture.deleteCustomLayout(saved.id) + + val mapping = fixture.playerMapping(body) + assertNull(mapping.layout) + assertEquals("Up", mapping.entries["A"]) + } + + @Test + fun `saving the same layout again gives those bindings their name back`() = runBlocking { + fixture.setMapping(fixture.console, body, "A", "Up") + fixture.saveCustomLayout(fixture.console, body, "My Wheel") + fixture.deleteCustomLayout(fixture.savedLayoutNamed("My Wheel").id) + + fixture.saveCustomLayout(fixture.console, body, "My Wheel") + + assertEquals("My Wheel", (fixture.playerMapping(body).layout as? SavedLayout)?.name) + } + + @Test + fun `a layout someone else saved names an identical mapping arrived at alone`() = runBlocking { + val other = left(PlayerNumber.P2) + fixture.setMapping(fixture.console, body, "A", "Up") + fixture.saveCustomLayout(fixture.console, body, "My Wheel") + + fixture.setMapping(fixture.console, other, "A", "Up") + + assertNotNull(fixture.playerMapping(other).layout) + assertEquals("My Wheel", (fixture.playerMapping(other).layout as? SavedLayout)?.name) + } +} diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteTest.kt new file mode 100644 index 0000000..2b9f53d --- /dev/null +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteTest.kt @@ -0,0 +1,52 @@ +package com.joegec.joycon2android.buttonmapping + +import com.joegec.joycon2android.buttonmapping.MappingFixture.Companion.right +import com.joegec.joycon2android.buttonmapping.preset.MarioKartWheelMapping +import com.joegec.joycon2android.buttonmapping.preset.WiiMapping +import com.joegec.joycon2android.model.PlayerNumber +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SidewaysRemoteTest { + + private val fixture = MappingFixture() + private val body = right(PlayerNumber.P1) + + @Test + fun `a body nothing has set follows the console's default layout`() = runBlocking { + assertFalse(fixture.playerMapping(body).sidewaysRemote) + } + + @Test + fun `applying a layout takes its answer with it, either way`() = runBlocking { + fixture.applyLayout(fixture.console, body, MarioKartWheelMapping.id) + assertTrue(fixture.playerMapping(body).sidewaysRemote) + + fixture.applyLayout(fixture.console, body, WiiMapping.id) + assertFalse(fixture.playerMapping(body).sidewaysRemote) + } + + @Test + fun `the player's switch stands until a layout is applied over it`() = runBlocking { + fixture.applyLayout(fixture.console, body, WiiMapping.id) + + fixture.setSidewaysRemote(fixture.console, body, true) + assertTrue(fixture.playerMapping(body).sidewaysRemote) + + fixture.applyLayout(fixture.console, body, WiiMapping.id) + assertFalse(fixture.playerMapping(body).sidewaysRemote) + } + + @Test + fun `one player's switch leaves the others alone`() = runBlocking { + val other = right(PlayerNumber.P2) + + fixture.setSidewaysRemote(fixture.console, body, true) + + assertTrue(fixture.playerMapping(body).sidewaysRemote) + assertEquals(false, fixture.playerMapping(other).sidewaysRemote) + } +} diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/WholeEmittedStickTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/WholeEmittedStickTest.kt index de3cd1c..c44d34b 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/WholeEmittedStickTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/WholeEmittedStickTest.kt @@ -7,7 +7,8 @@ import org.junit.Test class WholeEmittedStickTest { - private fun following(stick: StickSource) = MappingSource.directionsOf(stick).associateBy { it.direction } + private fun following(stick: StickSource): Map> = + MappingSource.directionsOf(stick).associate { it.direction to listOf(it) } @Test fun `directions that follow one stick the natural way read as that whole stick`() { @@ -22,8 +23,8 @@ class WholeEmittedStickTest { @Test fun `swapped directions are not a whole stick`() { val swapped = following(StickSource.LEFT_STICK) + mapOf( - StickDirection.UP to MappingSource.Stick(StickSource.LEFT_STICK, StickDirection.DOWN), - StickDirection.DOWN to MappingSource.Stick(StickSource.LEFT_STICK, StickDirection.UP), + StickDirection.UP to listOf(MappingSource.Stick(StickSource.LEFT_STICK, StickDirection.DOWN)), + StickDirection.DOWN to listOf(MappingSource.Stick(StickSource.LEFT_STICK, StickDirection.UP)), ) assertNull(swapped.wholeEmittedStick(JoyconSide.DUAL)) @@ -31,9 +32,22 @@ class WholeEmittedStickTest { @Test fun `a direction driven by a button or left unbound is not a whole stick`() { - val withButton = following(StickSource.LEFT_STICK) + (StickDirection.UP to MappingSource.Button(JoyconButton.X)) + val withButton = following(StickSource.LEFT_STICK) + + (StickDirection.UP to listOf(MappingSource.Button(JoyconButton.X))) assertNull(withButton.wholeEmittedStick(JoyconSide.DUAL)) assertNull((following(StickSource.LEFT_STICK) - StickDirection.LEFT).wholeEmittedStick(JoyconSide.DUAL)) } + + @Test + fun `a direction with a second source of its own is not a whole stick`() { + val doubled = following(StickSource.LEFT_STICK) + ( + StickDirection.UP to listOf( + MappingSource.Stick(StickSource.LEFT_STICK, StickDirection.UP), + MappingSource.Button(JoyconButton.X), + ) + ) + + assertNull(doubled.wholeEmittedStick(JoyconSide.DUAL)) + } } diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt new file mode 100644 index 0000000..227d809 --- /dev/null +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt @@ -0,0 +1,210 @@ +package com.joegec.joycon2android.buttonmapping.preset + +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.MappingLayouts +import com.joegec.joycon2android.buttonmapping.MappingSource +import com.joegec.joycon2android.buttonmapping.emittedFor +import com.joegec.joycon2android.buttonmapping.sourceIdsOf +import com.joegec.joycon2android.buttonmapping.target.WiimoteButton +import com.joegec.joycon2android.buttonmapping.target.WiimoteStick +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WiiPresetsTest { + + @Test + fun `the Wii layout is what the console starts on`() { + assertEquals(WiiMapping, MappingPresets.default(Console.WIIMOTE_NUNCHUK)) + } + + @Test + fun `a left Joy-Con on the Wii layout keeps A and 1 and 2 where a right Joy-Con does, and + on top`() { + val left = WiiMapping.entries(JoyconSide.LEFT) + + assertEquals("Right", left.getValue(WiimoteButton.A.name)) + assertEquals("Left", left.getValue(WiimoteButton.One.name)) + assertEquals("Down", left.getValue(WiimoteButton.Two.name)) + assertEquals("Up", left.getValue(WiimoteButton.Plus.name)) + } + + @Test + fun `the Joy-Con layout puts the remote's buttons where a Joy-Con keeps them`() { + val dual = JoyconWiiMapping.entries(JoyconSide.DUAL) + assertEquals("B", dual.getValue(WiimoteButton.B.name)) + assertEquals("R", dual.getValue(WiimoteButton.One.name)) + assertEquals("ZR", dual.getValue(WiimoteButton.Two.name)) + assertEquals("Minus", dual.getValue(WiimoteButton.Minus.name)) + + val right = JoyconWiiMapping.entries(JoyconSide.RIGHT) + assertEquals("B", right.getValue(WiimoteButton.B.name)) + assertEquals("R", right.getValue(WiimoteButton.One.name)) + assertEquals("ZR", right.getValue(WiimoteButton.Two.name)) + + val left = JoyconWiiMapping.entries(JoyconSide.LEFT) + assertEquals("Right", left.getValue(WiimoteButton.A.name)) + assertEquals("Down", left.getValue(WiimoteButton.B.name)) + assertEquals("L", left.getValue(WiimoteButton.One.name)) + assertEquals("ZL", left.getValue(WiimoteButton.Two.name)) + assertEquals("Up", left.getValue(WiimoteButton.Plus.name)) + } + + @Test + fun `no button fires two of the Joy-Con layout's targets`() { + JoyconSide.entries.forEach { side -> + val fired = mutableMapOf>() + JoyconWiiMapping.entries(side).forEach { (target, value) -> + sourceIdsOf(value).forEach { fired.getOrPut(it) { mutableSetOf() }.add(target) } + } + + assertEquals("$side", emptyMap>(), fired.filterValues { it.size > 1 }) + } + } + + @Test + fun `Mario Kart accelerates and brakes on the buttons Mario Kart 8 uses`() { + val right = MarioKartWheelMapping.entries(JoyconSide.RIGHT) + + assertEquals("X", right.getValue(WiimoteButton.Two.name)) + assertEquals("A", right.getValue(WiimoteButton.One.name)) + assertEquals("SrRight", right.getValue(WiimoteButton.B.name)) + } + + @Test + fun `Mario Kart puts both bodies' jobs under the same thumb positions`() { + val left = MarioKartWheelMapping.entries(JoyconSide.LEFT) + + // Sideways, the left Joy-Con's Down sits where the right's X does, Left where its A does, + // Right where its Y does and Up where its B does (see SidewaysMapper). + assertEquals("Down", left.getValue(WiimoteButton.Two.name)) + assertEquals("Left", left.getValue(WiimoteButton.One.name)) + assertEquals("Right", left.getValue(WiimoteButton.A.name)) + assertEquals("Up", left.getValue(WiimoteButton.Minus.name)) + assertEquals("Minus", left.getValue(WiimoteButton.Plus.name)) + } + + @Test + fun `Mario Kart throws an item from SL as well as the stick`() { + assertEquals("RIGHT_STICK_UP|SlRight", MarioKartWheelMapping.entries(JoyconSide.RIGHT).getValue("DPadUp")) + assertEquals("LEFT_STICK_UP|SlLeft", MarioKartWheelMapping.entries(JoyconSide.LEFT).getValue("DPadUp")) + assertEquals("LEFT_STICK_DOWN", MarioKartWheelMapping.entries(JoyconSide.LEFT).getValue("DPadDown")) + } + + @Test + fun `a lone Joy-Con on the Nunchuck layout plays both halves itself`() { + JoyconSide.entries.filterNot { it == JoyconSide.DUAL }.forEach { side -> + val lone = MarioKartNunchukMapping.entries(side) + val rail = if (side == JoyconSide.LEFT) "SlLeft" else "SlRight" + + assertEquals("$side", rail, lone.getValue(WiimoteButton.NunchukZ.name)) + assertTrue("$side steers from its own stick", lone.keys.any { it.startsWith(WiimoteStick.NunchukStick.name) }) + // Unbound on purpose; omitted, each would keep the Wii layout's binding. + listOf( + WiimoteButton.DPadUp, WiimoteButton.DPadDown, WiimoteButton.DPadLeft, WiimoteButton.DPadRight, + WiimoteButton.One, WiimoteButton.Two, WiimoteButton.Minus, + ).forEach { assertEquals("$side ${it.name}", "", lone.getValue(it.name)) } + } + } + + @Test + fun `the Nunchuck layout puts the same job under the same thumb on both bodies`() { + val left = MarioKartNunchukMapping.entries(JoyconSide.LEFT) + val right = MarioKartNunchukMapping.entries(JoyconSide.RIGHT) + + listOf(WiimoteButton.A, WiimoteButton.B, WiimoteButton.NunchukC).forEach { target -> + assertEquals( + target.name, + emittedFace(left, target, JoyconSide.LEFT), + emittedFace(right, target, JoyconSide.RIGHT), + ) + } + } + + private fun emittedFace(entries: Map, target: WiimoteButton, side: JoyconSide) = + (MappingSource.fromId(sourceIdsOf(entries.getValue(target.name)).first()) as? MappingSource.Button) + ?.button + ?.emittedFor(side) + + @Test + fun `no button on a lone Joy-Con fires two targets, bar the shoulder that hops and tricks`() { + JoyconSide.entries.filterNot { it == JoyconSide.DUAL }.forEach { side -> + val entries = MappingLayouts.entriesOf(Console.WIIMOTE_NUNCHUK, side, MarioKartNunchukMapping) + val fired = mutableMapOf>() + entries.forEach { (target, value) -> + sourceIdsOf(value).forEach { fired.getOrPut(it) { mutableSetOf() }.add(target) } + } + val rail = if (side == JoyconSide.LEFT) "SrLeft" else "SrRight" + + assertEquals( + "$side", + mapOf(rail to setOf(WiimoteButton.B.name, WiimoteButton.Shake.name)), + fired.filterValues { it.size > 1 }, + ) + } + } + + @Test + fun `the wheel is offered to a lone Joy-Con alone, the Nunchuck layout to every body`() { + assertEquals(setOf(JoyconSide.LEFT, JoyconSide.RIGHT), MarioKartWheelMapping.sides) + assertEquals(JoyconSide.entries.toSet(), MarioKartNunchukMapping.sides) + } + + @Test + fun `only the Mario Kart layouts play as a sideways Wii Remote`() { + assertTrue(MarioKartWheelMapping.sidewaysRemote) + assertTrue(MarioKartNunchukMapping.sidewaysRemote) + assertFalse(WiiMapping.sidewaysRemote) + assertFalse(JoyconWiiMapping.sidewaysRemote) + } + + @Test + fun `Mario Kart Nunchuck moves a pair's fingers onto the shoulders, and tricks from one`() { + val pair = MarioKartNunchukMapping.entries(JoyconSide.DUAL) + + assertEquals("ZL", pair.getValue(WiimoteButton.One.name)) + assertEquals("ZR", pair.getValue(WiimoteButton.Two.name)) + assertEquals("R|B", pair.getValue(WiimoteButton.B.name)) + assertEquals("L", pair.getValue(WiimoteButton.NunchukZ.name)) + assertEquals("X", pair.getValue(WiimoteButton.NunchukC.name)) + assertEquals("Minus", pair.getValue(WiimoteButton.Minus.name)) + assertEquals("R", pair.getValue(WiimoteButton.Shake.name)) + } + + @Test + fun `a pair has no sideways grip to match, so the rest stays the Wii layout`() { + val untouched = WiiMapping.entries(JoyconSide.DUAL) - MarioKartNunchukMapping.entries(JoyconSide.DUAL).keys + + assertTrue(untouched.isEmpty()) + assertEquals( + WiiMapping.entries(JoyconSide.DUAL).getValue(WiimoteButton.A.name), + MarioKartNunchukMapping.entries(JoyconSide.DUAL).getValue(WiimoteButton.A.name), + ) + } + + @Test + fun `Mario Kart Wheel tricks off SR on a lone Joy-Con, the shoulder that already hops`() { + JoyconSide.entries.filterNot { it == JoyconSide.DUAL }.forEach { side -> + val lone = MarioKartWheelMapping.entries(side) + + assertEquals("$side", lone.getValue(WiimoteButton.B.name), lone.getValue(WiimoteButton.Shake.name)) + } + } + + @Test + fun `every Wii layout binds the whole remote on a lone Joy-Con`() { + // Shake is a motion, not a button. + val remote = (WiimoteButton.entries - WiimoteButton.NunchukC - WiimoteButton.NunchukZ - + WiimoteButton.Shake).map { it.name } + + MappingPresets.forConsole(Console.WIIMOTE_NUNCHUK) + .filterNot { it == MarioKartNunchukMapping } + .forEach { preset -> + assertTrue( + "${preset.id} binds the remote", + preset.entries(JoyconSide.RIGHT).keys.containsAll(remote), + ) + } + } +} diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingScreen.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingScreen.kt index 74390bb..80d8db1 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingScreen.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingScreen.kt @@ -20,44 +20,47 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import com.joegec.joycon2android.buttonmapping.Console -import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.GlobalMapping +import com.joegec.joycon2android.buttonmapping.MappingLayouts +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets +import com.joegec.joycon2android.ui.components.DropdownOption +import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.core.buttonmapping.presentation.R -import com.joegec.joycon2android.ui.components.ExpandableInfoSection -import com.joegec.joycon2android.ui.components.LabeledDropdown +import com.joegec.joycon2android.model.PlayerState +import com.joegec.joycon2android.ui.components.ConfirmDialog +import com.joegec.joycon2android.ui.components.TextInputDialog import com.joegec.joycon2android.ui.theme.Dimens import com.joegec.joycon2android.ui.theme.TextDim @Composable fun ControllerMappingScreen( - console: Console, - leftMapping: Map, - rightMapping: Map, - dualMapping: Map, - onSetMapping: (side: JoyconSide, targetKey: String, sourceId: String) -> Unit, - onResetMapping: (side: JoyconSide) -> Unit, + state: ControllerMappingUiState, + players: List, + actions: MappingActions, onBack: () -> Unit, modifier: Modifier = Modifier, ) { BackHandler(onBack = onBack) + var dialog by remember { mutableStateOf(null) } + val labels = rememberLayoutLabels() + Column( modifier .fillMaxSize() .windowInsetsPadding(WindowInsets.systemBars) .padding(horizontal = Dimens.screenPaddingHorizontal), ) { - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.controller_mapping_back)) - } - Text(console.displayName, style = MaterialTheme.typography.headlineSmall, color = Color.White) - } + ScreenHeader(state, onBack) Spacer(Modifier.height(Dimens.sectionSpacing)) Column( Modifier @@ -65,57 +68,127 @@ fun ControllerMappingScreen( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(Dimens.sectionSpacing), ) { - ExpandableInfoSection(JoyconSide.LEFT.displayName) { - MappingSection(console, JoyconSide.LEFT, leftMapping, onSetMapping, onResetMapping) + if (state.players.isEmpty()) { + Text(stringResource(R.string.controller_mapping_no_players), color = TextDim) + } else { + AllPlayersRow(state.console, state.global, labels, actions) { dialog = it } } - ExpandableInfoSection(JoyconSide.RIGHT.displayName) { - MappingSection(console, JoyconSide.RIGHT, rightMapping, onSetMapping, onResetMapping) - } - ExpandableInfoSection(JoyconSide.DUAL.displayName) { - MappingSection(console, JoyconSide.DUAL, dualMapping, onSetMapping, onResetMapping) + state.players.forEach { player -> + val connected = players.firstOrNull { it.player == player.body.player } + if (connected != null) { + PlayerMappingCard( + console = state.console, + player = connected, + state = player, + labels = labels, + actions = actions, + onSaveLayout = { dialog = MappingDialog.Save(player.body) }, + onDeleteLayout = { dialog = MappingDialog.Delete(it.id, it.label, global = false) }, + ) + } } Spacer(Modifier.height(Dimens.sectionSpacing)) } } + + MappingDialogs(state, dialog, actions) { dialog = null } } @Composable -private fun MappingSection( - console: Console, - side: JoyconSide, - mapping: Map, - onSetMapping: (side: JoyconSide, targetKey: String, sourceId: String) -> Unit, - onResetMapping: (side: JoyconSide) -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(Dimens.elementSpacing)) { - val sourceOptions = MappingOptions.sources(side) - (MappingOptions.buttonTargets(console) + MappingOptions.stickDirectionTargets(console)).forEach { (key, label) -> - MappingRow(label, mapping[key] ?: MappingOptions.NONE_ID, sourceOptions) { onSetMapping(side, key, it) } - } - TextButton(onClick = { onResetMapping(side) }) { - Text(stringResource(R.string.controller_mapping_reset)) +private fun ScreenHeader(state: ControllerMappingUiState, onBack: () -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.controller_mapping_back), + ) } + Text(state.console.label(), style = MaterialTheme.typography.headlineSmall, color = Color.White) } } @Composable -private fun MappingRow( - label: String, - selectedId: String, - options: List>, - onSelect: (String) -> Unit, +private fun AllPlayersRow( + console: Console, + global: GlobalMapping, + labels: LayoutLabels, + actions: MappingActions, + onDialog: (MappingDialog) -> Unit, ) { + val saved = global.savedLayouts.map { + DropdownOption( + id = it.id, + label = labels.name(it), + subLabel = it.playerSummary(), + available = it.fits(global.bodies), + deletable = true, + ) + } + Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Dimens.elementSpacing), verticalAlignment = Alignment.CenterVertically, ) { - Text(label, color = TextDim, modifier = Modifier.weight(1f)) - LabeledDropdown( - options = options, - selectedId = selectedId, - onSelect = onSelect, + Text( + stringResource(R.string.controller_mapping_all_players), + color = TextDim, modifier = Modifier.weight(1f), ) + LayoutRow( + options = MappingPresets.forConsole(console).map { labels.option(it) } + saved, + selectedId = global.selectedId, + layoutName = labels.sessionName(global), + subLabel = global.matchingSaved?.let { it.playerSummary() }, + onSelect = actions.selectGlobalLayout, + onSave = { onDialog(MappingDialog.Save(body = null)) }, + onDelete = { onDialog(MappingDialog.Delete(it.id, it.label, global = true)) }, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun MappingDialogs( + state: ControllerMappingUiState, + dialog: MappingDialog?, + actions: MappingActions, + onDismiss: () -> Unit, +) { + when (dialog) { + null -> Unit + is MappingDialog.Save -> TextInputDialog( + title = stringResource(R.string.controller_mapping_save_layout), + fieldLabel = stringResource(R.string.controller_mapping_layout_name), + defaultValue = MappingLayouts.nextName( + stringResource(R.string.controller_mapping_layout_custom), + state.takenNames(dialog.body == null), + ), + confirmLabel = stringResource(R.string.controller_mapping_save), + dismissLabel = stringResource(R.string.controller_mapping_cancel), + onConfirm = { name -> + actions.saveLayout(dialog.body, name) + onDismiss() + }, + onDismiss = onDismiss, + ) + is MappingDialog.Delete -> ConfirmDialog( + title = stringResource(R.string.controller_mapping_delete_layout), + body = stringResource(R.string.controller_mapping_delete_layout_body, dialog.name), + confirmLabel = stringResource(R.string.controller_mapping_delete), + dismissLabel = stringResource(R.string.controller_mapping_cancel), + onConfirm = { + actions.deleteLayout(dialog.id, dialog.global) + onDismiss() + }, + onDismiss = onDismiss, + ) } } + +private sealed interface MappingDialog { + /** Null means the whole session. */ + data class Save(val body: PlayerBody?) : MappingDialog + + data class Delete(val id: String, val name: String, val global: Boolean) : MappingDialog +} diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingUiState.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingUiState.kt new file mode 100644 index 0000000..e9d8d00 --- /dev/null +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingUiState.kt @@ -0,0 +1,49 @@ +package com.joegec.joycon2android.buttonmapping.presentation + +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.GlobalMapping +import com.joegec.joycon2android.buttonmapping.MappingLayout +import com.joegec.joycon2android.buttonmapping.MappingLayouts +import com.joegec.joycon2android.buttonmapping.PlayerBody +import com.joegec.joycon2android.buttonmapping.PlayerMapping +import com.joegec.joycon2android.buttonmapping.SavedLayout + +data class ControllerMappingUiState( + val console: Console, + val global: GlobalMapping, + val players: List, +) + +data class PlayerMappingUiState( + val body: PlayerBody, + val layouts: List, + val layout: MappingLayout?, + val sidewaysRemote: Boolean, + val offersSidewaysRemote: Boolean, + val mapping: Map, +) + +internal fun controllerMappingUiState( + console: Console, + mapping: GlobalMapping, + savedLayouts: List, +) = ControllerMappingUiState( + console = console, + global = mapping, + players = mapping.players.map { + it.uiState(console, MappingLayouts.forBody(console, it.body.side, savedLayouts)) + }, +) + +private fun PlayerMapping.uiState(console: Console, layouts: List) = PlayerMappingUiState( + body = body, + layouts = layouts, + layout = layout, + sidewaysRemote = sidewaysRemote, + offersSidewaysRemote = MappingOptions.offersSidewaysRemote(console, body.side), + mapping = entries, +) + +internal fun ControllerMappingUiState.takenNames(session: Boolean): List = + if (session) global.savedLayouts.map { it.name } + else players.flatMap { it.layouts }.filterIsInstance().map { it.name }.distinct() diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingViewModel.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingViewModel.kt index 414dd8e..a19b856 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingViewModel.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingViewModel.kt @@ -2,40 +2,92 @@ package com.joegec.joycon2android.buttonmapping.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.joegec.joycon2android.buttonmapping.ApplyGlobalLayoutUseCase +import com.joegec.joycon2android.buttonmapping.ApplyMappingLayoutUseCase import com.joegec.joycon2android.buttonmapping.Console -import com.joegec.joycon2android.buttonmapping.JoyconSide -import com.joegec.joycon2android.buttonmapping.ObserveControllerMappingUseCase -import com.joegec.joycon2android.buttonmapping.ResetControllerMappingUseCase +import com.joegec.joycon2android.buttonmapping.DeleteCustomLayoutUseCase +import com.joegec.joycon2android.buttonmapping.DeleteGlobalLayoutUseCase +import com.joegec.joycon2android.buttonmapping.ObserveGlobalMappingUseCase +import com.joegec.joycon2android.buttonmapping.ObserveSavedLayoutsUseCase +import com.joegec.joycon2android.buttonmapping.PlayerBody +import com.joegec.joycon2android.buttonmapping.SaveCustomLayoutUseCase +import com.joegec.joycon2android.buttonmapping.SaveGlobalLayoutUseCase import com.joegec.joycon2android.buttonmapping.SetControllerMappingUseCase +import com.joegec.joycon2android.buttonmapping.SetSidewaysRemoteUseCase +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -/** Feature-scoped state holder for the controller mapping editor screen. */ +@OptIn(ExperimentalCoroutinesApi::class) class ControllerMappingViewModel( - private val observeControllerMapping: ObserveControllerMappingUseCase, + private val observeGlobalMapping: ObserveGlobalMappingUseCase, + private val observeSavedLayouts: ObserveSavedLayoutsUseCase, + private val applyMappingLayout: ApplyMappingLayoutUseCase, + private val applyGlobalLayout: ApplyGlobalLayoutUseCase, private val setControllerMapping: SetControllerMappingUseCase, - private val resetControllerMapping: ResetControllerMappingUseCase, + private val setSidewaysRemote: SetSidewaysRemoteUseCase, + private val saveCustomLayout: SaveCustomLayoutUseCase, + private val saveGlobalLayout: SaveGlobalLayoutUseCase, + private val deleteCustomLayout: DeleteCustomLayoutUseCase, + private val deleteGlobalLayout: DeleteGlobalLayoutUseCase, ) : ViewModel() { - private val mappingFlows = mutableMapOf, StateFlow>>() + private val editing = MutableStateFlow(null) - fun mapping(console: Console, side: JoyconSide): StateFlow> = - mappingFlows.getOrPut(console to side) { - observeControllerMapping(console, side) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyMap()) - } + val uiState: StateFlow = editing + .flatMapLatest { target -> target?.let(::observe) ?: flowOf(null) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), null) - fun setMapping(console: Console, side: JoyconSide, targetKey: String, sourceId: String) { - viewModelScope.launch { setControllerMapping(console, side, targetKey, sourceId) } + fun edit(console: Console, bodies: List) { + editing.value = MappingTarget(console, bodies) } - fun resetMapping(console: Console, side: JoyconSide) { - viewModelScope.launch { resetControllerMapping(console, side) } + fun selectLayout(body: PlayerBody, layoutId: String) = onTarget { + applyMappingLayout(it.console, body, layoutId) + } + + fun selectGlobalLayout(layoutId: String) = onTarget { + applyGlobalLayout(it.console, it.bodies, layoutId) + } + + fun setMapping(body: PlayerBody, targetKey: String, sourceId: String) = onTarget { + setControllerMapping(it.console, body, targetKey, sourceId) + } + + fun setSidewaysRemoteEnabled(body: PlayerBody, enabled: Boolean) = onTarget { + setSidewaysRemote(it.console, body, enabled) + } + + /** Null means the whole session. */ + fun saveLayout(body: PlayerBody?, name: String) = onTarget { target -> + if (body == null) saveGlobalLayout(target.console, target.bodies, name) + else saveCustomLayout(target.console, body, name) + } + + fun deleteLayout(layoutId: String, global: Boolean) = onTarget { + if (global) deleteGlobalLayout(layoutId) else deleteCustomLayout(layoutId) + } + + private fun observe(target: MappingTarget): Flow = combine( + observeGlobalMapping(target.console, target.bodies), + observeSavedLayouts(target.console), + ) { mapping, saved -> controllerMappingUiState(target.console, mapping, saved) } + + private fun onTarget(block: suspend (MappingTarget) -> Unit) { + val target = editing.value ?: return + viewModelScope.launch { block(target) } } private companion object { const val STOP_TIMEOUT_MS = 5_000L } } + +private data class MappingTarget(val console: Console, val bodies: List) diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/LayoutLabels.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/LayoutLabels.kt new file mode 100644 index 0000000..d561eed --- /dev/null +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/LayoutLabels.kt @@ -0,0 +1,81 @@ +package com.joegec.joycon2android.buttonmapping.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.GlobalLayout +import com.joegec.joycon2android.buttonmapping.GlobalMapping +import com.joegec.joycon2android.buttonmapping.LayoutFamily +import com.joegec.joycon2android.buttonmapping.MappingLayout +import com.joegec.joycon2android.buttonmapping.SavedLayout +import com.joegec.joycon2android.buttonmapping.preset.GameCubeMapping +import com.joegec.joycon2android.buttonmapping.preset.JoyconWiiMapping +import com.joegec.joycon2android.buttonmapping.preset.MappingPreset +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets +import com.joegec.joycon2android.buttonmapping.preset.MarioKartNunchukMapping +import com.joegec.joycon2android.buttonmapping.preset.MarioKartWheelMapping +import com.joegec.joycon2android.buttonmapping.preset.SwitchProMapping +import com.joegec.joycon2android.buttonmapping.preset.WiiMapping +import com.joegec.joycon2android.core.buttonmapping.presentation.R +import com.joegec.joycon2android.ui.components.DropdownOption + +/** Resolved in composition, the only place resources can be read. Saved layouts carry their own name. */ +class LayoutLabels internal constructor( + private val names: Map, + private val descriptions: Map, + private val families: Map, +) { + fun name(layout: MappingLayout): String = + if (layout is SavedLayout) layout.name else names.getValue(layout as MappingPreset) + + fun name(layout: GlobalLayout): String = layout.name + + fun name(family: LayoutFamily): String = families.getValue(family) + + fun description(layout: MappingLayout): String? = descriptions[layout] +} + +@Composable +internal fun rememberLayoutLabels(): LayoutLabels { + val presets = Console.entries.flatMap(MappingPresets::forConsole).distinct() + return LayoutLabels( + names = presets.associateWith { nameOf(it) }, + descriptions = presets.mapNotNull { preset -> descriptionOf(preset)?.let { preset to it } }.toMap(), + families = LayoutFamily.entries.associateWith { nameOf(it) }, + ) +} + +@Composable +private fun nameOf(preset: MappingPreset): String = when (preset) { + GameCubeMapping, SwitchProMapping -> stringResource(R.string.layout_standard) + WiiMapping -> stringResource(R.string.layout_wii) + JoyconWiiMapping -> stringResource(R.string.layout_joycon) + MarioKartWheelMapping -> stringResource(R.string.layout_mario_kart_wheel) + MarioKartNunchukMapping -> stringResource(R.string.layout_mario_kart_nunchuk) +} + +@Composable +private fun descriptionOf(preset: MappingPreset): String? = when (preset) { + GameCubeMapping, SwitchProMapping -> null // the only layout their console offers + WiiMapping -> stringResource(R.string.layout_wii_description) + JoyconWiiMapping -> stringResource(R.string.layout_joycon_description) + MarioKartWheelMapping -> stringResource(R.string.layout_mario_kart_wheel_description) + MarioKartNunchukMapping -> stringResource(R.string.layout_mario_kart_nunchuk_description) +} + +@Composable +private fun nameOf(family: LayoutFamily): String = when (family) { + LayoutFamily.MARIO_KART -> stringResource(R.string.layout_family_mario_kart) +} + +internal fun LayoutLabels.option(layout: MappingLayout) = DropdownOption( + id = layout.id, + label = name(layout), + subLabel = description(layout), + deletable = layout is SavedLayout, +) + +fun LayoutLabels.sessionName(global: GlobalMapping): String? = + global.matchingSaved?.let(::name) + ?: global.sharedLayout?.let(::name) + ?: global.sharedFamily?.let(::name) diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/LayoutRow.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/LayoutRow.kt new file mode 100644 index 0000000..e031cc2 --- /dev/null +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/LayoutRow.kt @@ -0,0 +1,84 @@ +package com.joegec.joycon2android.buttonmapping.presentation + +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.joegec.joycon2android.core.buttonmapping.presentation.R +import com.joegec.joycon2android.ui.components.DropdownOption +import com.joegec.joycon2android.ui.components.OptionDropdown +import com.joegec.joycon2android.ui.theme.Accent +import com.joegec.joycon2android.ui.theme.Dimens +import com.joegec.joycon2android.ui.theme.TextDim + +@Composable +fun LayoutRow( + options: List, + selectedId: String?, + layoutName: String?, + onSelect: (String) -> Unit, + onSave: () -> Unit, + onDelete: (DropdownOption) -> Unit, + modifier: Modifier = Modifier, + subLabel: String? = null, +) { + val context = LocalContext.current + + Row( + modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Dimens.elementSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + OptionDropdown( + options = options, + selectedId = selectedId, + label = layoutName ?: stringResource(R.string.controller_mapping_layout_custom), + subLabel = subLabel, + onSelect = onSelect, + onDelete = onDelete, + onUnavailable = { option -> + context.toast( + R.string.controller_mapping_layout_unavailable, + option.label, + option.subLabel.orEmpty(), + ) + }, + modifier = Modifier.weight(1f), + ) + SaveButton(layoutName, onSave) + } +} + +/** Dimmed once the mapping already reads as a layout, rather than saving a twin. */ +@Composable +private fun SaveButton(layoutName: String?, onSave: () -> Unit) { + val context = LocalContext.current + + IconButton( + onClick = { + if (layoutName == null) onSave() + else context.toast(R.string.controller_mapping_layout_already_saved, layoutName) + }, + ) { + Icon( + Icons.Filled.Save, + contentDescription = stringResource(R.string.controller_mapping_save_layout), + tint = if (layoutName == null) Accent else TextDim, + modifier = Modifier.size(Dimens.iconSizeMedium), + ) + } +} + +private fun Context.toast(messageRes: Int, vararg args: String) = + Toast.makeText(this, getString(messageRes, *args), Toast.LENGTH_LONG).show() diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingActions.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingActions.kt new file mode 100644 index 0000000..688cf83 --- /dev/null +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingActions.kt @@ -0,0 +1,14 @@ +package com.joegec.joycon2android.buttonmapping.presentation + +import androidx.compose.runtime.Immutable +import com.joegec.joycon2android.buttonmapping.PlayerBody + +@Immutable +class MappingActions( + val selectLayout: (body: PlayerBody, layoutId: String) -> Unit, + val selectGlobalLayout: (layoutId: String) -> Unit, + val saveLayout: (body: PlayerBody?, name: String) -> Unit, + val deleteLayout: (layoutId: String, global: Boolean) -> Unit, + val setMapping: (body: PlayerBody, targetKey: String, sourceId: String) -> Unit, + val setSidewaysRemote: (body: PlayerBody, enabled: Boolean) -> Unit, +) diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingBindings.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingBindings.kt new file mode 100644 index 0000000..4448d7c --- /dev/null +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingBindings.kt @@ -0,0 +1,58 @@ +package com.joegec.joycon2android.buttonmapping.presentation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.sourceIdOf +import com.joegec.joycon2android.buttonmapping.sourceIdsOf +import com.joegec.joycon2android.ui.components.MultiSelectDropdown +import com.joegec.joycon2android.ui.theme.Dimens +import com.joegec.joycon2android.ui.theme.TextDim + +@Composable +fun MappingBindings(console: Console, state: PlayerMappingUiState, actions: MappingActions) { + Column(verticalArrangement = Arrangement.spacedBy(Dimens.elementSpacing)) { + val sourceOptions = MappingOptions.sources(state.body.side) + MappingOptions.targets(console).forEach { (key, label) -> + val selectedIds = sourceIdsOf(state.mapping[key].orEmpty()) + BindingRow(label, selectedIds, sourceOptions) { toggled -> + actions.setMapping(state.body, key, sourceIdOf(selectedIds.toggling(toggled))) + } + } + } +} + +@Composable +private fun BindingRow( + label: String, + selectedIds: List, + options: List>, + onToggle: (String) -> Unit, +) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Dimens.elementSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, color = TextDim, modifier = Modifier.weight(1f)) + MultiSelectDropdown( + options = options, + selectedIds = selectedIds, + onToggle = onToggle, + modifier = Modifier.weight(1f), + ) + } +} + +/** Any source can fire a target, so picking one adds it; picking "None" empties the row. */ +private fun List.toggling(sourceId: String): List = when { + sourceId == MappingOptions.NONE_ID -> emptyList() + sourceId in this -> this - sourceId + else -> this + sourceId +} diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingLabels.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingLabels.kt new file mode 100644 index 0000000..b7fea0a --- /dev/null +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingLabels.kt @@ -0,0 +1,123 @@ +package com.joegec.joycon2android.buttonmapping.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.GlobalLayout +import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.StickDirection +import com.joegec.joycon2android.buttonmapping.StickSource +import com.joegec.joycon2android.buttonmapping.target.GameCubeButton +import com.joegec.joycon2android.buttonmapping.target.GameCubeStick +import com.joegec.joycon2android.buttonmapping.target.SwitchProButton +import com.joegec.joycon2android.buttonmapping.target.SwitchProStick +import com.joegec.joycon2android.buttonmapping.target.WiimoteButton +import com.joegec.joycon2android.buttonmapping.target.WiimoteStick +import com.joegec.joycon2android.core.buttonmapping.presentation.R + +@Composable +internal fun Console.label(): String = when (this) { + Console.GAMECUBE -> stringResource(R.string.console_gamecube) + Console.WIIMOTE_NUNCHUK -> stringResource(R.string.console_wiimote_nunchuk) + Console.SWITCH_PRO -> stringResource(R.string.console_switch_pro) +} + +/** Short enough to sit in a list of players, as in "P1 L, P2 R". */ +@Composable +internal fun JoyconSide.shortLabel(): String = when (this) { + JoyconSide.LEFT -> stringResource(R.string.side_left_short) + JoyconSide.RIGHT -> stringResource(R.string.side_right_short) + JoyconSide.DUAL -> stringResource(R.string.side_dual_short) +} + +@Composable +internal fun GlobalLayout.playerSummary(): String = bodies + .map { stringResource(R.string.player_body, it.body.player.index, it.body.side.shortLabel()) } + .joinToString(", ") + +@Composable +internal fun StickDirection.label(): String = when (this) { + StickDirection.UP -> stringResource(R.string.direction_up) + StickDirection.DOWN -> stringResource(R.string.direction_down) + StickDirection.LEFT -> stringResource(R.string.direction_left) + StickDirection.RIGHT -> stringResource(R.string.direction_right) +} + +@Composable +internal fun StickSource.label(): String = when (this) { + StickSource.LEFT_STICK -> stringResource(R.string.stick_left) + StickSource.RIGHT_STICK -> stringResource(R.string.stick_right) +} + +@Composable +internal fun GameCubeButton.label(): String = when (this) { + GameCubeButton.A -> "A" + GameCubeButton.B -> "B" + GameCubeButton.X -> "X" + GameCubeButton.Y -> "Y" + GameCubeButton.Z -> "Z" + GameCubeButton.Start -> stringResource(R.string.button_start) + GameCubeButton.TriggerL -> "L" + GameCubeButton.TriggerR -> "R" + GameCubeButton.DPadUp -> stringResource(R.string.button_dpad_up) + GameCubeButton.DPadDown -> stringResource(R.string.button_dpad_down) + GameCubeButton.DPadLeft -> stringResource(R.string.button_dpad_left) + GameCubeButton.DPadRight -> stringResource(R.string.button_dpad_right) +} + +@Composable +internal fun GameCubeStick.label(): String = when (this) { + GameCubeStick.MainStick -> stringResource(R.string.stick_main) + GameCubeStick.CStick -> stringResource(R.string.stick_c) +} + +@Composable +internal fun WiimoteButton.label(): String = when (this) { + WiimoteButton.A -> "A" + WiimoteButton.B -> "B" + WiimoteButton.One -> "1" + WiimoteButton.Two -> "2" + WiimoteButton.Home -> stringResource(R.string.button_home) + WiimoteButton.Plus -> "+" + WiimoteButton.Minus -> "-" + WiimoteButton.DPadUp -> stringResource(R.string.button_dpad_up) + WiimoteButton.DPadDown -> stringResource(R.string.button_dpad_down) + WiimoteButton.DPadLeft -> stringResource(R.string.button_dpad_left) + WiimoteButton.DPadRight -> stringResource(R.string.button_dpad_right) + WiimoteButton.NunchukC -> stringResource(R.string.button_nunchuk_c) + WiimoteButton.NunchukZ -> stringResource(R.string.button_nunchuk_z) + WiimoteButton.Shake -> stringResource(R.string.button_shake) +} + +@Composable +internal fun WiimoteStick.label(): String = when (this) { + WiimoteStick.NunchukStick -> stringResource(R.string.stick_nunchuk) +} + +@Composable +internal fun SwitchProButton.label(): String = when (this) { + SwitchProButton.A -> "A" + SwitchProButton.B -> "B" + SwitchProButton.X -> "X" + SwitchProButton.Y -> "Y" + SwitchProButton.L -> "L" + SwitchProButton.R -> "R" + SwitchProButton.ZL -> "ZL" + SwitchProButton.ZR -> "ZR" + SwitchProButton.Plus -> "+" + SwitchProButton.Minus -> "-" + SwitchProButton.Home -> stringResource(R.string.button_home) + SwitchProButton.Capture -> stringResource(R.string.button_capture) + SwitchProButton.LStickClick -> stringResource(R.string.button_lstick_click) + SwitchProButton.RStickClick -> stringResource(R.string.button_rstick_click) + SwitchProButton.DPadUp -> stringResource(R.string.button_dpad_up) + SwitchProButton.DPadDown -> stringResource(R.string.button_dpad_down) + SwitchProButton.DPadLeft -> stringResource(R.string.button_dpad_left) + SwitchProButton.DPadRight -> stringResource(R.string.button_dpad_right) +} + +@Composable +internal fun SwitchProStick.label(): String = when (this) { + SwitchProStick.LStick -> stringResource(R.string.stick_l) + SwitchProStick.RStick -> stringResource(R.string.stick_r) +} diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt index 7896f3b..7e14f19 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt @@ -1,5 +1,7 @@ package com.joegec.joycon2android.buttonmapping.presentation +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.JoyconSide import com.joegec.joycon2android.buttonmapping.MappingSource @@ -12,35 +14,55 @@ import com.joegec.joycon2android.buttonmapping.target.SwitchProButton import com.joegec.joycon2android.buttonmapping.target.SwitchProStick import com.joegec.joycon2android.buttonmapping.target.WiimoteButton import com.joegec.joycon2android.buttonmapping.target.WiimoteStick +import com.joegec.joycon2android.core.buttonmapping.presentation.R import com.joegec.joycon2android.model.JoyconButton -/** The (storage key, label) rows and (source id, label) choices the mapping editor offers. */ internal object MappingOptions { const val NONE_ID = "" - fun buttonTargets(console: Console): List> = when (console) { - Console.GAMECUBE -> GameCubeButton.entries.map { it.name to it.displayName } - Console.WIIMOTE_NUNCHUK -> WiimoteButton.entries.map { it.name to it.displayName } - Console.SWITCH_PRO -> SwitchProButton.entries.map { it.name to it.displayName } + fun offersSidewaysRemote(console: Console, side: JoyconSide) = + console == Console.WIIMOTE_NUNCHUK && side != JoyconSide.DUAL + + @Composable + fun targets(console: Console): List> = + buttonTargets(console) + stickDirectionTargets(console) + motionTargets(console) + + @Composable + private fun buttonTargets(console: Console): List> = when (console) { + Console.GAMECUBE -> GameCubeButton.entries.map { it.name to it.label() } + Console.WIIMOTE_NUNCHUK -> (WiimoteButton.entries - MOTION_TARGETS).map { it.name to it.label() } + Console.SWITCH_PRO -> SwitchProButton.entries.map { it.name to it.label() } } - fun stickDirectionTargets(console: Console): List> { + // Shake is a motion, so it's listed after the sticks. + private val MOTION_TARGETS = setOf(WiimoteButton.Shake) + + @Composable + private fun motionTargets(console: Console): List> = + if (console == Console.WIIMOTE_NUNCHUK) MOTION_TARGETS.map { it.name to it.label() } else emptyList() + + @Composable + private fun stickDirectionTargets(console: Console): List> { val sticks = when (console) { - Console.GAMECUBE -> GameCubeStick.entries.map { it to it.displayName } - Console.WIIMOTE_NUNCHUK -> WiimoteStick.entries.map { it to it.displayName } - Console.SWITCH_PRO -> SwitchProStick.entries.map { it to it.displayName } + Console.GAMECUBE -> GameCubeStick.entries.map { it to it.label() } + Console.WIIMOTE_NUNCHUK -> WiimoteStick.entries.map { it to it.label() } + Console.SWITCH_PRO -> SwitchProStick.entries.map { it to it.label() } } return sticks.flatMap { (stick, label) -> StickDirection.entries.map { direction -> - stick.directionKey(direction) to "$label ${direction.displayName}" + stick.directionKey(direction) to stringResource(R.string.source_direction, label, direction.label()) } } } + @Composable fun sources(side: JoyconSide): List> = - listOf(NONE_ID to "None") + physicalButtons(side).map { it.name to it.id } + stickDirections(side) + listOf(NONE_ID to stringResource(R.string.source_none)) + + physicalButtons(side).map { it.name to it.id } + + stickDirections(side) // A lone Joy-Con has one stick, so its directions need no "Left"/"Right" to tell them apart. + @Composable private fun stickDirections(side: JoyconSide): List> { val sticks = when (side) { JoyconSide.DUAL -> StickSource.entries @@ -48,14 +70,13 @@ internal object MappingOptions { JoyconSide.RIGHT -> listOf(StickSource.RIGHT_STICK) } return sticks.flatMap(MappingSource::directionsOf).map { source -> - val stickLabel = if (side == JoyconSide.DUAL) source.stick.displayName else "Stick" - source.id to "$stickLabel ${source.direction.displayName}" + val stick = + if (side == JoyconSide.DUAL) source.stick.label() else stringResource(R.string.stick_lone) + source.id to stringResource(R.string.source_direction, stick, source.direction.label()) } } - // The buttons a real, lone Joy-Con of that side can actually produce — matches what the physical - // hardware has, so a mapping chosen here can always fire (see JoyconButton for the full set; SL/SR - // are split per side, A/B/X/Y/Home/C only exist on the right Joy-Con, the d-pad only on the left). + // Only what that side physically has, so every choice can fire. private fun physicalButtons(side: JoyconSide): List = when (side) { JoyconSide.DUAL -> JoyconButton.entries JoyconSide.LEFT -> listOf( diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt new file mode 100644 index 0000000..c2c876e --- /dev/null +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt @@ -0,0 +1,171 @@ +package com.joegec.joycon2android.buttonmapping.presentation + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.core.buttonmapping.presentation.R +import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.PlayerState +import com.joegec.joycon2android.ui.components.DropdownOption +import com.joegec.joycon2android.ui.components.SettingSwitch +import com.joegec.joycon2android.ui.theme.Accent +import com.joegec.joycon2android.ui.theme.CardBg +import com.joegec.joycon2android.ui.theme.Dimens +import com.joegec.joycon2android.ui.theme.JoyconDefaultColor +import com.joegec.joycon2android.ui.theme.TextDim +import com.joegec.joycon2android.ui.theme.joyconBorderColor + +@Composable +fun PlayerMappingCard( + console: Console, + player: PlayerState, + state: PlayerMappingUiState, + labels: LayoutLabels, + actions: MappingActions, + onSaveLayout: () -> Unit, + onDeleteLayout: (DropdownOption) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by rememberSaveable(state.body) { mutableStateOf(false) } + + Column( + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Dimens.cardCorner)) + .background(CardBg), + ) { + CardHeader(player, state.layout?.let(labels::name), expanded) { expanded = !expanded } + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column( + Modifier.padding( + start = Dimens.compactRowPaddingHorizontal, + end = Dimens.compactRowPaddingHorizontal, + bottom = Dimens.cardPadding, + ), + verticalArrangement = Arrangement.spacedBy(Dimens.elementSpacing), + ) { + LayoutRow( + options = state.layouts.map { labels.option(it) }, + selectedId = state.layout?.id, + layoutName = state.layout?.let(labels::name), + subLabel = state.layout?.let(labels::description), + onSelect = { actions.selectLayout(state.body, it) }, + onSave = onSaveLayout, + onDelete = onDeleteLayout, + ) + if (state.offersSidewaysRemote) { + SidewaysRemoteSwitch(state.body.side, state.sidewaysRemote) { + actions.setSidewaysRemote(state.body, it) + } + } + MappingBindings(console, state, actions) + } + } + } +} + +@Composable +private fun CardHeader(player: PlayerState, layoutName: String?, expanded: Boolean, onToggle: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding( + horizontal = Dimens.compactRowPaddingHorizontal, + vertical = Dimens.compactRowPaddingVertical, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimens.elementSpacing), + ) { + Text( + stringResource(R.string.player_label, player.player.index), + color = Accent, + style = MaterialTheme.typography.titleMedium, + ) + ControllerChips(player) + Text( + layoutName ?: stringResource(R.string.controller_mapping_layout_custom), + color = TextDim, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.End, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Icon( + if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = null, + tint = TextDim, + modifier = Modifier.size(Dimens.iconSizeSmall), + ) + } +} + +@Composable +private fun ControllerChips(player: PlayerState) { + Row(horizontalArrangement = Arrangement.spacedBy(Dimens.compactControllerGap)) { + if (player.hasPro) { + ControllerChip(R.string.controller_pro, player.left!!) + } else { + player.left?.let { ControllerChip(R.string.controller_left, it) } + player.right?.let { ControllerChip(R.string.controller_right, it) } + } + } +} + +@Composable +private fun ControllerChip(textRes: Int, joycon: ConnectedJoycon) { + Text( + stringResource(textRes), + color = joyconBorderColor(joycon.accentColor, JoyconDefaultColor), + style = MaterialTheme.typography.titleMedium, + ) +} + +/** A right Joy-Con aims from its tail: docs/dsu-motion.md#playing-as-a-sideways-wii-remote */ +@Composable +private fun SidewaysRemoteSwitch(side: JoyconSide, enabled: Boolean, onSetEnabled: (Boolean) -> Unit) { + val aimsFromItsTail = side == JoyconSide.RIGHT + SettingSwitch( + title = stringResource(R.string.controller_mapping_sideways_remote), + description = stringResource(R.string.controller_mapping_sideways_remote_description), + warning = stringResource(R.string.controller_mapping_sideways_remote_warning) + .takeIf { aimsFromItsTail }, + checked = enabled, + onCheckedChange = onSetEnabled, + ) +} diff --git a/core/buttonmapping/presentation/src/main/res/values/strings.xml b/core/buttonmapping/presentation/src/main/res/values/strings.xml index 232fb21..33f6eb2 100644 --- a/core/buttonmapping/presentation/src/main/res/values/strings.xml +++ b/core/buttonmapping/presentation/src/main/res/values/strings.xml @@ -1,4 +1,65 @@ Back - Reset to defaults + All players + Custom + Name + \"%1$s\" was saved for %2$s + Save layout + This is already \"%1$s\" + Save + Cancel + Delete layout? + Only the name goes — everyone playing on it keeps their buttons, and reads as \"Custom\" until you save it again. + Delete + Connect a controller to map it. + Sideways Wii Remote + Fixes motion steering. Up is towards the rail. + When enabled, a right Joy-Con points from its tail rather than its R edge. + P%1$d + Left + Right + Pro + Standard + Wii + Like a Wiimote, ZR \u2192 B + Joy-Con + True to Joy-Con buttons, B \u2192 B + Mario Kart Wheel + Steer with motion, mapped like MK8 + Mario Kart Nunchuck + Steer with the stick, mapped like MK8 + Mario Kart + Gamecube + Wiimote & Nunchuck + Joycons + L + R + L/R + P%1$d %2$s + Up + Down + Left + Right + Left Stick + Right Stick + Main Stick + C-Stick + Nunchuk Stick + L-Stick + R-Stick + Stick + Start + Home + Capture + Shake + Nunchuk C + Nunchuk Z + L-Stick Click + R-Stick Click + D-Pad Up + D-Pad Down + D-Pad Left + D-Pad Right + None + %1$s %2$s diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/CloseEmulatorDialog.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/CloseEmulatorDialog.kt index 0de38ba..93864ef 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/CloseEmulatorDialog.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/CloseEmulatorDialog.kt @@ -4,7 +4,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.joegec.joycon2android.core.designsystem.R -/** Consent for the one destructive step auto setup needs: closing the emulator before writing. */ @Composable fun CloseEmulatorDialog( emulatorName: String, diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/ConfirmDialog.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/ConfirmDialog.kt index a076ef9..27abfee 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/ConfirmDialog.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/ConfirmDialog.kt @@ -11,7 +11,6 @@ import com.joegec.joycon2android.ui.theme.Accent import com.joegec.joycon2android.ui.theme.CardBg import com.joegec.joycon2android.ui.theme.TextDim -/** Two-button dialog in the app's card styling: an accented confirm and a dimmed way out. */ @Composable fun ConfirmDialog( title: String, diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DolphinSetupButton.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DolphinSetupButton.kt index 2259c75..7b69288 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DolphinSetupButton.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DolphinSetupButton.kt @@ -20,7 +20,6 @@ import com.joegec.joycon2android.ui.theme.Dimens import com.joegec.joycon2android.ui.theme.ErrorText import com.joegec.joycon2android.ui.theme.TextOnAccent -/** Filled accent button (Scan-button styling) that runs a one-shot emulator config write. */ @Composable fun DolphinSetupButton( phase: DolphinSetupPhase, diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DropdownOption.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DropdownOption.kt new file mode 100644 index 0000000..26d086f --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DropdownOption.kt @@ -0,0 +1,10 @@ +package com.joegec.joycon2android.ui.components + +/** A disabled option stays visible, dimmed, so its reason can be explained on tap. */ +data class DropdownOption( + val id: String, + val label: String, + val subLabel: String? = null, + val available: Boolean = true, + val deletable: Boolean = false, +) diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DropdownTrigger.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DropdownTrigger.kt new file mode 100644 index 0000000..ec359c6 --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DropdownTrigger.kt @@ -0,0 +1,42 @@ +package com.joegec.joycon2android.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import com.joegec.joycon2android.ui.theme.Accent +import com.joegec.joycon2android.ui.theme.Dimens +import com.joegec.joycon2android.ui.theme.TextDim + +@Composable +internal fun DropdownTrigger(label: String, subLabel: String?, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Dimens.buttonCorner)) + .clickable(onClick = onClick) + .heightIn(min = Dimens.minTouchTarget) + .padding(horizontal = Dimens.emulatorPickerPadding), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(label, color = Accent, style = MaterialTheme.typography.labelMedium) + subLabel?.let { Text(it, color = TextDim, style = MaterialTheme.typography.labelSmall) } + } + Icon(Icons.Filled.ArrowDropDown, contentDescription = null, tint = Accent) + } +} diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/EmulatorAutoSetup.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/EmulatorAutoSetup.kt index 20350bc..cef6c80 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/EmulatorAutoSetup.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/EmulatorAutoSetup.kt @@ -34,7 +34,6 @@ import com.joegec.joycon2android.ui.theme.CardBg import com.joegec.joycon2android.ui.theme.Dimens import com.joegec.joycon2android.ui.theme.TextDim -/** Emulator picker paired with its one-shot config button, framed as a self-explaining group. */ @Composable fun EmulatorAutoSetup( emulators: List, diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/EmulatorDropdown.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/EmulatorDropdown.kt index d50e5c9..4edcd5e 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/EmulatorDropdown.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/EmulatorDropdown.kt @@ -1,30 +1,10 @@ package com.joegec.joycon2android.ui.components -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp import com.joegec.joycon2android.ui.theme.Accent import com.joegec.joycon2android.ui.theme.Dimens @@ -47,39 +27,11 @@ fun EmulatorDropdown( return } - var expanded by remember { mutableStateOf(false) } - var anchorWidth by remember { mutableStateOf(0.dp) } - val density = LocalDensity.current - - Box(modifier.onSizeChanged { anchorWidth = with(density) { it.width.toDp() } }) { - Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(Dimens.buttonCorner)) - .clickable { expanded = true } - .heightIn(min = Dimens.minTouchTarget) - .padding(horizontal = Dimens.emulatorPickerPadding), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - selected.label, - color = Accent, - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.weight(1f), - ) - Icon(Icons.Filled.ArrowDropDown, contentDescription = null, tint = Accent) - } - PanelDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - options = options.map { it.id to it.label }, - selectedId = selected.id, - modifier = Modifier.width(anchorWidth), - onSelect = { id -> - onSelect(id) - expanded = false - }, - ) - } + OptionDropdown( + options = options.map { DropdownOption(it.id, it.label) }, + selectedId = selected.id, + label = selected.label, + onSelect = onSelect, + modifier = modifier, + ) } diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/EmulatorOption.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/EmulatorOption.kt index f670819..b4ce63a 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/EmulatorOption.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/EmulatorOption.kt @@ -1,4 +1,4 @@ package com.joegec.joycon2android.ui.components -/** A selectable emulator in a setup dropdown. [id] is the package name; [label] is shown. */ +/** [id] is the package name. */ data class EmulatorOption(val id: String, val label: String) diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/LabeledBorderBox.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/LabeledBorderBox.kt index 980d6e6..4d958fd 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/LabeledBorderBox.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/LabeledBorderBox.kt @@ -22,13 +22,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import com.joegec.joycon2android.ui.theme.Dimens -/** - * Fieldset-style container: a rounded border with [label] straddling the top edge, its own - * background masking the border line behind it — like an HTML `` or an outlined text - * field's notched label. [labelBackground] must match the surface the box sits on for the mask - * to read as a clean gap. When [onInfoClick] is set, an info icon joins the label and the whole - * legend becomes its tap target. - */ +/** Fieldset-style box with [label] notched into the top border; [labelBackground] must match the surface behind. */ @Composable fun LabeledBorderBox( label: String, diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/LabeledDropdown.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/LabeledDropdown.kt deleted file mode 100644 index 9afbdb8..0000000 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/LabeledDropdown.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.joegec.joycon2android.ui.components - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight -import com.joegec.joycon2android.ui.theme.Accent -import com.joegec.joycon2android.ui.theme.Dimens - -/** Generic id/label picker for rows that aren't a fixed [EmulatorOption] list, e.g. mapping editors. */ -@Composable -fun LabeledDropdown( - options: List>, - selectedId: String, - onSelect: (String) -> Unit, - modifier: Modifier = Modifier, -) { - val selected = options.firstOrNull { it.first == selectedId } ?: options.firstOrNull() ?: return - var expanded by remember { mutableStateOf(false) } - - Box(modifier) { - Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(Dimens.buttonCorner)) - .clickable { expanded = true } - .padding(horizontal = Dimens.pillPaddingHorizontal, vertical = Dimens.pillPaddingVertical), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - selected.second, - color = Accent, - fontSize = Dimens.fontSizeSmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), - ) - Icon(Icons.Filled.ArrowDropDown, contentDescription = null, tint = Accent) - } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - options.forEach { (id, label) -> - DropdownMenuItem( - text = { Text(label) }, - onClick = { - onSelect(id) - expanded = false - }, - ) - } - } - } -} diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/MultiSelectDropdown.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/MultiSelectDropdown.kt new file mode 100644 index 0000000..9eb7825 --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/MultiSelectDropdown.kt @@ -0,0 +1,48 @@ +package com.joegec.joycon2android.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp + +/** Stays open while choices are ticked. The first option is the caller's "none", which empties the row and closes. */ +@Composable +fun MultiSelectDropdown( + options: List>, + selectedIds: List, + onToggle: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val none = options.firstOrNull() ?: return + val label = selectedIds.mapNotNull { id -> options.firstOrNull { it.first == id }?.second } + .takeIf { it.isNotEmpty() } + ?.joinToString(", ") + ?: none.second + var expanded by remember { mutableStateOf(false) } + var anchorWidth by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + + Box(modifier.onSizeChanged { anchorWidth = with(density) { it.width.toDp() } }) { + DropdownTrigger(label, subLabel = null) { expanded = true } + PanelDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + options = options.map { (id, text) -> DropdownOption(id, text) }, + selectedId = null, + ticked = selectedIds.toSet(), + // A source's name runs longer than an emulator's, so the panel may outgrow its trigger. + modifier = Modifier.widthIn(min = anchorWidth), + onSelect = { option -> + onToggle(option.id) + if (option.id == none.first) expanded = false + }, + ) + } +} diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/OptionDropdown.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/OptionDropdown.kt new file mode 100644 index 0000000..b0bf9e9 --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/OptionDropdown.kt @@ -0,0 +1,47 @@ +package com.joegec.joycon2android.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.joegec.joycon2android.ui.theme.Dimens + +/** [label] is passed in rather than derived, so a caller whose value is off the list can say so. */ +@Composable +fun OptionDropdown( + options: List, + selectedId: String?, + label: String, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, + subLabel: String? = null, + onUnavailable: (DropdownOption) -> Unit = {}, + onDelete: ((DropdownOption) -> Unit)? = null, +) { + var expanded by remember { mutableStateOf(false) } + var anchorWidth by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + + Box(modifier.onSizeChanged { anchorWidth = with(density) { it.width.toDp() } }) { + DropdownTrigger(label, subLabel) { expanded = true } + PanelDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + options = options, + selectedId = selectedId, + modifier = Modifier.width(anchorWidth), + onDelete = onDelete?.let { delete -> { option -> expanded = false; delete(option) } }, + onSelect = { option -> + expanded = false + if (option.available) onSelect(option.id) else onUnavailable(option) + }, + ) + } +} diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/PanelDropdownMenu.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/PanelDropdownMenu.kt index 848198a..02aa20a 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/PanelDropdownMenu.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/PanelDropdownMenu.kt @@ -1,15 +1,25 @@ package com.joegec.joycon2android.ui.components import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.DeleteOutline import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.joegec.joycon2android.core.designsystem.R import com.joegec.joycon2android.ui.theme.Accent import com.joegec.joycon2android.ui.theme.CardBg import com.joegec.joycon2android.ui.theme.Dimens @@ -19,10 +29,13 @@ import com.joegec.joycon2android.ui.theme.TextDim fun PanelDropdownMenu( expanded: Boolean, onDismissRequest: () -> Unit, - options: List>, - selectedId: String, - onSelect: (id: String) -> Unit, + options: List, + selectedId: String?, + onSelect: (DropdownOption) -> Unit, modifier: Modifier = Modifier, + onDelete: ((DropdownOption) -> Unit)? = null, + /** A menu of a set rather than a choice: every row keeps room for its tick. */ + ticked: Set? = null, ) { DropdownMenu( expanded = expanded, @@ -33,17 +46,63 @@ fun PanelDropdownMenu( tonalElevation = 0.dp, border = BorderStroke(Dimens.cardBorderWidth, TextDim), ) { - options.forEach { (id, label) -> + options.forEach { option -> DropdownMenuItem( - text = { - Text( - label, - color = if (id == selectedId) Accent else Color.White, - style = MaterialTheme.typography.labelMedium, - ) - }, - onClick = { onSelect(id) }, + text = { OptionText(option, selected = option.id == selectedId) }, + leadingIcon = tickSlot(ticked, option), + trailingIcon = deleteAction(option, onDelete), + onClick = { onSelect(option) }, ) } } } + +@Composable +private fun OptionText(option: DropdownOption, selected: Boolean) { + val color = when { + !option.available -> TextDim + selected -> Accent + else -> Color.White + } + Column { + Text(option.label, color = color, style = MaterialTheme.typography.labelMedium) + option.subLabel?.let { + Text(it, color = TextDim, style = MaterialTheme.typography.labelSmall) + } + } +} + +// Null rather than an empty composable, so a row with nothing to delete keeps no room for it. +private fun deleteAction( + option: DropdownOption, + onDelete: ((DropdownOption) -> Unit)?, +): (@Composable () -> Unit)? { + if (onDelete == null || !option.deletable) return null + return { + IconButton(onClick = { onDelete(option) }, modifier = Modifier.size(Dimens.iconButtonSize)) { + Icon( + Icons.Filled.DeleteOutline, + contentDescription = stringResource(R.string.dropdown_delete_option), + tint = TextDim, + modifier = Modifier.size(Dimens.iconSizeMedium), + ) + } + } +} + +// Every row of a set keeps the slot, ticked or not, or the labels would shift as one is chosen. +private fun tickSlot(ticked: Set?, option: DropdownOption): (@Composable () -> Unit)? { + if (ticked == null) return null + return { + if (option.id in ticked) { + Icon( + Icons.Filled.Check, + contentDescription = null, + tint = Accent, + modifier = Modifier.size(Dimens.iconSizeMedium), + ) + } else { + Spacer(Modifier.size(Dimens.iconSizeMedium)) + } + } +} diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/SettingSwitch.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/SettingSwitch.kt index e21ac49..3b29ffc 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/SettingSwitch.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/SettingSwitch.kt @@ -1,12 +1,17 @@ package com.joegec.joycon2android.ui.components +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.selection.toggleable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.WarningAmber +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults @@ -15,10 +20,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import com.joegec.joycon2android.core.designsystem.R import com.joegec.joycon2android.ui.theme.Accent import com.joegec.joycon2android.ui.theme.Dimens import com.joegec.joycon2android.ui.theme.TextDim +import com.joegec.joycon2android.ui.theme.WarningText @Composable fun SettingSwitch( @@ -28,6 +36,7 @@ fun SettingSwitch( onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, descriptionColor: Color = TextDim, + warning: String? = null, ) { Row( modifier @@ -39,6 +48,10 @@ fun SettingSwitch( Text(title, color = Color.White, style = MaterialTheme.typography.bodyMedium) Spacer(Modifier.height(Dimens.featureCardTitleGap)) Text(description, color = descriptionColor, style = MaterialTheme.typography.bodySmall) + warning?.let { + Spacer(Modifier.height(Dimens.featureCardTitleGap)) + SettingWarning(it) + } } Spacer(Modifier.width(Dimens.featureCardSwitchGap)) Switch( @@ -48,3 +61,16 @@ fun SettingSwitch( ) } } + +@Composable +private fun SettingWarning(text: String) { + Row(horizontalArrangement = Arrangement.spacedBy(Dimens.statusDotGap)) { + Icon( + Icons.Filled.WarningAmber, + contentDescription = stringResource(R.string.setting_warning), + tint = WarningText, + modifier = Modifier.size(Dimens.iconSizeTiny), + ) + Text(text, color = WarningText, style = MaterialTheme.typography.bodySmall) + } +} diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/StartEmulatorDialog.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/StartEmulatorDialog.kt index a165c5d..766ed21 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/StartEmulatorDialog.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/StartEmulatorDialog.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.joegec.joycon2android.core.designsystem.R -/** Offer to launch the emulator once its config is written — it only reads that config on start. */ +/** An emulator only reads its config on start. */ @Composable fun StartEmulatorDialog( emulatorName: String, diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/TextInputDialog.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/TextInputDialog.kt new file mode 100644 index 0000000..123c517 --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/TextInputDialog.kt @@ -0,0 +1,75 @@ +package com.joegec.joycon2android.ui.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import com.joegec.joycon2android.ui.theme.Accent +import com.joegec.joycon2android.ui.theme.CardBg +import com.joegec.joycon2android.ui.theme.TextDim + +/** [defaultValue] stands if nothing is typed, and clears when the field is tapped. */ +@Composable +fun TextInputDialog( + title: String, + fieldLabel: String, + defaultValue: String, + confirmLabel: String, + dismissLabel: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var text by rememberSaveable { mutableStateOf(defaultValue) } + var offering by rememberSaveable { mutableStateOf(true) } + + AlertDialog( + onDismissRequest = onDismiss, + containerColor = CardBg, + title = { Text(title, color = Color.White, style = MaterialTheme.typography.titleSmall) }, + text = { + OutlinedTextField( + value = text, + onValueChange = { + offering = false + text = it + }, + singleLine = true, + label = { Text(fieldLabel) }, + modifier = Modifier.onFocusChanged { focus -> + if (focus.isFocused && offering) { + offering = false + text = "" + } + }, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + focusedBorderColor = Accent, + unfocusedBorderColor = TextDim, + focusedLabelColor = Accent, + unfocusedLabelColor = TextDim, + cursorColor = Accent, + ), + ) + }, + confirmButton = { + TextButton(onClick = { onConfirm(text.trim().ifBlank { defaultValue }) }) { + Text(confirmLabel, color = Accent, fontWeight = FontWeight.Bold) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(dismissLabel, color = TextDim) } + }, + ) +} diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/AppTextStyles.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/AppTextStyles.kt index fab2efb..85d4b6f 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/AppTextStyles.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/AppTextStyles.kt @@ -6,28 +6,16 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -/** - * App-specific text roles the Material type scale doesn't cover. These live outside [Typography] - * because they aren't part of the reading hierarchy: [telemetry] is data-viz, [statusOverline] is - * a fixed chrome label. - */ +/** The two text roles that aren't reading hierarchy, so aren't in the scale: docs/DESIGN.md#typography. */ object AppType { - /** - * Live numeric readouts (IMU, stick coordinates, battery %, DSU port, config snippets). - * Tabular figures (`tnum`) keep digit columns aligned as values change; padding is stripped so - * the mono line sits tight against the graphics it annotates. Size is intentionally left to the - * call site — telemetry is sized to the control it labels, not to a hierarchy step. - */ + /** Sized by the caller: telemetry is sized to the control it labels, not to a hierarchy step. */ val telemetry = TextStyle( fontFamily = FontFamily.Monospace, fontFeatureSettings = "tnum", platformStyle = PlatformTextStyle(includeFontPadding = false), ) - /** - * Wide-tracked chrome label for the connection/Shizuku status line in the app bar. Line height - * is left at the font default so the two stacked status lines keep their breathing room. - */ + /** Line height stays at the font default, so the two stacked status lines keep their room. */ val statusOverline = TextStyle( fontWeight = FontWeight.Medium, fontSize = 11.sp, diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Color.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Color.kt index 10d96dd..734ca13 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Color.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Color.kt @@ -26,7 +26,6 @@ val CrosshairColor = Color(0xFF222C36) val BatteryHigh = Accent val BatteryMedium = Color(0xFFFBBF24) -// A lighter red than ErrorText so the low-battery readout clears WCAG AA on the AccentDim pill. val BatteryLow = Color(0xFFFF8A8A) private const val BATTERY_LOW_PERCENT = 20 @@ -40,8 +39,7 @@ fun batteryColor(percent: Int): Color = when { private const val ACCENT_SATURATION_BOOST = 1.4f -// A near-black shell would vanish when it fills a pressed control on the dark UI, so the active -// variant floors brightness while the thin border keeps the colour verbatim. +// Shell colour and its brightness floor: docs/DESIGN.md#color private const val ACTIVE_VALUE_FLOOR = 0.72f // accentColor is the controller's real shell accent read from SPI flash, packed as 0xRRGGBB. @@ -60,15 +58,13 @@ private fun boostedShellColor(accentColor: Int, valueFloor: Float): Color { ) } -/** The controller's shell colour as a hairline border; falls back when the shell reports no colour. */ fun joyconBorderColor(accentColor: Int?, fallback: Color): Color = if (accentColor == null) fallback else boostedShellColor(accentColor, valueFloor = 0f) -/** The shell colour raised to a brightness floor so it still reads as "lit" filling a pressed control. */ fun controllerActiveColor(accentColor: Int?, fallback: Color = JoyconDefaultColor): Color = if (accentColor == null) fallback else boostedShellColor(accentColor, ACTIVE_VALUE_FLOOR) -/** Dark ink or white, whichever has the higher WCAG contrast against [background]. */ +/** Whichever of dark ink or white has the higher WCAG contrast. */ fun readableInkOn(background: Color): Color = if (contrastRatio(TextOnAccent, background) >= contrastRatio(Color.White, background)) { TextOnAccent diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Dimens.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Dimens.kt index 9c044ba..0e27df6 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Dimens.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Dimens.kt @@ -30,6 +30,7 @@ object Dimens { val dpadSize = 46.dp val faceButtonSize = 46.dp val iconButtonSize = 36.dp + val iconSizeTiny = 14.dp // sits beside bodySmall text rather than standing on its own val iconSizeSmall = 18.dp val iconSizeMedium = 20.dp val progressIndicatorSmall = 14.dp @@ -87,9 +88,7 @@ object Dimens { val statusDotSize = 7.dp val statusDotGap = 6.dp - // Glyph and telemetry sizes, tuned to the controller graphics they sit on rather than to a - // text-hierarchy step. UI text goes through MaterialTheme.typography; telemetry readouts through - // AppType.telemetry, which these size. + // Sized to the controller graphics, outside the type scale: docs/DESIGN.md#typography val fontSizeButton = 14.sp // on-controller button label (SmallButton) val fontSizeSmall = 10.sp // on-controller rail label + code/table telemetry val fontSizeLabel = 9.sp // IMU / stick coordinate telemetry diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Type.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Type.kt index 720fb40..ff1ffcd 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Type.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Type.kt @@ -5,9 +5,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -// Fixed sp scale (product UI, not fluid), ~1.2 ratio between steps. Weight, colour, and tracking -// carry the hierarchy alongside size so adjacent steps never lean on size alone. Light-on-dark -// text gets a small line-height and tracking bump versus the Material defaults. +// Scale and rationale: docs/DESIGN.md#typography private val SemiBold = FontWeight.SemiBold val Typography = Typography( diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml index 39c06bd..16e3c6c 100644 --- a/core/designsystem/src/main/res/values/strings.xml +++ b/core/designsystem/src/main/res/values/strings.xml @@ -14,4 +14,6 @@ Not now Configure button mapping Auto setup might not work on every device. If the emulator doesn\'t respond to Joy-Con input after setting up, configure the controller manually in the emulator\'s own settings instead. + Delete + Warning diff --git a/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/DolphinControls.kt b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/DolphinControls.kt new file mode 100644 index 0000000..f4cf969 --- /dev/null +++ b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/DolphinControls.kt @@ -0,0 +1,13 @@ +package com.joegec.joycon2android.emulatorconfig + +import com.joegec.joycon2android.buttonmapping.StickDirection + +/** Wire tokens: Dolphin won't match a key spelled otherwise. */ +object DolphinControls { + val DIRECTIONS = mapOf( + StickDirection.UP to "Up", + StickDirection.DOWN to "Down", + StickDirection.LEFT to "Left", + StickDirection.RIGHT to "Right", + ) +} diff --git a/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/DolphinPaths.kt b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/DolphinPaths.kt index 5ad5ab0..bbbc13e 100644 --- a/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/DolphinPaths.kt +++ b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/DolphinPaths.kt @@ -1,10 +1,5 @@ package com.joegec.joycon2android.emulatorconfig -/** - * Dolphin's package and config-file locations on Android. Shared because both the DSU feature - * (motion input) and the Virtual Gamepad feature (GameCube pad mapping) write to the same app's - * external config dir — writable by a shell-uid process (Shizuku / wireless debugging), not by us. - */ object DolphinPaths { const val PACKAGE = "org.dolphinemu.dolphinemu" diff --git a/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/EdenControls.kt b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/EdenControls.kt index 2961024..ebfa0b9 100644 --- a/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/EdenControls.kt +++ b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/EdenControls.kt @@ -5,11 +5,6 @@ import com.joegec.joycon2android.buttonmapping.target.SwitchProButton import com.joegec.joycon2android.buttonmapping.target.SwitchProStick import com.joegec.joycon2android.model.PlayerState -/** - * The vocabulary of Eden's `config.ini` `[Controls]` section, shared by the two features that can - * bind a player there — the Virtual Gamepad as an Android HID pad, DSU as a cemuhook pad. Both - * write the same keys for the same player; only the device half of each binding differs. - */ object EdenControls { const val SECTION = "[Controls]" @@ -30,12 +25,7 @@ object EdenControls { val STICK_KEYS = mapOf(SwitchProStick.LStick to "lstick", SwitchProStick.RStick to "rstick") - /** - * Eden's npad type. Both single Joy-Cons report as Pro: Eden doesn't translate a sideways - * Joy-Con — it only sets an `is_horizontal` flag and masks an npad by type, so a JoyconLeft - * can't even report A/B/X/Y — so a normalised full controller is presented and the rotation - * done on our side instead. - */ + /** Both single Joy-Cons report as Pro: docs/virtual-gamepad.md#why-theyre-set-up-as-pro-controllers. */ fun npadType(player: PlayerState): Int? = when { player.hasPro -> PRO player.hasFullController -> DUAL_JOYCON @@ -45,11 +35,8 @@ object EdenControls { fun quote(value: String) = "\"$value\"" - /** - * A stick assembled from up to four digital inputs, each a whole binding of its own. Eden parses - * the nested bindings out of one value, so their `:`, `,` and `$` are escaped as `$0`, `$1` and - * `$2`, exactly as its ParamPackage serializes them. - */ + /** A stick built from up to four whole bindings nested in one value, escaped as Eden's + * ParamPackage serializes them: docs/virtual-gamepad.md#emulator-config. */ fun stickFromButtons(directions: Map): String = (listOf("engine:analog_from_button") + directions.map { (direction, binding) -> "${direction.name.lowercase()}:${escapeNested(binding)}" diff --git a/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/EdenPaths.kt b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/EdenPaths.kt index 3aa309b..2a609e2 100644 --- a/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/EdenPaths.kt +++ b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/EdenPaths.kt @@ -1,13 +1,6 @@ package com.joegec.joycon2android.emulatorconfig -/** - * Eden's packages and config-file location on Android. Shared because both the DSU feature - * (cemuhook motion input) and the Virtual Gamepad feature (Pro Controller mapping) write to the - * same `config.ini` — writable by a shell-uid process (Shizuku / wireless debugging), not by us. - * - * Stable and nightly install side by side under different package names, so a path is always - * derived from the package the user picked rather than assumed. - */ +/** Stable and nightly install side by side, so a path derives from the package the user picked. */ object EdenPaths { const val PACKAGE = "dev.eden.eden_emulator" const val NIGHTLY_PACKAGE = "dev.eden.eden_emulator.nightly" diff --git a/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/IniEditor.kt b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/IniEditor.kt index 2e074a6..c5e711b 100644 --- a/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/IniEditor.kt +++ b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/IniEditor.kt @@ -1,10 +1,6 @@ package com.joegec.joycon2android.emulatorconfig -/** - * Edits ini-format config text (read → transform → write), used to splice our settings into an - * emulator's config files without disturbing the user's other keys. Emulator-agnostic: Dolphin's - * `GCPadNew.ini`/`Dolphin.ini`, Eden's `config.ini`, etc. all share this section/key grammar. - */ +/** Splices keys into an emulator's ini, leaving the user's other keys and sections intact. */ object IniEditor { fun mergeSections(existing: String?, sections: Map): String { val bodies = LinkedHashMap() @@ -28,7 +24,6 @@ object IniEditor { return out.toString() } - /** The value of `key` in a `[section]`, or null when either is absent. */ fun valueOf(existing: String?, section: String, key: String): String? { val lines = existing?.lines() ?: return null val headerIndex = lines.indexOfFirst { it.trim() == section } @@ -42,7 +37,6 @@ object IniEditor { return null } - /** Removes keys in a `[section]` whose name matches [keyMatches], leaving other keys and sections intact. */ fun removeKeys(existing: String?, section: String, keyMatches: (String) -> Boolean): String { if (existing == null) return "" val lines = existing.lines() @@ -64,11 +58,7 @@ object IniEditor { return (lines.subList(0, headerIndex + 1) + kept + lines.subList(end, lines.size)).joinToString("\n") } - /** - * Sets `key = value` entries inside a single `[section]`, replacing matching keys and appending - * the rest, while leaving every other key and section intact. Used for shared files like - * Dolphin.ini where wholesale section replacement would wipe unrelated settings. - */ + /** Merges keys into one section rather than replacing it, for shared files like Dolphin.ini. */ fun setKeys( existing: String?, section: String, diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/BatteryGauge.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/BatteryGauge.kt index eb13df5..834e55d 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/BatteryGauge.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/BatteryGauge.kt @@ -3,10 +3,7 @@ package com.joegec.joycon2android.model import kotlin.math.roundToInt object BatteryGauge { - // The BLE packet reports a regulated/under-load voltage ~0.6 V below the true cell - // voltage (observed: ~3.30 V reads 75% on a Switch 2, ~3.60 V reads 100%). Anchors are - // Nintendo's Joy-Con level thresholds (dekuNukem docs: 3.3/3.6/3.76/3.9/4.2 V) shifted - // down 0.6 V to match. Below ~3.0 V is extrapolated — no low-battery readings observed yet. + // Anchors and the 0.6 V offset: docs/protocol.md#battery private val voltsToPercent = listOf( 2.70f to 0, 3.00f to 25, diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/ConnectedJoycon.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/ConnectedJoycon.kt index a9cc16d..839e9f2 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/ConnectedJoycon.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/ConnectedJoycon.kt @@ -9,6 +9,5 @@ data class ConnectedJoycon( val assignedPlayer: PlayerNumber? = null, val ready: Boolean = false, ) { - /** Shell accent color (0xRRGGBB) read from the controller's SPI flash, or null if not yet known. */ val accentColor: Int? get() = connectionState.accentColor } diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/EmulatorSetupResult.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/EmulatorSetupResult.kt index c32e66c..8df1668 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/EmulatorSetupResult.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/EmulatorSetupResult.kt @@ -1,15 +1,10 @@ package com.joegec.joycon2android.model -/** Outcome of a one-shot emulator config write. */ enum class EmulatorSetupResult { SUCCESS, NO_PRIVILEGED_ACCESS, - /** - * The emulator was running. Emulators hold their config in memory and flush it on exit, so a - * write while one is open is silently overwritten — verified against Eden, whose in-memory - * bindings replaced ours on shutdown. - */ + /** Emulators flush their in-memory config over ours on exit (seen on Eden), so it must close first. */ EMULATOR_RUNNING, FAILED, } diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconConnectionState.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconConnectionState.kt index fe860b8..92d5e24 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconConnectionState.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconConnectionState.kt @@ -6,6 +6,6 @@ data class JoyconConnectionState( val ready: Boolean = false, val deviceName: String? = null, val error: String? = null, - /** Shell accent color read from SPI flash, packed as 0xRRGGBB. Null until read (or unset on the controller). */ + /** 0xRRGGBB from SPI flash; null until read, or unset on the controller. */ val accentColor: Int? = null, ) diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/PlayerState.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/PlayerState.kt index 3af8521..223a322 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/PlayerState.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/PlayerState.kt @@ -10,7 +10,7 @@ data class PlayerState( val hasFullController: Boolean get() = left != null && right != null val isSideways: Boolean get() = hasController && !hasFullController && !hasPro - // Raw hardware button state (for UI display showing physical button activity) + // As the hardware reports it, before any sideways rotation ([gamepad]). val pressed: Set get() = if (hasPro) left!!.input.pressed else (left?.input?.pressed ?: emptySet()) + (right?.input?.pressed ?: emptySet()) @@ -23,11 +23,8 @@ data class PlayerState( val leftInput: JoyconInput get() = left?.input ?: JoyconInput() val rightInput: JoyconInput get() = right?.input ?: JoyconInput() - // IMU source for motion consumers: the right Joy-Con of a pair (the "Wiimote hand"), - // otherwise whichever controller is present + // A pair's right Joy-Con is the Wii Remote hand. val motionSource: ConnectedJoycon? get() = right ?: left - // Gamepad-oriented state (rotated sticks + remapped buttons for HID output and consumers - // that want standard gamepad semantics regardless of physical orientation) val gamepad: GamepadState get() = GamepadState.from(this) } diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/SidewaysMapper.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/SidewaysMapper.kt index 7f51f30..63d35a1 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/SidewaysMapper.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/SidewaysMapper.kt @@ -1,17 +1,6 @@ package com.joegec.joycon2android.model -/** - * Transforms raw Joy-Con input into gamepad-oriented values for sideways (single Joy-Con) mode. - * - * Left Joy-Con rotated 90° CCW, right Joy-Con 90° CW, matching how the Switch treats a lone - * Joy-Con: the stick axes rotate with the body, the four-button cluster becomes the face buttons in - * its rotated positions, and the SL/SR rail buttons fill in the shoulder pair the body lacks. - * - * The cluster must land on real face buttons rather than the HID hat. A sideways Joy-Con has no - * d-pad, so a virtual pad that reports its cluster as hat directions has no A/B/X/Y at all, and - * anything binding a hat axis without its sign — Eden's manual mapping does exactly this — cannot - * tell left from right or up from down. - */ +/** docs/virtual-gamepad.md#sidewaysmapper */ object SidewaysMapper { private const val STICK_MAX = 4096 @@ -28,8 +17,7 @@ object SidewaysMapper { fun remapButtonsRight(pressed: Set): Set = pressed.mapTo(mutableSetOf()) { RIGHT_REMAP[it] ?: it } - // D-pad rotates 90° CCW onto the faces: Right sits at the top, Down at the right, Left at the - // bottom, Up at the left. Rail buttons fill the missing right-hand shoulders. + // The cluster must land on real face buttons, never the hat: a sideways body has no d-pad. private val LEFT_REMAP = mapOf( JoyconButton.Right.id to JoyconButton.X.id, JoyconButton.Down.id to JoyconButton.A.id, @@ -39,8 +27,7 @@ object SidewaysMapper { JoyconButton.SrLeft.id to JoyconButton.ZR.id, ) - // Faces rotate 90° CW onto themselves: Y sits at the top, X at the right, A at the bottom, B at - // the left. Stick click becomes LS (the stick is now the left one); rails fill the left shoulders. + // Stick click becomes LS, the stick now being the left one. private val RIGHT_REMAP = mapOf( JoyconButton.Y.id to JoyconButton.X.id, JoyconButton.X.id to JoyconButton.A.id, diff --git a/core/session/src/main/kotlin/com/joegec/joycon2android/session/SessionCoordinator.kt b/core/session/src/main/kotlin/com/joegec/joycon2android/session/SessionCoordinator.kt index b18eaa8..afd65fd 100644 --- a/core/session/src/main/kotlin/com/joegec/joycon2android/session/SessionCoordinator.kt +++ b/core/session/src/main/kotlin/com/joegec/joycon2android/session/SessionCoordinator.kt @@ -15,16 +15,8 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch /** - * The cross-feature glue: joins connected controllers with player assignments into - * [AppUiState], drives the per-packet output pipeline, and orchestrates assignment - * (including its connection/gamepad side effects). - * - * Lives above the feature domains and depends only on their interfaces. The actual - * gamepad/DSU effects are injected as callbacks ([onState], [onPlayerAssigned], - * [onPlayerUnassigned]) so this module never depends on those features. - * - * [onState] fires synchronously on every emission — the controller list re-emits on each - * input change, so per-packet consumers (gamepad, DSU motion) see every update. + * Gamepad and DSU effects arrive as callbacks, so this never depends on those features. [onState] + * is synchronous: a conflated flow would drop per-packet motion. */ class SessionCoordinator( private val scope: CoroutineScope, diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 502c5f6..78933c2 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,25 +1,17 @@ # Design -> Compose-adapted design-system snapshot. This is a native Android (Jetpack Compose, -> Material 3) app, so this doc captures the **theme in code** rather than web CSS tokens. -> Source of truth: `core/designsystem/.../ui/theme/{Color,Type,Dimens,Theme,AppTextStyles}.kt`, -> plus the connection-screen chrome and landscape layout in `app/.../ui/JoyconScreen.kt`. -> Update this doc when those change. +The theme as built in `core/designsystem/.../ui/theme/` and the screen layout in +`app/.../ui/JoyconScreen.kt`. Update this doc when those change; who the app is for is in +[PRODUCT.md](PRODUCT.md). ## Theme -Dark-only. `Joycon2AndroidTheme` wraps Material 3 with a `darkColorScheme` that maps only -`primary`, `surface`, and `background`; the rest of the palette lives as top-level `Color` -vals consumed directly. Deep near-black blue-gray canvas, single teal accent, controller-color -accents on cards. No light scheme currently exists. - -Physical scene: an enthusiast at a desk or on a couch, often in a dimly lit room, mid-setup, -frequently holding a controller in the other hand. Dark is a deliberate fit, not a default. +Dark-only, deliberately: the app is used mid-setup, often in a dim room. `Joycon2AndroidTheme` maps +only `primary`, `surface` and `background` into a `darkColorScheme`; the rest of the palette is +top-level `Color` vals used directly. One teal accent, plus each controller's own shell colour. ## Color -Defined in `Color.kt`. Values are the real hex in code. - **Surfaces & ink** - `Background` / `surface` — `#0E1116` (deep near-black blue-gray canvas) - `CardBg` — `#161B22` (raised card surface) @@ -39,28 +31,22 @@ Defined in `Color.kt`. Values are the real hex in code. (a lighter red than `ErrorText` so the low % clears AA on the `AccentDim` pill). Shown with a battery icon whose fill tracks the level, so it isn't colour-only. -**Signature: controller shell color.** The controller's real shell accent is read from SPI flash -(packed `0xRRGGBB`), converted to HSV, saturation-boosted ×1.4 (capped). It drives two things: -- `joyconBorderColor()` — the card's hairline border (colour verbatim). -- `controllerActiveColor()` — the same hue with a brightness floor (0.72) so it reads as "lit" - filling a control; every live input inside a `JoyconCard` glows in it (pressed d-pad / face / - shoulder / rail / special buttons, and the stick ring + dot). Delivered via the - `LocalControllerAccent` CompositionLocal, so a dual pair lights each side in its own colour. - `readableInkOn()` picks dark-ink-or-white by WCAG contrast for the label on that fill. - -This is the app's identity move — the UI, not just its border, wears the colour of the actual -hardware. `JoyconBlue`/`JoyconRed` and the teal `Accent` are the fallbacks. Lean into this. +**Signature: the controller's shell colour.** The UI wears the colour of the actual hardware — the +app's identity move, so lean into it. The shell accent is read from SPI flash (`0xRRGGBB`) and +saturation-boosted ×1.4 (capped) in HSV. It drives: +- `joyconBorderColor()` — the card's hairline border, colour verbatim. +- `controllerActiveColor()` — the fill of every live input in a `JoyconCard` (pressed buttons, stick + ring and dot), with a brightness floor of 0.72: a near-black shell would otherwise vanish on the + dark UI. `ControllerAccent` provides it per card, so a pair lights each side in its own colour, and + `readableInkOn()` picks dark ink or white for the label on it. -**Color strategy:** Committed-dark — one teal accent doing most of the lifting, with the -controller shell color as a per-item second accent. Not restrained (the hardware color is -load-bearing), not full-palette. +`JoyconBlue` / `JoyconRed` and the teal `Accent` are the fallbacks. ## Typography -`Type.kt` defines a full Material 3 `Typography` — a fixed sp scale (product UI, not fluid), -~1.2 ratio, with weight/tracking carrying hierarchy alongside size and a small line-height + -tracking bump for light-on-dark. UI text is styled via `MaterialTheme.typography.*`; there are -no scattered `fontSize` literals in the UI. +`Type.kt` is a full Material 3 `Typography`: a fixed sp scale at ~1.2 ratio, with weight and +tracking carrying hierarchy alongside size, and a small line-height and tracking bump for +light-on-dark. UI text uses `MaterialTheme.typography.*`, never a `fontSize` literal. | Role | Size / LH | Weight | Use | |---|---|---|---| @@ -97,68 +83,54 @@ From `Dimens.kt` (all dp unless noted): variants, IMU/legend/battery-icon sub-scales, plus stick sub-tokens (`stickValueGap`, `stickAxisGap`, `crosshairStroke`, `stickIdleRingAlpha`) — fully tokenised, no hard-coded values -Card-based, but cards are the correct affordance here (each = one controller/feature). The -controller-color border gives them identity beyond a plain card grid. +Cards are the right affordance here — each is one controller or feature — and the shell-colour +border gives them identity beyond a plain grid. -### Connection-screen chrome (`app/.../ui/JoyconScreen.kt`) +### Connection-screen chrome -- **Edge-to-edge, top and bottom.** `contentWindowInsets` reserves only the horizontal insets, so - content passes under the transparent status bar and nav bar (`enableEdgeToEdge` in `MainActivity`). -- **Overlaid, scroll-away app bar.** The top app bar is *not* in the Scaffold `topBar` slot (which - reserves space and blocks content going behind the status bar). It's overlaid on the content and - translated up in lockstep with the scroll offset (`graphicsLayer`), so it slides away with no gap - and the content — including the Ko-fi banner, now the first scroll item rather than pinned — - passes behind the status bar. +In `JoyconScreen.kt`: + +- **Edge-to-edge.** `contentWindowInsets` reserves only the horizontal insets, so content passes + under the transparent status and nav bars; each screen adds its own clearance. +- **Overlaid, scroll-away app bar.** Not in the Scaffold's `topBar` slot, which would reserve space. + It overlays the content and is translated up in lockstep with the scroll, so it slides away with + no gap and content (the Ko-fi banner included) passes behind the status bar. Screens add its + height plus the status-bar inset as top clearance. ### Landscape -Landscape lays the whole connected screen out **two-up** to use the wide, short viewport; portrait -keeps single full-width columns. All of it lives in `JoyconScreen.kt`: +Two-up, to use the wide, short viewport; portrait keeps single columns. -- **Players** — a two-column grid (both detailed and compact views). Detailed players are shrunk to - `LandscapePlayerScale` (`0.7`) by a `scaleLayout` modifier that measures the content at `1/scale` - space, draws it scaled down, and reports the smaller size — so the whole controller (buttons, - labels, spacing) shrinks uniformly *and* reflows, letting a full player fit the short height. - Compact rows aren't scaled (already short). -- **Feature cards** — two columns: Virtual Gamepad with its Shizuku dependency stacked beneath it on - the left, DSU Motion Server on the right (so the Shizuku card always sits under the gamepad). -- **Scanning graphics** — the "Looking for Joy-Con 2" card and the sync-button illustration sit side - by side (`ScanningGraphics`). -- **Action buttons** — a row with Disconnect All on the left and Scan on the right; Disconnect keeps - its half (weighted spacer) while a scan is running and the Scan button drops out. +- **Players** — a two-column grid. Detailed players are shrunk to `LandscapePlayerScale` (0.7) by + `scaleLayout`, which scales the whole controller uniformly and reflows, so a full player fits the + short height. Compact rows aren't scaled. +- **Feature cards** — Virtual Gamepad with its Shizuku card beneath on the left, DSU on the right. +- **Scanning graphics** — the "Looking for Joy-Con 2" card and sync-button illustration side by side. +- **Action buttons** — Disconnect All left, Scan right; Disconnect keeps its half while Scan is hidden + during a scan. -Odd trailing items take a half cell with a weighted `Spacer` filling the other half. +An odd trailing item takes a half cell, a weighted `Spacer` filling the other half. ## Components Shared in `core/designsystem/.../ui/components/`: - `FeatureToggleCard` — the primary on/off feature surface (gamepad, DSU) -- `EmulatorDropdown` / `EmulatorOption` / `EmulatorAutoSetup` / `PanelDropdownMenu` — emulator picker + one-tap setup +- `OptionDropdown` / `DropdownOption` / `PanelDropdownMenu` — the app's picker: an accented current + value over a panel of alternatives, each row optionally sub-labelled, dimmed or deletable +- `EmulatorDropdown` / `EmulatorOption` / `EmulatorAutoSetup` — emulator picker + one-tap setup - `DolphinSetupButton` / `DolphinSetupPhase` / `CloseEmulatorDialog` — staged setup flow -- `SettingsRow` · `SettingSwitch` · `LabeledDropdown` — settings surfaces (e.g. Motion settings) +- `SettingsRow` · `SettingSwitch` — settings surfaces (e.g. Motion settings) +- `ConfirmDialog` · `TextInputDialog` — ask before a change lands, or ask it for a name - `ErrorBox` · `WarningBox` · `LabeledBorderBox` · `ExpandableInfoSection` · `CopyableCode` ## Motion -Mostly minimal and functional today: the top app bar sliding up in lockstep with the scroll (see -chrome above), the portrait view-mode `AnimatedContent` crossfade, and expand/fade transitions on -error boxes and feature-card content. Given the "playful gaming gear" personality, connect / -assign moments still have room for more character (opportunity area). Any motion must honor system -reduced-motion (see PRODUCT.md accessibility). Ease-out curves, no bounce/elastic. - -## Opportunity Areas (for future impeccable passes) - -1. ~~**Type hierarchy** — replace scattered `fontSize*` literals with a real Material type scale.~~ - ✅ Done: full Material 3 `Typography` + `AppType` telemetry/overline roles (see Typography above). -2. **Personality gap** — partly closed: each controller's live inputs now glow in its real shell - colour (see the controller-color signature above). Remaining: motion, and celebratory - connect/assign moments. -3. ~~**Contrast** — reduced-alpha telemetry labels + battery-low failed WCAG AA.~~ ✅ Done: - telemetry now uses solid `TextBright` (values) / `TextDim` (labels) with no sub-threshold alpha, - and `BatteryLow` was lightened to `#FF8A8A`; all clear 4.5:1 (verified numerically). -4. ~~**Color-only status** — pair battery/connection color with icon or text.~~ ✅ Largely addressed: - battery shows a level-filled icon + %, and the connection/Shizuku status pairs its dot with a - text label. -5. **Motion system** — define purposeful, reduced-motion-aware transitions for connect / assign. -6. **Responsive polish** — landscape grid + player scaling is in; a ≤320dp / 200%-font density - check on the dual layout is still open (needs a device). +Minimal and functional: the scroll-away app bar, the portrait view-mode crossfade, and expand/fade +on error boxes and feature-card content. Ease-out curves, no bounce or elastic, and always honour the +system's reduced-motion setting. + +## Open work + +- **Motion with character** — purposeful, reduced-motion-aware transitions for connect and assign + moments, which the "playful gaming gear" personality still lacks. +- **Density check** — the dual layout at ≤320dp and 200% font scale (needs a device). diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 70bdcac..0c3efa0 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -6,15 +6,13 @@ product ## Users -Android gaming and emulation enthusiasts who want to use Nintendo Switch 2 **Joy-Con 2** -controllers as system-wide gamepads on their phone or tablet. They are comfortable with -Developer Options, Shizuku, and per-emulator config — this is not a mainstream consumer -audience. Their context is hands-on: often mid-setup at a desk or on a couch, frequently -*holding a controller in one hand* while operating the app with the other, wanting to get -connected and into a game (or emulator) with as little friction as possible and then have -the app stay out of the way. - -The primary jobs, screen by screen: +Android gaming and emulation enthusiasts using Nintendo Switch 2 **Joy-Con 2** and Pro Controllers +as gamepads on a phone or tablet. They are comfortable with Developer Options, Shizuku and +per-emulator config. They use the app mid-setup, at a desk or on a couch, often in a dim room and +*holding a controller in one hand*, and want to get into a game fast and then have the app stay out +of the way. + +The primary jobs: - **Connect** — pair one or more Joy-Con 2 over BLE and confirm they're live. - **Assign** — map controllers to player slots (P1–P8), single or dual (L+R) layouts. - **Enable output** — turn on the virtual gamepad and/or DSU motion server. @@ -23,31 +21,24 @@ The primary jobs, screen by screen: ## Product Purpose -Joy-Con 2 controllers speak BLE over a custom GATT service rather than standard -HID-over-GATT, so Android cannot pair them through normal Bluetooth settings. Joycon2Android -bridges that gap: it connects over GATT, sends the vendor init sequence, parses raw -notification packets, and exposes each assigned player as its own standard virtual HID -gamepad via UHID (through Shizuku's privileged path). It also runs a DSU motion server so -emulators get gyro/accel, and can write emulator controller configs directly. +The controllers speak a custom BLE GATT service rather than HID-over-GATT, so Android can't pair +them from Bluetooth settings. The app connects itself, presents each player as a virtual HID gamepad +through UHID, runs a DSU motion server, and writes emulator configs. -Success is: a controller goes from SYNC-button to "working in my game/emulator" in well -under a minute, multiplayer "just works" (one distinct device per player), and the app is -trustworthy enough to leave running in the background without a second thought. +Success: SYNC to "working in my game" in well under a minute, multiplayer that just works (one device +per player), and an app trustworthy enough to leave running in the background. ## Brand Personality **Playful gaming gear** — energetic, characterful, unmistakably *about controllers and play*, not a generic system utility. Three words: **playful, precise, native-to-gaming**. -The personality is carried by substance, not decoration: the standout identity move is that -each controller's card border is drawn from the controller's **real shell color** read out of -SPI flash (saturation-boosted so it reads on the dark UI). The design should lean into that — -color, motion, and layout that celebrate the hardware — while staying tasteful and technically -credible. Playful with restraint, never toy-like. +Personality comes from substance, not decoration — above all each controller's **real shell +colour** ([DESIGN.md](DESIGN.md#color)). Colour, motion and layout should celebrate the hardware while +staying technically credible: playful with restraint, never toy-like. -Voice: confident and direct, speaks the user's language (BLE, DSU, UHID, emulator names) without -over-explaining. Copy is generic and self-explanatory — toggles describe their use case rather -than giving app-specific step-by-step instructions. +Voice: confident and direct, in the user's language (BLE, DSU, UHID, emulator names). Toggles +describe their use case rather than giving step-by-step instructions. ## Anti-references @@ -75,12 +66,10 @@ than giving app-specific step-by-step instructions. ## Accessibility & Inclusion -- **Contrast (WCAG AA).** Body text ≥4.5:1, large/bold text ≥3:1 against its background — a real - risk on the dark theme with muted-gray text (`TextDim #8B98A5`); verify and bump toward ink - where borderline. +- **Contrast (WCAG AA).** Body text ≥4.5:1, large/bold text ≥3:1 — watch muted text on the dark + theme. - **Reduced motion.** Any animation or live-readout motion honours the system reduced-motion setting with a calm fallback. - **Large touch targets.** ≥48dp for primary controls — the app is often operated one-handed while the other hand holds a controller. -- **Don't rely on color alone.** Battery and connection state pair color with icon/text so - status survives color-blindness (currently battery is color-only via `batteryColor()`). +- **Don't rely on colour alone.** Battery and connection state pair colour with an icon or text. diff --git a/docs/adding-a-feature.md b/docs/adding-a-feature.md index b1f790f..c9297c8 100644 --- a/docs/adding-a-feature.md +++ b/docs/adding-a-feature.md @@ -1,33 +1,28 @@ # Adding or changing a feature -A practical recipe for working within this app's [architecture](architecture.md). Read that -first if you haven't — this guide assumes the layer/module rules. +Assumes the layer and module rules in [architecture.md](architecture.md). -Start by deciding which case you're in: +- **Extending an existing feature** (new action, state or screen section) → + [Recipe A](#recipe-a--extend-an-existing-feature). The common case. +- **A new capability nothing else owns** → [Recipe B](#recipe-b--add-a-new-feature). -- **Extending an existing feature** (new action, new bit of state, new screen section) → - [Recipe A](#recipe-a--extend-an-existing-feature). This is the common one. -- **A genuinely new feature** (a new capability nothing else owns) → - [Recipe B](#recipe-b--add-a-new-feature). - -If you're unsure whether something is its own feature, ask *"what changes for what reason?"* — -if it changes for the same reason as `connection`/`assignment`/`gamepad`/`dsu`, it belongs in -that feature. +Unsure? Ask *"what changes for what reason?"* If it changes for the same reason as an existing +feature, it belongs there. ## The convention plugins -Every module's `build.gradle.kts` applies exactly one of these (defined in -`build-logic/convention/`). Use them — never hand-roll `android {}`/`compileSdk` in a module. +Every module's `build.gradle.kts` applies exactly one of these (`build-logic/convention/`). Never +hand-roll `android {}` or `compileSdk` in a module. | Plugin id | For | Gives you | |---|---|---| | `joycon.kotlin.jvm` | `domain`, `:core:model`, `:core:session`, pure-Kotlin `data` | Kotlin/JVM, Java 11 | -| `joycon.android.library` | Android `data` modules | `com.android.library`, compileSdk 36, minSdk 24, Java 11, JVM unit-test defaults. (AGP 9 has Kotlin built-in — do **not** also apply `org.jetbrains.kotlin.android`; it collides on the `kotlin` extension.) | +| `joycon.android.library` | Android `data` modules | `com.android.library`, compileSdk 36, minSdk 24, Java 11, JVM unit-test defaults. Never also apply `org.jetbrains.kotlin.android`: AGP 9 has Kotlin built in. | | `joycon.android.library.compose` | `presentation`, `:core:designsystem` | the above + Compose | ## Recipe A — extend an existing feature -Adding "do X" to a feature is four edits, following the existing pattern in that feature: +Five edits, following the feature's existing pattern: 1. **Domain — add the capability to the repository interface** (if the data layer needs to do something new). e.g. add `fun setFoo(enabled: Boolean)` to `DsuRepository`. @@ -35,8 +30,8 @@ Adding "do X" to a feature is four edits, following the existing pattern in that 2. **Data — implement it** in the repository impl (`DsuServer`, `Joycon2Manager`, `GamepadOutput`, …). -3. **Domain — add a one-line use case.** Use cases are non-negotiable: presentation reaches data - *only* through them. Always an `operator fun invoke`: +3. **Domain — add a one-line use case.** Presentation reaches data *only* through use cases, always + an `operator fun invoke`: ```kotlin class SetFooUseCase(private val repository: DsuRepository) { @@ -54,14 +49,13 @@ Adding "do X" to a feature is four edits, following the existing pattern in that DsuViewModel(c.observeDsuStatus, c.enableDsu, c.disableDsu, c.setFoo) ``` -For new *state* to observe, the repository exposes a `StateFlow`/`Flow`, an -`Observe…StatusUseCase` wraps it (often `combine`-ing several flows into a status data class), -and the ViewModel `stateIn`s it. Mirror `ObserveDsuStatusUseCase` / `DsuViewModel`. +For new *state*, the repository exposes a `Flow`, an `Observe…StatusUseCase` wraps it (often +`combine`-ing several into a status data class), and the ViewModel `stateIn`s it. Mirror +`ObserveDsuStatusUseCase` / `DsuViewModel`. ## Recipe B — add a new feature -Say the feature is `foo`. Create three modules (drop `presentation` if it has no UI, or `data` -if it's pure logic). +For a feature `foo`, create three modules (drop `presentation` without UI, `data` for pure logic). ### 1. Register the modules @@ -119,8 +113,8 @@ feature/foo/domain/src/main/kotlin/.../foo/ ### 4. Data: the implementation -`feature/foo/data/src/main/kotlin/.../foo/FooManager.kt` implements `FooRepository`. This is the -only layer allowed to touch BLE, the relay, sockets, or framework APIs. +`FooManager` implements `FooRepository`. Only this layer touches BLE, the relay, sockets or +framework APIs. ### 5. Presentation: ViewModel + UI @@ -147,32 +141,14 @@ Compose UI goes alongside it, built from `:core:designsystem` components. - If the feature reacts to player assignment (like gamepad/dsu), hook it into the `SessionCoordinator`'s `onState` in `AppContainer` rather than calling it from the UI. -## Conventions & gotchas - -- **Packages are feature-rooted:** `com.joegec.joycon2android.` for a feature's domain + - data, and `com.joegec.joycon2android..presentation` for its ViewModel + composables. A - module's `namespace` matches its package root (so generated `R`/`BuildConfig` land there). The - one exception is `:core:designsystem`, which owns `com.joegec.joycon2android.ui.components` / - `ui.theme` — features must not add to those packages. -- **Split a crowded package by concern, not layer.** When one package accumulates unrelated - clusters, give each its own sub-package — e.g. gamepad is `gamepad` (the relay output), - `gamepad.privileged` (shell access), `gamepad.emulator` - (emulator config); dsu is `dsu` / `dsu.motion` / `dsu.emulator`. Concern sub-packages live in the - same module, so this is free of build-graph changes. -- **Strings & `R` are per-namespace.** A composable moved into a feature module needs its strings - copied into that module's `res/values/strings.xml` and its `R` import set to the module's - namespace (`com.joegec.joycon2android.foo.presentation.R`). When you rename a package, update the - module's `namespace` to match or the `R` import will dangle. -- **Repositories are app-scoped singletons** owned by `AppContainer` (held by `Application`), so - they outlive the Activity and the foreground service. **ViewModels are feature-scoped** and - depend only on their feature's use cases — never on `:app` or another feature. -- **Cross-feature interactions go through `:core:session`**, not feature-to-feature deps. If two - features need to talk, the coordinator is where. -- **Per-packet paths stay synchronous.** Motion/gamepad output rides `onState`, not a conflated - `StateFlow` — conflation drops samples. -- **Run `:konsist:test` after moving classes.** It fails the build if a ViewModel/use - case/repository-interface lands in the wrong layer. The rules and what they catch are in - [architecture.md](architecture.md#dependency-rules). +## Gotchas + +- **Strings and `R` are per-namespace.** A composable moved into a feature module needs its strings + copied into that module's `res/values/strings.xml` and its `R` import switched to the module's + namespace. Rename a package, and update the module's `namespace` to match. +- Package layout, singleton scope, cross-feature coordination and the per-packet path are in + [architecture.md](architecture.md); `:konsist:test` fails the build if a class lands in the wrong + layer. ## Before you're done — checklist diff --git a/docs/architecture.md b/docs/architecture.md index d2c1ae0..b8942e6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,16 +1,12 @@ # Architecture -The living reference for how this app is structured. For *how to add or change* a feature, -see [adding-a-feature.md](adding-a-feature.md). +How the app is structured. To add or change a feature, follow [adding-a-feature.md](adding-a-feature.md). -## Shape in one paragraph - -A single-activity Compose app built as a **Gradle multi-module** project, split by **feature × -layer**. Each feature (`connection`, `assignment`, `gamepad`, `dsu`, `update`) has up to three modules — -`domain`, `data`, `presentation` — plus shared `:core` modules and a thin `:app` that wires -everything together. The split exists to *enforce* the dependency rules at compile time: a -ViewModel physically cannot reach a repository implementation, because presentation and data -live in separate modules that share only domain. +A single-activity Compose app, split into Gradle modules by **feature × layer**. Each feature +(`connection`, `assignment`, `gamepad`, `dsu`, `update`) has up to three modules — `domain`, `data`, +`presentation` — over shared `:core` modules and a thin `:app` that wires them together. The split +*enforces* the dependency rules at compile time: presentation and data share only domain, so a +ViewModel cannot reach a repository implementation. ## Module graph @@ -23,9 +19,9 @@ live in separate modules that share only domain. | `:core:buttonmapping:domain` | `joycon.kotlin.jvm` | `:core:model` | | `:core:buttonmapping:data` | `joycon.android.library` | `:core:buttonmapping:domain`, `:core:model` | | `:core:buttonmapping:presentation` | `joycon.android.library.compose` | `:core:buttonmapping:domain`, `:core:designsystem`, `:core:model` | -| `:feature::domain` | `joycon.kotlin.jvm` | `:core:model` ² | -| `:feature::data` | `joycon.android.library`¹ | `:feature::domain`, `:core:model` | -| `:feature::presentation` | `joycon.android.library.compose` | `:feature::domain`, `:core:designsystem`, `:core:model` | +| `:feature::domain` | `joycon.kotlin.jvm` | `:core:model` ²³ | +| `:feature::data` | `joycon.android.library`¹ | `:feature::domain`, `:core:model` ³ | +| `:feature::presentation` | `joycon.android.library.compose` | `:feature::domain`, `:core:designsystem`, `:core:model` ³ | | `:app` | `com.android.application` | every feature module + all `:core` | | `:konsist` | `joycon.kotlin.jvm` (test-only) | — (scans the whole project) | @@ -34,17 +30,13 @@ live in separate modules that share only domain. ¹ `assignment:data` is pure Kotlin (`joycon.kotlin.jvm`) — it has no Android dependencies. ² `gamepad:domain` and `dsu:domain` also depend on `:core:emulatorconfig` and -`:core:buttonmapping:domain` for the one-tap emulator setup (shared ini editing, emulator paths, and -the user's button mapping). Each feature owns its own emulator-config -*generators* — gamepad mapping in `gamepad:domain`, DSU/motion mapping in `dsu:domain` — over that -shared leaf; no feature depends on another feature. +`:core:buttonmapping:domain` for one-tap emulator setup. Each owns its own config *generators* over +that shared leaf; no feature depends on another. -³ `update` needs neither `:core:model` nor `:core:emulatorconfig` — it only reads GitHub's -releases API and hands an APK to the system installer. +³ Not `update`, which only reads GitHub's releases API and hands an APK to the system installer. -The three convention plugins live in `build-logic/convention/` and dedupe all the per-module -Gradle config (compileSdk, Java 11, Compose, test options) so each `build.gradle.kts` is a few -lines. See [adding-a-feature.md](adding-a-feature.md) for what each plugin sets up. +Per-module Gradle config (compileSdk, Java 11, Compose, test options) lives in three convention +plugins in `build-logic/convention/` ([which to use](adding-a-feature.md#the-convention-plugins)). ## The layers @@ -71,15 +63,28 @@ app-specific lives in a feature's presentation, not here. cases. It's the one place that depends on more than one feature's domain, because assembling the app's `AppUiState` *is* the cross-feature concern (connection + assignment → player state). -**`:core:emulatorconfig`** — shared primitives for the one-tap setup: `IniEditor` (splices keys -into ini text, leaving the user's others intact), `DolphinPaths` / `EdenPaths` (package and config -locations), and `EdenControls` (the `[Controls]` vocabulary both features write). Shared because the -gamepad and DSU features both write to Dolphin and Eden. Holds *mechanism*, not feature logic — the -per-emulator config generators live in their owning feature's `domain`. - -**`:core:buttonmapping`** — the user-editable Joy-Con → emulator button mapping: the per-console -defaults and mapping model (`domain`), their persistence (`data`), and the mapping editor -(`presentation`). Both the gamepad and DSU config generators read it. +**`:core:emulatorconfig`** — mechanism shared by the gamepad and DSU setup, which both write to +Dolphin and Eden: `IniEditor`, `DolphinPaths` / `EdenPaths`, and `EdenControls` (the `[Controls]` +keys both features write; each setup first clears every player's old keys). The generators +themselves live in their feature's `domain`. The config files sit in the emulator's `Android/data`, +writable by a shell-uid process (Shizuku), not by the app. + +**`:core:buttonmapping`** — the user's Joy-Con → emulator mapping: model and layouts (`domain`, +shipped layouts in `preset/`), persistence (`data`) and the editor (`presentation`). See +[Button mapping](#button-mapping). + +## Button mapping + +- **Keyed by `PlayerBody`** — a player plus the body they hold — so each player maps independently. +- **A target holds every source bound to it.** Dolphin ORs them into one expression; Eden binds one + input per key, so it keeps the first the body can emit. +- **A layout is a name for a set of bindings, never a stored reference.** Applying one copies out + everything it says, layered over the console default so a target it omits is still bound. + `MappingLayouts.matching` reads the name back by comparing a body's bindings with every shipped + layout and every one the user saved for that body; no match is "Custom". So deleting a layout + removes only its name, and the same bindings answer to it again if an identical one is saved. +- **`GlobalLayout` freezes the whole session** — every player's bindings in full, and the bodies they + held — so it restores exactly what it saved, but only onto those players. ## Dependency rules @@ -112,7 +117,7 @@ lands there. `:core:designsystem` solely owns `com.joegec.joycon2android.ui.comp ## Composition root — `AppContainer` -`app/.../AppContainer.kt` is the only place the abstractions and implementations meet. It: +The only place abstractions and implementations meet. It: - constructs each **repository implementation** (`Joycon2Manager`, `DsuServer`, `PlayerAssignmentManager`, `GamepadOutput`, `PrivilegedAccess`) as **app-scoped singletons**, @@ -126,10 +131,8 @@ no state of its own. ## ViewModels and the UI -One **ViewModel per feature**, each in its own presentation module, constructed in -`MainActivity` via `viewModelFactory { initializer { … } }` that pulls the relevant use cases -off `AppContainer`. This keeps the ViewModel class dependent only on its domain — never on -`:app`. +One **ViewModel per feature**, in its presentation module, built in `MainActivity` by a +`viewModelFactory` that pulls use cases off `AppContainer` — so it depends only on its domain. - `DsuViewModel` — DSU status, enable toggle, motion settings and emulator auto setup. - `GamepadViewModel` — gamepad status, Shizuku availability and emulator auto setup. @@ -154,9 +157,9 @@ BLE notify ─→ Joycon2Manager (connection/data, ControllerRepository) └─→ ObserveSessionUseCase ─→ Joycon2ViewModel ─→ AppUiState ─→ Compose ``` -The gamepad and DSU outputs ride a **synchronous per-packet path** off the coordinator's -`onState` callback, not a conflated `StateFlow` — conflation would drop motion samples. The -hardware-level detail is in [virtual-gamepad.md](virtual-gamepad.md) and [dsu-motion.md](dsu-motion.md). +The gamepad and DSU outputs ride the coordinator's **synchronous** `onState` callback, not a +conflated `StateFlow`, which would drop motion samples. Hardware detail: +[virtual-gamepad.md](virtual-gamepad.md), [dsu-motion.md](dsu-motion.md). ## Build & test @@ -166,6 +169,3 @@ hardware-level detail is in [virtual-gamepad.md](virtual-gamepad.md) and [dsu-mo ./gradlew :konsist:test # architecture-rule tests only ./gradlew :feature:dsu:data:test # one module's tests ``` - -`:konsist` enforces the layer-placement rules described above; run it after moving classes -between modules. diff --git a/docs/dsu-motion.md b/docs/dsu-motion.md index f99a224..9d183e0 100644 --- a/docs/dsu-motion.md +++ b/docs/dsu-motion.md @@ -7,9 +7,8 @@ How motion reaches emulators, and why the emulator bindings are shaped the way t `DsuServer` implements the [cemuhook protocol](https://v1993.github.io/cemuhook-protocol/) over UDP on port 26760. -- **Bound to IPv4 `127.0.0.1`.** `getLoopbackAddress()` resolves to IPv6 `::1` on Android, and a - socket there never sees the `127.0.0.1` datagrams emulators send. -- **Pad batches ride a buffered channel**, not a `StateFlow` — conflation would drop motion samples. +**Bound to IPv4 `127.0.0.1`.** `getLoopbackAddress()` resolves to IPv6 `::1` on Android, and a +socket there never sees the `127.0.0.1` datagrams emulators send. | Class | Job | |---|---| @@ -33,30 +32,126 @@ Players always win their own slot; pairs take what's left, so four players leave ## Motion frame -Scale factors are the Switch 1 values, verified on Joy-Con 2: accel 4096 LSB per g, gyro -0.061 °/s per LSB. The axis mapping was measured against Dolphin's Wii pointer — the -`MotionConverter` KDoc records the frames and signs, and [tools/README.md](../tools/README.md) the -calibration workflow. +Scale factors are the Switch 1 values, verified on Joy-Con 2 (2026-06: at rest gravity reads +exactly −1.00 g): accel ±8 g over 4096 LSB per g, gyro ±2000 °/s at 0.06103 °/s per LSB. +[tools/README.md](../tools/README.md) has the calibration workflow. -**Gyro bias.** Joy-Con 2 gyros idle with a constant offset (+0.2 °/s yaw, +0.9 °/s roll observed), -which clients integrate into pointer drift. Whenever a controller stays within ~2.4 °/s for ~2 s, -`GyroCalibrator` adopts the window mean as its bias, as the Switch does. +**The two frames.** `MotionConverter` turns one into the other, and neither is guessable: -### Sideways Joy-Cons +| | x | y | z | +|---|---|---|---| +| Joy-Con (R) raw, measured | the controller's **right** | toward the **tail** | out of the **button face** | +| cemuhook wire | **left** | **down** through the controller | toward the **player** | + +Flat at rest reads accel `(0, −1, 0)`; nose up reads accel z = −1 and gyro pitch −; turning right +reads +yaw; rolling right reads +roll. + +**The signs are DS4 hardware history, not a consistent right-handed frame**, so derive nothing from +them — verify any change against Dolphin's on-screen pointer, testing fast and slow movements +separately. Its complementary filter makes the *accelerometer* the authority on sustained pitch, so +gyro signs cannot be judged from pointer direction alone; gyro shows up in the fast response, accel +in the settled position. + +- **Raw x is the controller's right.** A rail-down pose (SL/SR on the table, gravity toward the + controller's right) reads +1 g on the wire's *left* axis (2026-09). Roll mirrors with x, since + angular velocity is a pseudovector. +- **Yaw is the one sign no measurement pins.** It turns about gravity, so neither a static pose nor + the accel/gyro consistency check sees it; it follows the pointer's horizontal response. Mirroring x + strictly implies mirroring yaw too, so if horizontal pointing ever reads backwards, flip yaw rather + than re-deriving the frame. +- **Left Joy-Con and Pro are assumed to share the raw frame** — unverified. Recalibrate with + `tools/dsu_client` if their motion feels rotated. -A lone Joy-Con's buttons and stick already arrive rotated into its sideways grip -([virtual-gamepad.md](virtual-gamepad.md#sidewaysmapper)), and Eden presents it as a Pro -Controller, so its motion is turned 90° about the button face to match. Without it, tilting read as -if the Joy-Con's nose pointed at the screen. +### Gyro bias + +Joy-Con 2 gyros idle with a constant offset (+0.2 °/s yaw, +0.9 °/s roll observed), which clients +integrate into pointer drift. Whenever a controller's gyro stays within ~2.4 °/s for 240 samples +(hand tremor exceeds that), `GyroCalibrator` adopts the window mean as its bias, as the Switch does. + +### Sideways Joy-Cons -- **The direction was measured**, in Eden's Mario Kart 8, and is the *opposite* of the stick's turn — - the IMU axes don't line up with the stick's. -- **A pair's second hand isn't turned**, even though it streams alone on its slot (`DsuStream.heldSideways`). -- **Dolphin maps it back.** Its emulated Wii Remote is the Joy-Con's own body, so - `DolphinWiimoteConfig` swaps a lone Joy-Con's IMU inputs back (table in the - [README](../README.md#manual-setup)). -- **Don't enable Dolphin's "Sideways Wii Remote"** with that mapping. It rotates IMU input by 90° - itself (`Wiimote::GetOrientation`), so it would turn motion twice. +A lone Joy-Con's buttons and stick arrive already rotated into its sideways grip +([virtual-gamepad.md](virtual-gamepad.md#sidewaysmapper)), and Eden presents it as a Pro Controller, +so `SidewaysMotion` turns its motion 90° about the button face to match. Without that, tilt reads as +if the nose pointed at the screen. + +- **The direction was measured** (Eden, Mario Kart 8) and is the *opposite* of the stick's turn: the + IMU axes don't line up with the stick's. +- **A pair's second hand isn't turned**, though it streams alone on its slot (`DsuStream.heldSideways`). +- **Dolphin turns it back.** Its emulated Wii Remote is the Joy-Con's own body, so + `DolphinWiimoteConfig` maps a lone Joy-Con's IMU inputs back about the button face (table in the + [README](../README.md#manual-setup)), putting the nose on the shoulder edge the player aims. The + bodies rotate into their grips opposite ways, so their tables are half a turn apart. +- **Measure the pointer, don't reason about it.** `tools/dsu_client` plus a replay of + `EmulateIMUCursor` settles in minutes what guessing costs days. Posed captures mislead: asked to + hold an "aim up", a player makes a different rotation from the one they make while playing, so + compare a captured session against candidate tables by how much cursor travel each yields. + +### Playing as a sideways Wii Remote + +A per-player switch in the mapping editor, seeded by the layout (`MappingLayout.sidewaysRemote`, on +for both Mario Kart layouts) and overridable (`SidewaysRemoteRepository`; applying a layout clears +the override). + +- **Only a right Joy-Con turns.** A game written for that grip reads gravity against a remote whose + nose points left, where a left Joy-Con's L/ZL edge already points. A right Joy-Con gives up its own + body to steer true, and with it R/ZR as the nose: aiming moves to the tail. +- **Up follows the grip.** Off, a lone Joy-Con's stick reads up toward its L/R edge; on, toward its + rail, where the relay already puts it (`emittedDirection`). +- **The D-pad turns a quarter on both bodies**, since the player's up is a sideways remote's right. + That is Dolphin's own `dpad_sideways_bitmasks`, written into the bindings so Dolphin's *Sideways + Wii Remote* option stays off — the option would turn the accelerometer a second time. +- **Pointing and a wheel want a right Joy-Con's nose half a turn apart**, and no Dolphin option + bridges them: `GetOrientation()` turns a quarter (Sideways) or a quarter about the left axis + (Upright), and reaches only the accelerometer, never `GetTotalTransformation()` and so never the + pointer. Hence the choice lives in the layout. + +### Tricks and wheelies + +Mario Kart Wii tricks and pops wheelies off a flick, and reads only the accelerometer (no +MotionPlus). A Joy-Con flick is mostly rotation — captures peak past 1200 °/s summed with barely a g +of linear jerk, where jerking a real Wii Wheel throws the whole thing — so on its own it never +reaches the game. Playing sideways, the flick is read from the gyroscope and delivered as a shake of +the accelerometer, the path steering proves reaches the game. `Shake` is also a mapping target, so a +button can trick too. + +`DolphinWiimoteConfig` appends this to `IMUAccelerometer/Up`, and to `/Down` with Up and Down +swapped: + +``` ++ pulse(, 0.6) * max(sin(timer(0.15) * 6.2832), 0) * 50 + = (`Gyro Pitch Up` / 9) & not(pulse(`Gyro Pitch Down` / 9, 0.4)) +``` + +- **Pitch only.** Over three captures (2026-09-22, right Joy-Con, 15 ms stream) a flick is 59–89% + pitch on a lone sideways Joy-Con and a pair alike, while steering is roll: + + | Gesture | Raw pitch | + |---|---| + | steering, hard, 25 s | ≤ 2.8 rad/s | + | wheelie flicks | 7.0 – 10.7 | + | trick flicks | 8.8 – 14.5 | + + `pulse()` fires as its input crosses a half, so the threshold is 4.5 rad/s: 1.6× above the worst + steering, 1.6× below the weakest flick. Separating by axis beats a slew limiter, which, fast enough + to reject a turn, ate all but 0.9 rad/s of the slow wheelie down-flick. +- **Direction matters, because a wheelie is a state**: an up-flick starts one, a down-flick drops it. + The remote is jerked the way it was flicked (positive wire pitch is up on both bodies), and the wave + is half-rectified; a full one would cancel the wheelie four times a second. +- **Each direction locks the other out for 0.4 s.** Every flick rebounds the opposite way + 0.12–0.32 s later, often stronger than a genuine flick (8.5 against 7.0), so only order tells them + apart. Gating `pulse()`'s input rather than its output lets a running shake finish. +- **A shake, not a push**: 0.6 s at ~6.7 Hz, 50 m/s² — past what an emulated remote reports. Shaking + a Joy-Con hard for about a second landed tricks by hand where a single held push didn't. +- In Dolphin's expressions `|` is a max and binds looser than `/` (checked against its source, 2026-09). + +Two approaches fail, so don't retry them: + +- **Amplifying the accelerometer's own transient.** An emulated Wii Remote saturates around + +3.9/−4.9 g (`ACCEL_ZERO_G` 0x80, `ACCEL_ONE_G` 0x9A over 8 bits) and the push already exceeds it, + so a bigger number only clips sooner. +- **Dolphin's `Shake` group.** A full 7 g oscillation bound to a key never landed a trick (2026-09), + though `m_shake_state.acceleration` does reach the reported acceleration. ## Report rate @@ -65,6 +160,30 @@ The Joy-Con reports once per BLE connection interval. Android's balanced priorit `CONNECTION_PRIORITY_HIGH` while DSU runs — 15 ms (~67 Hz) on the same Thor — at a battery cost on both ends. +## Eden's cemuhook bindings + +Eden's cemuhook engine addresses a pad by `guid`, `port` and `pad`, and nothing else: + +- **`guid`** is the server's IPv4 as a 32-bit integer in hex, right-aligned in an otherwise-zero + UUID written raw, no dashes — so loopback is `0000000000000000000000007f000001`. +- **`port`** is the UDP port, not a controller index. +- **`pad`** is a global index, `client * 4 + slot`, so with our server as Eden's only client it is + the DSU slot itself. + +`EdenDsuConfig`'s button table is protocol wiring, not preference: cemuhook's two button bytes +packed low-then-high are exactly Eden's `PadButton` values, with Home and the touchpad click riding +the bytes above them. Sticks arrive as raw bytes that Eden reads as `(v − 127) / 127`, so the axis +pairs need no inversion. + +**Motion is the one thing a pad cannot share.** A pad packet carries a single accelerometer and +gyroscope, so `motion` is always index 0 and each hand streams on a slot of its own ([Slots](#slots)). +The hand holding the player's own slot lands on `motionright` and its second hand on `motionleft`; +one Joy-Con, or a pair that ran out of slots, binds both to the same pad — which is what Eden's own +auto-mapping does for every device. + +A cemuhook pad carries the full DS4 button set and both sticks, so nothing else is needed for a +player to play: the Virtual Gamepad is an alternative route to the same keys, not a prerequisite. + ## Eden reads the device's own motion Eden's Android build feeds the device's gyro and accelerometer into Player 1 on top of any mapped @@ -95,6 +214,8 @@ What `DolphinWiimoteConfig` writes, and why: `DSUClient//Joycon2:Accel Up` reads the second hand's slot. A real Nunchuk has no gyro. - **Recenter** is R1 (L1 on a lone left Joy-Con). Gyro pointing drifts, and pressing it while aiming at the screen centre is what summons the pointer. +- **`IMUIR/Total Yaw` is 60°.** Dolphin's 25° clamps the cursor after ±12.5° of turn, which a + hand-held aim overruns; the clamp reads as the pointer sticking. ## MotionPlus tutorial replays diff --git a/docs/protocol.md b/docs/protocol.md index f790123..58b64f9 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -25,7 +25,8 @@ Manufacturer data for ID `0x0553` carries: - **Bytes `[5..6]`** — little-endian product ID: `0x2067` left Joy-Con 2, `0x2066` right Joy-Con 2, `0x2069` Switch 2 Pro Controller. The advertisement has no local name, so this is the only type - signal before input starts. + signal before input starts. Left and right are confirmed on hardware; the Pro value is community + reverse-engineering. - **Bytes `[10..15]`** — the bonded host's MAC. Holding SYNC zeroes it; a button press on a synced controller wakes it into a short reconnect advertisement carrying the address. The scanner only accepts a zeroed field, so stray presses on nearby synced Joy-Cons don't flash into the list. @@ -116,6 +117,53 @@ A left Joy-Con's right-stick bytes are garbage, and a right Joy-Con's left-stick 0x01 GR 0x02 GL ``` +## Player LEDs + +``` +09 91 01 07 00 08 00 00 00 00 00 00 00 00 00 +``` + +The mask's low nibble lights P1–P4 solid (`0x01`, `0x02`, `0x04`, `0x08`), its high nibble flashes +them (`0x10` … `0x80`). `0xF0`, all flashing, is the controller's default cycling animation. + +## SPI reads + +The controller keeps its factory data in SPI flash. The app wants one field: the **shell accent +colour**, 3 bytes RGB at `0x01301F` — the per-side colour (coral right, blue left) the UI paints each +controller with. Not the body colour at `0x013019`: that is the near-black shell, the same on both +Joy-Cons. + +The request reads the surrounding DeviceInfo block, `0x40` bytes from `0x013000`: + +``` +02 91 00 04 00 08 00 00 40 7E 00 00 00 30 01 00 +report cmd len magic address, LE +``` + +Byte 2 must be `0x00`, as HandHeldLegend's procon2tool sends it. The init commands carry `0x01` +there, but an SPI read with `0x01` gets no reply. + +The reply arrives on the command-response characteristic. Layout, little-endian, confirmed on a live +controller: + +| Offset | Meaning | +|---|---| +| `0` | report type — `0x02` for SPI | +| `3` | command — `0x04` for SPI read | +| `8` | data length | +| `12..15` | source address, echoing the address requested | +| `16..` | data bytes, starting at that source address | + +`SpiColorParser` finds the field at `16 + (wanted address − echoed address)`, so the block can be +requested at any alignment. + +## Battery + +The packet's voltage reads ~0.6 V below the cell's: ~3.30 V shows 75% on a Switch 2, ~3.60 V shows +100%. `BatteryGauge` interpolates Nintendo's Joy-Con thresholds (3.3 / 3.6 / 3.76 / 3.9 / 4.2 V, +from dekuNukem's docs) shifted down 0.6 V. Below ~3.0 V is extrapolated; no low readings have been +captured yet. + ## Stick range and centre The raw 12-bit sticks neither span `0x000..0xFFF` nor rest at the midpoint, and both vary per @@ -129,17 +177,21 @@ rest: left Joy-Con x 2080 y 2157 right Joy-Con x 2014 y 2022 Rest isn't the midpoint of travel (those extremes midpoint to 2150/2125), so it has to be sampled. Treating 2048 as centre and half-span leaves full deflection at ~60% with a 4–5% drift at rest. -`StickCalibrator` learns each axis' centre from the first still window after connecting, then -freezes it — a stick held at full deflection is perfectly still too. It scales each direction by -its own span, the same centre/below/above triple the factory calibration stores. It runs where -packets are parsed, so the live display, gamepad and DSU all see corrected values. +`StickCalibrator` runs where packets are parsed, so the live display, gamepad and DSU all see +corrected values: + +- **Centre** is learned from the first still window (30 samples), then frozen: a stick held at full + deflection is perfectly still too. +- **Each direction scales by its own span**, the centre/below/above triple the factory calibration + stores. Spans are seeded just under the smallest travel measured (~1180 LSB), so full tilt works + from the first packet, and only ever widen. ## Android BLE gotchas 1. **MTU first.** The default ATT MTU of 23 truncates 63-byte notifications: `requestMtu(247)` after connecting, wait for `onMtuChanged`, then discover services. -2. **One GATT operation at a time.** Queue them and advance only on the matching callback - (`GattOpQueue`). +2. **One GATT operation at a time.** A second issued before the callback is silently dropped. + `GattOpQueue` advances on the matching callback, or after a timeout if none comes. 3. **Write the CCCD.** `setCharacteristicNotification(true)` alone delivers nothing; descriptor `0x2902` must be written too. 4. **Pass `TRANSPORT_LE`** to `connectGatt`, or it may try classic Bluetooth. diff --git a/docs/virtual-gamepad.md b/docs/virtual-gamepad.md index 9e380d8..3ed21ea 100644 --- a/docs/virtual-gamepad.md +++ b/docs/virtual-gamepad.md @@ -17,12 +17,9 @@ The app creates gamepads through Linux's UHID (user-space HID) interface: The device uses `BUS_USB` with generic IDs `0x1234:0x5678` so the kernel's `hid-generic` driver binds it. Nintendo's IDs would let `hid-nintendo` claim it and reject it. -### One device per player - -Each assigned player gets its own device, `Joy-Con Virtual Gamepad `. The name carries the -player number, but Android numbers input devices by enumeration order: with P1, P2 and P4 (no P3), -P4 is the third pad, `Android/3/Joy-Con Virtual Gamepad 4`. That's why `DolphinGcpadConfig` keys -`Device = Android//…` on enumeration rank while the port stays on the player number. +Each assigned player gets its own device, `Joy-Con Virtual Gamepad `, named by player number. +Emulators address it by enumeration rank instead: with P1, P2 and P4, P4 is the third pad, +`Android/3/Joy-Con Virtual Gamepad 4` ([Device identity](#device-identity)). ## Report layout @@ -59,7 +56,8 @@ shift: - **GR and C overflow.** One gamepad collection carries 15 buttons — a 16th lands on `0x13F`, which no key layout names — and the Switch 2 controllers have 17. For a Button usage outside a pointer/joystick/gamepad collection Linux falls back to `BTN_MISC + n - 1`, which key layouts name - `BUTTON_1..16`: GR is **188**, C is **189**. Firmware that re-publishes pads (see + `BUTTON_1..16`: GR is **188**, C is **189**. The collection is vendor-defined so nothing + interprets it. Firmware that re-publishes pads (see [Device identity](#device-identity)) forwards only keys it knows, so it may drop these two. - **Triggers are Brake (left) and Accelerator (right)**, never reversed. Android aliases `AXIS_LTRIGGER` to `AXIS_BRAKE` and `AXIS_RTRIGGER` to `AXIS_GAS`, and re-publishing firmware @@ -100,11 +98,44 @@ Left Joy-Con turned 90° counter-clockwise, right 90° clockwise, as on a Switch Motion is turned too, but for DSU only — see [dsu-motion.md](dsu-motion.md#sideways-joy-cons). +## Emulator config + +- **Dolphin's names** for each Android keycode and hat direction (`DolphinGcpadConfig`) are + captured from a real mapping, not derived. +- **Every stick direction is its own Dolphin input**, so a stick target can mix tilts and buttons + freely without the tilts losing their analog range. +- **Eden nests whole bindings inside one value** for a stick assembled from digital inputs, so + `EdenControls` escapes their `:`, `,` and `$` as `$0`, `$1` and `$2` — exactly as Eden's own + `ParamPackage` serializes them. +- **Sources resolve to what the body emits.** The relay rotates a lone Joy-Con + ([`SidewaysMapper`](#sidewaysmapper)) before anything reaches an emulator, so both generators map a + chosen source through that rotation. + ## Device identity An emulator addresses a pad by `port` — its enumeration rank, not the player number — plus, for Eden, a `guid` built from vendor/product IDs. Both are read from the live input-device list on every -setup, never derived: handheld firmware may re-publish an external gamepad under the built-in -controller's IDs (AYN's Odin/Thor line does), and a binding with the wrong guid is silently -ignored. Each setup also clears the player's old keys, so a layout or port change can't leave a -stale binding firing on another player's port. +setup, never derived: a guessed number binds a config to the wrong device or to none, and a handheld's +built-in controller already occupies the low numbers. A player whose pad isn't enumerated yet is +skipped. + +Each emulator counts differently. Each rule below is read from that emulator's source and mirrored +in `VirtualGamepadIdentity`: + +- **Dolphin** takes the id in its `Android//` qualifier from + `InputDevice.getControllerNumber()`, Android's gamepad enumeration counter. + `ControllerInterface::AddDevice` prefers `GetPreferredId()`, which the Android backend fills from + `getControllerNumber()`, falling back to a duplicate-name index only for non-gamepads. +- **Eden** (yuzu lineage) numbers `port` by walking `InputDevice.getDeviceIds()` and counting + *every* physical game controller it passes, so any built-in pad shifts ours along. In + `InputHandler.getDevices()` a controller number already registered is skipped but still consumes + a port, which `edenGamepads` reproduces. + +Eden's `guid` is product then vendor ID, each a 16-digit hex half. Some handheld firmware +re-publishes an external gamepad under the built-in controller's IDs (AYN's Odin/Thor line does), +leaving two devices with our name, and a binding with the wrong guid is silently ignored. So every +field of a player's identity comes from one `InputDevice`: the last match, the republished one where +that happens. + +Each setup also clears the player's old keys, so a layout or port change can't leave a stale +binding firing on another player's port. diff --git a/feature/assignment/data/src/main/kotlin/com/joegec/joycon2android/assignment/PlayerAssignmentManager.kt b/feature/assignment/data/src/main/kotlin/com/joegec/joycon2android/assignment/PlayerAssignmentManager.kt index 73f0990..02b0cb5 100644 --- a/feature/assignment/data/src/main/kotlin/com/joegec/joycon2android/assignment/PlayerAssignmentManager.kt +++ b/feature/assignment/data/src/main/kotlin/com/joegec/joycon2android/assignment/PlayerAssignmentManager.kt @@ -6,10 +6,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -/** - * Maps Joy-Con BLE addresses to player numbers. - * Enforces that each player can have at most one Left and one Right controller. - */ class PlayerAssignmentManager : AssignmentRepository { private val _assignments = MutableStateFlow>(emptyMap()) diff --git a/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/AssignmentRepository.kt b/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/AssignmentRepository.kt index 3a055b7..282b799 100644 --- a/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/AssignmentRepository.kt +++ b/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/AssignmentRepository.kt @@ -4,10 +4,7 @@ import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.Side import kotlinx.coroutines.flow.StateFlow -/** - * Holds which Joy-Con (by BLE address) is assigned to which player, enforcing one Left + - * one Right (or one Pro) per player. Implemented in the data layer. - */ +/** One Left and one Right, or one Pro, per player. */ interface AssignmentRepository { val assignments: StateFlow> diff --git a/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/ComboAssignment.kt b/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/ComboAssignment.kt index d9909ba..d7260e2 100644 --- a/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/ComboAssignment.kt +++ b/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/ComboAssignment.kt @@ -1,4 +1,3 @@ package com.joegec.joycon2android.assignment -/** Controllers that requested player assignment together via a button combo. */ data class ComboAssignment(val addresses: List) diff --git a/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/ComboAssignmentDetector.kt b/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/ComboAssignmentDetector.kt index 77c2d8a..a984fdf 100644 --- a/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/ComboAssignmentDetector.kt +++ b/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/ComboAssignmentDetector.kt @@ -5,11 +5,8 @@ import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.Side /** - * Detects the Switch "Change Grip/Order" assignment combos. - * - L held on one Joy-Con while R is held on another pairs both onto one player - * - SL + SR held on a single Joy-Con assigns it solo (sideways) - * - L + R held on a Pro Controller assigns it solo - * A triggered controller stays latched until released so one held combo yields exactly one assignment. + * The Switch's "Change Grip/Order" combos: L and R across two Joy-Cons, SL + SR on one, L + R on a + * Pro. Latched until release, so one hold assigns once. */ class ComboAssignmentDetector { diff --git a/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/PlayerStateResolver.kt b/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/PlayerStateResolver.kt index b5eb94d..453fb63 100644 --- a/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/PlayerStateResolver.kt +++ b/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/PlayerStateResolver.kt @@ -40,10 +40,7 @@ class PlayerStateResolver(private val evictConflicting: (address: String) -> Uni return when (SideInference.inferSide(joycon.input)) { Side.RIGHT -> PlayerState(player = player, left = null, right = joycon) - // LEFT, or not yet determinable: keep it in the left slot rather than defaulting an - // undetermined lone Joy-Con to "right". A genuinely unknown one corrects to its real - // side once a side-exclusive button is pressed — though the scan now usually identifies - // it up front (see BleScanner.sideFromManufacturerData). + // An unknown side waits in the left slot until a side-exclusive button corrects it. else -> PlayerState(player = player, left = joycon, right = null) } } diff --git a/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/SideInference.kt b/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/SideInference.kt index 6f0e1e4..ce9bcce 100644 --- a/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/SideInference.kt +++ b/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/SideInference.kt @@ -4,12 +4,7 @@ import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.JoyconInput import com.joegec.joycon2android.model.Side -/** - * Infers Joy-Con side from observed input when BLE advertisement didn't identify it. - * - * Left-exclusive buttons: ZL, L, Minus, LS, DPad (Up/Down/Left/Right), Capture, SL(L), SR(L) - * Right-exclusive buttons: ZR, R, Plus, RS, A, B, X, Y, Home, Chat, SL(R), SR(R) - */ +/** For a Joy-Con whose advertisement didn't reveal its side. */ object SideInference { private val leftButtons = setOf( diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt index 60b6aff..909e879 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt @@ -12,13 +12,6 @@ import android.os.Looper import android.util.Log import com.joegec.joycon2android.model.Side -/** - * Handles BLE scanning for Nintendo Joy-Con 2 controllers. - * Emits discovered devices via the [onDeviceFound] callback. - * - * All BLE operations require BLUETOOTH_SCAN and BLUETOOTH_CONNECT permissions, - * which are verified by the permission launcher in MainActivity before any BLE code is reached. - */ @SuppressLint("MissingPermission") class BleScanner(context: Context) { @@ -69,9 +62,7 @@ class BleScanner(context: Context) { if (!isScanning) return val manufacturerData = nintendoData(result) ?: return logAdvertisement(result, manufacturerData) - // A button press wakes a synced Joy-Con into a short-lived reconnect - // advertisement that only its bonded host can connect to (foreign - // connects fail with status 133) — connecting just flashes the UI + // Only the bonded host can connect to a wake advert: docs/protocol.md#advertising if (!JoyconAdvertisement.isPairing(manufacturerData)) return if (isKnownAddress(result.device.address)) return @@ -128,15 +119,7 @@ class BleScanner(context: Context) { else -> null } - /** - * Nintendo manufacturer data (company 0x0553) carries the little-endian USB/BLE product ID at - * bytes [5..6], so index 5 is its low byte: 0x67 = Left Joy-Con 2 (PID 0x2067), 0x66 = Right - * Joy-Con 2 (PID 0x2066), 0x69 = Switch 2 Pro Controller (PID 0x2069). Left/Right are confirmed - * on hardware and cross-checked against each controller's SPI accent colour (cyan left, coral - * right); the Pro value comes from community reverse-engineering of the same advertisement - * scheme. The pairing advertisement has no local name, so this byte is the only type signal - * available before the controller starts streaming input. - */ + /** The product ID's low byte: docs/protocol.md#advertising */ private fun sideFromManufacturerData(result: ScanResult): Side? { val mfgData = result.scanRecord ?.getManufacturerSpecificData(NINTENDO_MANUFACTURER_ID) ?: return null diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt index 2493720..53691df 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt @@ -6,13 +6,7 @@ import android.content.Context import com.joegec.joycon2android.model.Side import java.util.concurrent.ConcurrentHashMap -/** - * Manages all active Joy-Con BLE connections, keyed by device address. - * Thread-safe: BLE callbacks arrive on binder threads. - * - * All BLE operations require BLUETOOTH_CONNECT permission, which is verified - * by the permission launcher in MainActivity before any BLE code is reached. - */ +/** Thread-safe: BLE callbacks arrive on binder threads. */ @SuppressLint("MissingPermission") class ConnectionPool(private val context: Context) { @@ -24,10 +18,7 @@ class ConnectionPool(private val context: Context) { val addresses: Set get() = connections.keys.toSet() val size: Int get() = connections.size - /** - * Atomically creates and starts a connection for [result]. - * Returns null if this address is already in the pool (duplicate scan result). - */ + /** Null for an address already in the pool (a duplicate scan result). */ fun connect(result: ScanResult, side: Side, name: String, highPriority: Boolean): JoyconConnection? { val address = result.device.address val connection = JoyconConnection(context, side, name) { diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/GattOpQueue.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/GattOpQueue.kt index bd8024d..62da567 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/GattOpQueue.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/GattOpQueue.kt @@ -5,14 +5,7 @@ import android.os.Looper import android.util.Log import java.util.ArrayDeque -/** - * Serializes GATT operations. Android's BluetoothGatt allows only one - * outstanding write/descriptor-write at a time — issuing a second before - * the callback fires silently drops it. - * - * Includes a safety timeout: if a callback never arrives (e.g. writeCharacteristic - * returned false), the queue advances after [TIMEOUT_MS] to avoid permanent stalls. - */ +/** Android silently drops a second outstanding GATT op. Advances after [TIMEOUT_MS] if a callback never comes. */ class GattOpQueue { companion object { diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt index 676ac3c..d7df5d2 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt @@ -14,12 +14,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -/** - * Orchestrates BLE scanning and Joy-Con connection lifecycle, exposing connected - * controllers as domain [ConnectedJoycon]s. Delegates scanning to [BleScanner] and - * connection tracking to [ConnectionPool], and assembles the [controllers] list from each - * connection's live input + state (re-emitting on every change). - */ class Joycon2Manager( private val context: Context, private val scope: CoroutineScope, @@ -105,7 +99,6 @@ class Joycon2Manager( rebuildControllers() } - // Each connection's input + state drives a rebuild, so [controllers] reflects live data private fun syncCollectors() { (connectionJobs.keys - pool.addresses).forEach { connectionJobs.remove(it)?.cancel() } (pool.addresses - connectionJobs.keys).forEach { address -> diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisement.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisement.kt index cf400c5..d19a346 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisement.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisement.kt @@ -1,18 +1,11 @@ package com.joegec.joycon2android.connection -/** - * Joy-Con 2 advertisements (manufacturer ID 0x0553) carry the bonded host's MAC at - * bytes [10..15]: a button press wakes the controller to reconnect to that host and - * advertises its address; holding SYNC (pairing mode) zeroes the field. Observed on - * hardware 2026-06 — wake: `… 01 00 09 A7 9A 55 E2 98 0F …`, pairing: - * `… 01 00 00 00 00 00 00 00 0F …`. - */ +/** Bonded-host MAC at bytes [10..15], zeroed while pairing: docs/protocol.md#advertising */ object JoyconAdvertisement { private const val HOST_MAC_OFFSET = 10 private const val HOST_MAC_LENGTH = 6 - /** True when the controller is open for pairing rather than waking for its bonded host. */ fun isPairing(manufacturerData: ByteArray): Boolean { if (manufacturerData.size < HOST_MAC_OFFSET + HOST_MAC_LENGTH) return true return (HOST_MAC_OFFSET until HOST_MAC_OFFSET + HOST_MAC_LENGTH) diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt index 4f4b11a..cb75c71 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt @@ -22,13 +22,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import java.util.UUID -/** - * Manages a single BLE GATT connection to one Joy-Con 2. - * Each Joy-Con gets its own instance with independent state. - * - * All BLE operations require BLUETOOTH_CONNECT permission, which is verified - * by the permission launcher in MainActivity before any BLE code is reached. - */ @SuppressLint("MissingPermission") class JoyconConnection( private val context: Context, @@ -54,23 +47,13 @@ class JoyconConnection( 0x00, 0x00, 0xFF.toByte(), 0x00, 0x00, 0x00 ) - // SPI read (report 0x02, cmd 0x04): read 0x40 bytes from the DeviceInfo block - // at 0x013000, which contains the shell colors (body color at 0x013019). - // Payload: read length (0x40), 0x7E magic, then the 4-byte LE source address. - // The reply arrives on the command-response characteristic and is decoded - // by [SpiColorParser]. - // Byte [2] is 0x00 for SPI reads (matching HandHeldLegend procon2tool); - // the INIT_CMD_* feature commands use 0x01 there, but SPI reads only - // reply when this is 0x00. + // 0x40 bytes of the DeviceInfo block at 0x013000: docs/protocol.md#spi-reads private val SPI_READ_COLOR_CMD = byteArrayOf( 0x02, 0x91.toByte(), 0x00, 0x04, 0x00, 0x08, 0x00, 0x00, 0x40, 0x7E, 0x00, 0x00, 0x00, 0x30, 0x01, 0x00 ) - // Subcommand 0x07: set LED pattern via bitmask (16 bytes) - // Lower nibble = solid LEDs (0x01=P1, 0x02=P2, 0x04=P3, 0x08=P4) - // Upper nibble = flashing LEDs (0x10=P1, 0x20=P2, 0x40=P3, 0x80=P4) - // 0xF0 = all flashing = default cycling animation + // Bitmask layout: docs/protocol.md#player-leds private fun playerLedCmd(bitmask: Byte): ByteArray { return byteArrayOf( 0x09, 0x91.toByte(), 0x01, 0x07, 0x00, 0x08, 0x00, 0x00, @@ -78,7 +61,7 @@ class JoyconConnection( ) } - // All 4 player LEDs solid on (0x0F = P1+P2+P3+P4) + // All four solid. private val LED_ALL_ON_CMD = byteArrayOf( 0x09, 0x91.toByte(), 0x01, 0x07, 0x00, 0x08, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 @@ -185,7 +168,7 @@ class JoyconConnection( } writeChar!!.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE - // Subscribe to command response notifications (required for LED commands) + // LED and SPI replies arrive here. if (cmdResponseChar != null) { g.setCharacteristicNotification(cmdResponseChar, true) val cmdCccd = cmdResponseChar!!.getDescriptor(CCCD) @@ -199,7 +182,6 @@ class JoyconConnection( } } - // Subscribe to input notifications g.setCharacteristicNotification(notifyChar, true) val notifyCccd = notifyChar!!.getDescriptor(CCCD) if (notifyCccd != null) { @@ -259,8 +241,7 @@ class JoyconConnection( if (initComplete) gatt?.let(::requestPriority) } - // The default "balanced" connection interval lands on 30 ms on some phones, so the Joy-Con - // can only report ~33 times a second; high priority asks the stack for 7.5-15 ms. + // The connection interval is the report rate: docs/protocol.md#android-ble-gotchas private fun requestPriority(g: BluetoothGatt) { val priority = if (highPriority) { BluetoothGatt.CONNECTION_PRIORITY_HIGH diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/PacketParser.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/PacketParser.kt index c120417..ea9fdd3 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/PacketParser.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/PacketParser.kt @@ -10,9 +10,7 @@ object PacketParser { private const val MIN_PACKET_SIZE = 0x3B - // Button bitmask → enum. Bits 0..31 come from the uint32 at packet offset 0x03; the Pro - // Controller's two back paddles live in the next byte (0x07), folded into bits 32..39 so the - // whole set decodes through one mask table. GR is bit 0 of byte 0x07, GL is bit 1. + // docs/protocol.md#packet-layout. The back-paddle byte (0x07) is folded into bits 32..39. private val buttonMasks: List> = listOf( 0x80000000L to JoyconButton.ZL, 0x40000000L to JoyconButton.L, 0x00010000L to JoyconButton.Minus, 0x00080000L to JoyconButton.LS, 0x01000000L to JoyconButton.Down, 0x02000000L to JoyconButton.Up, diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiColorParser.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiColorParser.kt index d32fa8c..e53170c 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiColorParser.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiColorParser.kt @@ -1,23 +1,6 @@ package com.joegec.joycon2android.connection -/** - * Extracts the Joy-Con 2 shell accent color from an SPI-flash read reply. - * - * The controller stores several colors in SPI flash. The body color at - * 0x013019 is the near-black shell, identical across both Switch 2 Joy-Cons; - * the accent color at [ACCENT_COLOR_ADDRESS] is the per-side colour (coral on - * the right, blue on the left) that actually identifies a controller. We - * request a read of the surrounding DeviceInfo block and pull the accent out - * of the reply. - * - * Reply layout (command-response characteristic, little-endian), confirmed - * against a live controller: - * [0] report type (0x02 = SPI) - * [3] command (0x04 = SPI read) - * [8] data length - * [12..15] source address (LE) — echoes the requested read address - * [16..] data bytes, starting at the source address - */ +/** Pulls the shell accent colour out of an SPI-flash read reply: docs/protocol.md#spi-reads. */ object SpiColorParser { private const val REPORT_TYPE_SPI = 0x02 @@ -25,13 +8,9 @@ object SpiColorParser { private const val ADDRESS_OFFSET = 0x0C private const val DATA_OFFSET = 0x10 - /** SPI flash address of the shell accent color, 3 bytes RGB. */ const val ACCENT_COLOR_ADDRESS = 0x01301F - /** - * Returns the packed 0xRRGGBB accent color from an SPI-read reply, or null - * if [reply] is not an SPI-read reply or doesn't span the accent address. - */ + /** Packed 0xRRGGBB, or null if this is not an SPI read or does not span the accent address. */ fun parseAccentColor(reply: ByteArray): Int? { if (reply.size < DATA_OFFSET) return null if (reply[0].toInt() and 0xFF != REPORT_TYPE_SPI) return null diff --git a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerRepository.kt b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerRepository.kt index 900f27d..223ebb8 100644 --- a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerRepository.kt +++ b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerRepository.kt @@ -4,11 +4,7 @@ import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.PlayerNumber import kotlinx.coroutines.flow.StateFlow -/** - * Connected Joy-Cons as domain entities. The data layer assembles each [ConnectedJoycon] - * (live input + connection state) from its BLE connection, so callers never see the raw - * GATT layer. [controllers] re-emits on every input/state change. - */ +/** [controllers] re-emits on every input or state change. */ interface ControllerRepository { val controllers: StateFlow> val scanning: StateFlow diff --git a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibrator.kt b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibrator.kt index d7c209c..5182f97 100644 --- a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibrator.kt +++ b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibrator.kt @@ -2,24 +2,7 @@ package com.joegec.joycon2android.connection import com.joegec.joycon2android.model.JoyconInput -/** - * Rescales one controller's raw stick readings onto the full 0..4095 range, centred on 2048, - * that every downstream consumer assumes. - * - * Measured on hardware (2026-09): the raw 12-bit sticks reach only about +-1250 LSB of travel - * (full left 900, full right 3400) and they do not rest at 2048 — left Joy-Con x 2080 / y 2157, - * right x 2014 / y 2022. Taking 2048 as both the centre and the half-span therefore leaves full - * deflection at roughly 60% of range with a permanent 4-5% drift at rest. - * - * Travel is asymmetric about rest, so each direction carries its own span — the same - * centre/below/above triple the controller's own factory calibration stores. Spans start at - * [seedHalfSpan] and only ever widen, so a stick reaches full tilt from the first packet and - * self-corrects to units that travel further. - * - * Centre is learned from the first still window after connect and then frozen. Gyro bias can be - * re-learned whenever the controller goes quiet, but a stick held at full deflection is perfectly - * still, so "no movement means at rest" would happily adopt full tilt as centre. - */ +/** Onto 0..4095 centred on 2048, as everything downstream assumes: docs/protocol.md#stick-range-and-centre */ class StickCalibrator( restWindowSize: Int = DEFAULT_REST_WINDOW, maxRestSpreadLsb: Int = DEFAULT_MAX_REST_SPREAD, @@ -102,9 +85,7 @@ class StickCalibrator( private const val DEFAULT_REST_WINDOW = 30 private const val DEFAULT_MAX_REST_SPREAD = 32 - // Smallest travel measured across the user's two Joy-Cons was ~1180 LSB; seeding just - // under that means full tilt saturates slightly early rather than falling short, and - // the widening in [Axis.rescale] recovers the exact span once the stick is rolled. + // Just under the smallest measured travel (~1180), so full tilt saturates early rather than short. private const val DEFAULT_SEED_HALF_SPAN = 1150 } } diff --git a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ViewModePreferences.kt b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ViewModePreferences.kt index d9a3524..f487751 100644 --- a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ViewModePreferences.kt +++ b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ViewModePreferences.kt @@ -3,7 +3,6 @@ package com.joegec.joycon2android.connection import com.joegec.joycon2android.model.ConnectionViewMode import kotlinx.coroutines.flow.Flow -/** The connection view mode the user last chose, persisted across app restarts. */ interface ViewModePreferences { val viewMode: Flow suspend fun setViewMode(mode: ConnectionViewMode) diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ControllerAccent.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ControllerAccent.kt index b5809d3..eb1fde0 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ControllerAccent.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ControllerAccent.kt @@ -5,12 +5,7 @@ import androidx.compose.ui.graphics.Color import com.joegec.joycon2android.ui.theme.Accent import com.joegec.joycon2android.ui.theme.TextOnAccent -/** - * The colour a controller's live inputs light up in — its real shell colour — with the ink that - * stays readable on it. [JoyconCard] provides one per controller so each player's buttons and - * stick glow in that controller's own colour; the default is the teal accent for anything drawn - * outside a card. - */ +/** Provided per [JoyconCard]: docs/DESIGN.md#color */ data class ControllerAccent(val color: Color, val onColor: Color) val LocalControllerAccent = staticCompositionLocalOf { ControllerAccent(Accent, TextOnAccent) } diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ImuDisplay.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ImuDisplay.kt index b09e62d..d986f24 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ImuDisplay.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ImuDisplay.kt @@ -82,7 +82,6 @@ private fun ImuValue(text: String) { ) } -// Telemetry (mono, tabular figures, no font padding) pulled tight so the IMU grid stays compact. private val imuTextStyle = AppType.telemetry.copy( lineHeight = Dimens.fontSizeLabel * 1.1f, ) diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysImuDisplay.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysImuDisplay.kt index c0883dd..7da3366 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysImuDisplay.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysImuDisplay.kt @@ -85,8 +85,6 @@ private fun ImuText(text: String, bold: Boolean = false, dimmed: Boolean = false ) } -// Telemetry (mono, tabular figures, no font padding) pulled tight so the IMU grid stays compact; -// bold section labels override back to the default family. private val tightTextStyle = AppType.telemetry.copy( lineHeight = Dimens.fontSizeLabel * 1.1f, ) diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/StickCard.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/StickCard.kt index ca92dba..1210a80 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/StickCard.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/StickCard.kt @@ -68,8 +68,7 @@ private fun StickCanvas( } } -// Each axis is normalised against its own travel, so a full diagonal reaches 1 on both and lands -// outside the ring. The stick's gate is round, so it's the magnitude that clamps, not each axis. +// Each axis reaches 1 on its own, so a diagonal would leave the round gate: clamp the magnitude. private fun dotPosition(centre: Offset, nx: Float, ny: Float, travel: Float): Offset { val magnitude = hypot(nx, ny) val scale = if (magnitude > 1f) 1f / magnitude else 1f diff --git a/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuClientRegistry.kt b/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuClientRegistry.kt index 8a164f4..e15ebaf 100644 --- a/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuClientRegistry.kt +++ b/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuClientRegistry.kt @@ -3,13 +3,8 @@ package com.joegec.joycon2android.dsu import java.net.SocketAddress /** - * Tracks pad-data subscribers and which slots each one wants. Routing per slot on the - * server side matters: Dolphin's DSU devices overwrite their pad state with every - * received packet without checking the slot, so a server that broadcasts all slots to - * every client makes the last controller win. Clients are dropped after - * [timeoutMillis] of silence (the spec's ~5 s liveness convention). Callers supply the - * clock so the registry stays pure; register and recipientsFor run on different - * coroutines. + * Routes each slot only to its subscribers: docs/dsu-motion.md#the-server. [timeoutMillis] is the + * spec's liveness convention. [register] and [recipientsFor] run on different coroutines. */ class DsuClientRegistry(private val timeoutMillis: Long = 5_000) { diff --git a/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuPacketEncoder.kt b/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuPacketEncoder.kt index f0ce6eb..0b0b522 100644 --- a/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuPacketEncoder.kt +++ b/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuPacketEncoder.kt @@ -11,13 +11,7 @@ import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.zip.CRC32 -/** - * Encodes DSU (cemuhook) server packets. Spec: https://v1993.github.io/cemuhook-protocol/ - * - * All fields little-endian. 16-byte header: magic "DSUS", uint16 protocol version (1001), - * uint16 payload length (counts the uint32 message type that follows), uint32 CRC32 - * (computed over the whole packet with this field zeroed), uint32 server ID. - */ +/** Spec: https://v1993.github.io/cemuhook-protocol/ */ class DsuPacketEncoder( private val serverId: Int, private val motion: (DsuStream) -> DsuMotion = { stream -> @@ -121,8 +115,7 @@ class DsuPacketEncoder( return bits.toByte() } - // DSU sticks are uint8 centered at 128, Y up-positive — same polarity as the raw - // Joy-Con 0–4095 range, so scale only (unlike the HID report, which inverts Y) + // uint8 centred on 128, Y up like the raw Joy-Con range, so scale only. private fun putSticks(packet: ByteBuffer, gamepad: GamepadState) { packet.put(stickByte(gamepad.leftStickX)) packet.put(stickByte(gamepad.leftStickY)) diff --git a/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuRequestParser.kt b/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuRequestParser.kt index 9b4f736..0a2f9ba 100644 --- a/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuRequestParser.kt +++ b/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuRequestParser.kt @@ -4,10 +4,7 @@ import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.zip.CRC32 -/** - * Parses client → server DSU packets: magic "DSUC", then the same header layout the - * encoder writes. Packets with a bad magic, length, or CRC are dropped (returns null). - */ +/** Null for a bad magic, length or CRC. */ object DsuRequestParser { private const val HEADER_SIZE = 16 diff --git a/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuServer.kt b/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuServer.kt index 608e5d9..0a5d6f2 100644 --- a/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuServer.kt +++ b/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuServer.kt @@ -37,8 +37,7 @@ class DsuServer( private val sendBuffer = ByteArray(DsuPacketEncoder.PAD_DATA_PACKET_SIZE) private val packetCounters = LongArray(DsuPacketEncoder.SLOT_COUNT) - // Pad batches ride a buffered channel instead of a StateFlow: conflation would drop - // motion samples, and UDP sends can't run on the synchronous onState (main) thread + // Not a StateFlow: conflation drops motion samples, and sends can't run on the synchronous onState. private val batches = Channel(BATCH_BUFFER, BufferOverflow.DROP_OLDEST) private var socket: DatagramSocket? = null @@ -97,8 +96,7 @@ class DsuServer( batches.trySend(PadDataBatch(players, timestampMicros())) } - // Emulators dial the IPv4 address we advertise; getLoopbackAddress() resolves to - // IPv6 ::1 on Android, and a socket bound there never sees 127.0.0.1 datagrams + // Not getLoopbackAddress(), which is ::1 on Android: docs/dsu-motion.md#the-server private fun bindAddress(): InetAddress = InetAddress.getByAddress(byteArrayOf(127, 0, 0, 1)) private fun currentAddress(): String = "127.0.0.1:$port" @@ -111,8 +109,7 @@ class DsuServer( val datagram = DatagramPacket(ByteArray(RECEIVE_BUFFER_SIZE), RECEIVE_BUFFER_SIZE) while (!socket.isClosed) { try { - // receive() shrinks the packet to the last datagram's size; without a reset - // every following packet that is longer gets truncated and fails its CRC + // receive() shrinks the length to the last datagram, truncating any longer one after it. datagram.setLength(RECEIVE_BUFFER_SIZE) socket.receive(datagram) handleRequest(socket, datagram) diff --git a/feature/dsu/data/src/test/kotlin/com/joegec/joycon2android/dsu/DsuServerTest.kt b/feature/dsu/data/src/test/kotlin/com/joegec/joycon2android/dsu/DsuServerTest.kt index 0196952..f559728 100644 --- a/feature/dsu/data/src/test/kotlin/com/joegec/joycon2android/dsu/DsuServerTest.kt +++ b/feature/dsu/data/src/test/kotlin/com/joegec/joycon2android/dsu/DsuServerTest.kt @@ -30,8 +30,6 @@ class DsuServerTest { private val server = DsuServer(scope, port) { 1_000_000L } private lateinit var client: DatagramSocket - // IPv4 throughout: the server must answer at the 127.0.0.1 address emulators dial, - // not just whatever getLoopbackAddress() resolves to (::1 on Android) private val loopback: InetAddress = InetAddress.getByAddress(byteArrayOf(127, 0, 0, 1)) @Before diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuCoverage.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuCoverage.kt index 168d41f..82b24a8 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuCoverage.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuCoverage.kt @@ -2,11 +2,7 @@ package com.joegec.joycon2android.dsu import com.joegec.joycon2android.model.PlayerNumber -/** - * What the four slots cannot carry. A pad packet holds one accelerometer and gyroscope, so a - * player without a second slot still streams both Joy-Cons' buttons — only the left hand's - * motion is lost, which is why the two shortfalls are reported apart. - */ +/** Reported apart: a player without a second slot still streams both hands' buttons, losing only left-hand motion. */ data class DsuCoverage( val unservedPlayers: List = emptyList(), val unservedSecondHands: List = emptyList(), diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuSlots.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuSlots.kt index 2132f7a..1d6c3bb 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuSlots.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuSlots.kt @@ -2,16 +2,7 @@ package com.joegec.joycon2android.dsu import com.joegec.joycon2android.model.PlayerState -/** - * Maps players onto the protocol's four slots. Player N streams on slot N-1, so P5-P8 get no - * slot and are not served. - * - * A pad packet carries exactly one accelerometer and gyroscope, so a player holding two Joy-Cons - * cannot report both hands on one slot: the second hand needs a slot of its own for an emulator - * to read it (Dolphin's Nunchuk accelerometer, say). Players themselves always win the slot their - * number gives them; slotted pairs then take whatever is left, highest first, in player order. - * Four players therefore leave nothing over and no pair gets a second hand. - */ +/** docs/dsu-motion.md#slots */ object DsuSlots { const val COUNT = 4 diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuStream.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuStream.kt index c54d9eb..5a9bcfe 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuStream.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuStream.kt @@ -2,7 +2,6 @@ package com.joegec.joycon2android.dsu import com.joegec.joycon2android.model.PlayerState -/** One DSU slot and the controller state it reports. */ data class DsuStream( val slot: Int, val state: PlayerState, diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinDsuConfig.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinDsuConfig.kt index 7acb2b3..efbb88b 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinDsuConfig.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinDsuConfig.kt @@ -3,12 +3,7 @@ package com.joegec.joycon2android.dsu.emulator import com.joegec.joycon2android.dsu.DsuConfig import com.joegec.joycon2android.emulatorconfig.DolphinPaths -/** - * Dolphin's DSUClient.ini on Android. It lives in Dolphin's external data dir — writable by a - * shell-uid process (Shizuku / wireless debugging) but not by us directly. Servers are listed - * on the `Entries` line as `;`-separated `name:host:port` tokens; [merge] adds ours without - * disturbing any the user already configured. - */ +/** `Entries` lists servers as `;`-separated `name:host:port`; [merge] keeps the user's own. */ object DolphinDsuConfig { val path = DolphinPaths.config("DSUClient.ini") diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt index d69a31a..13b5ea6 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt @@ -2,8 +2,10 @@ package com.joegec.joycon2android.dsu.emulator import com.joegec.joycon2android.buttonmapping.JoyconSide import com.joegec.joycon2android.buttonmapping.MappingSource +import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.buttonmapping.StickDirection import com.joegec.joycon2android.buttonmapping.StickSource +import com.joegec.joycon2android.buttonmapping.emittedDirection import com.joegec.joycon2android.buttonmapping.emittedFor import com.joegec.joycon2android.buttonmapping.emittedStick import com.joegec.joycon2android.buttonmapping.target.WiimoteButton @@ -11,24 +13,19 @@ import com.joegec.joycon2android.buttonmapping.target.WiimoteStick import com.joegec.joycon2android.buttonmapping.toSourceMap import com.joegec.joycon2android.buttonmapping.toStickDirectionMap import com.joegec.joycon2android.dsu.DsuSlots +import com.joegec.joycon2android.emulatorconfig.DolphinControls import com.joegec.joycon2android.emulatorconfig.DolphinPaths import com.joegec.joycon2android.emulatorconfig.IniEditor import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.PlayerState -/** - * Generates Dolphin's WiimoteNew.ini button mappings for the DSU device, one `[WiimoteN]` - * section per assigned player (player N streams on DSU slot N-1 → `DSUClient//Joycon2`, - * the name matching our [DolphinDsuConfig] entry), driven by the user's customizable Joy-Con -> - * Wiimote/Nunchuk mapping. By default a single sideways Joy-Con drives the D-pad target from its - * own analog stick with no extension, and a pair drives it from the physical D-pad and exposes its - * left stick as the Nunchuk; a single Joy-Con gains a Nunchuk once the user binds one of its controls. [DS4_NAMES]/[PAD_NAMES] are the fixed, body-independent DS4-convention names the DSU - * device exposes for each Android input (see the in-app mapping table); [specFor] resolves a - * customized source to the one its body actually emits. - */ +/** What it writes and why: docs/dsu-motion.md#dolphin-wii-remote-mapping */ object DolphinWiimoteConfig { val path = DolphinPaths.config("WiimoteNew.ini") + // Dolphin's 25 clamps the cursor after +-12.5 degrees of turn, which a hand-held aim overruns. + private const val IMU_TOTAL_YAW_DEGREES = 60 + // Keeps a tilted grip's gravity leak from nudging the virtual remote off its neutral position. private const val SWING_DEAD_ZONE_PERCENT = 20 private const val SWING_RANGE_PERCENT = 7 @@ -78,14 +75,14 @@ object DolphinWiimoteConfig { private val ACCEL_DIRECTIONS = listOf("Up", "Down", "Left", "Right", "Forward", "Backward") - private val IMU_CONTROLS = ACCEL_DIRECTIONS.map { "IMUAccelerometer/$it" to "Accel $it" } + + private val GYRO_DIRECTIONS = listOf("Pitch Up", "Pitch Down", "Roll Left", "Roll Right", "Yaw Left", "Yaw Right") - .map { "IMUGyroscope/$it" to "Gyro $it" } - // A lone Joy-Con streams in its sideways grip (SidewaysMotion), but the emulated remote is the - // Joy-Con's own body, so its inputs turn back about the button face. Up/Down and yaw lie on - // that axis and pass through name-to-name. - private val LEFT_BODY_INPUTS = mapOf( + private val IMU_CONTROLS = ACCEL_DIRECTIONS.map { "IMUAccelerometer/$it" to "Accel $it" } + + GYRO_DIRECTIONS.map { "IMUGyroscope/$it" to "Gyro $it" } + + // Turn a lone Joy-Con's sideways stream back onto the body it aims: docs/dsu-motion.md#sideways-joy-cons + private val SIDEWAYS_REMOTE_INPUTS = mapOf( "Accel Left" to "Accel Backward", "Accel Right" to "Accel Forward", "Accel Forward" to "Accel Left", "Accel Backward" to "Accel Right", "Gyro Pitch Up" to "Gyro Roll Right", "Gyro Pitch Down" to "Gyro Roll Left", @@ -98,32 +95,75 @@ object DolphinWiimoteConfig { "Gyro Roll Left" to "Gyro Pitch Down", "Gyro Roll Right" to "Gyro Pitch Up", ) - private fun imuLines(side: JoyconSide): List { - val bodyInputs = when (side) { - JoyconSide.LEFT -> LEFT_BODY_INPUTS - JoyconSide.RIGHT -> RIGHT_BODY_INPUTS - JoyconSide.DUAL -> emptyMap() + // A left Joy-Con's L/ZL edge is already a sideways remote's nose, so only a right one differs. + private fun bodyInputs(side: JoyconSide, sidewaysRemote: Boolean): Map = when (side) { + JoyconSide.DUAL -> emptyMap() + JoyconSide.LEFT -> SIDEWAYS_REMOTE_INPUTS + JoyconSide.RIGHT -> if (sidewaysRemote) SIDEWAYS_REMOTE_INPUTS else RIGHT_BODY_INPUTS + } + + // The player's up is a sideways remote's right. Dolphin's own option would turn the + // accelerometer a second time, so it stays off and these turn the bindings instead. + private val SIDEWAYS_DPAD_KEYS = mapOf( + WiimoteButton.DPadUp to "D-Pad/Right", + WiimoteButton.DPadRight to "D-Pad/Down", + WiimoteButton.DPadDown to "D-Pad/Left", + WiimoteButton.DPadLeft to "D-Pad/Up", + ) + + private fun dolphinKey(target: WiimoteButton, sideways: Boolean): String = + (if (sideways) SIDEWAYS_DPAD_KEYS[target] else null) ?: DOLPHIN_KEYS.getValue(target) + + // Measured: docs/dsu-motion.md#tricks-and-wheelies + private const val FLICK_RADIANS = 9 + private const val FLICK_LOCKOUT_SECONDS = 0.4 + private const val TRICK_ACCELERATION = 50 // m/s^2, past what an emulated remote can report + private const val TRICK_SECONDS = 0.6 + private const val TRICK_PERIOD_SECONDS = 0.15 + private const val FULL_TURN = 6.2832 + + private const val UP = "IMUAccelerometer/Up" + private val TRICK_AXES = mapOf(UP to ("Pitch Up" to "Pitch Down"), "IMUAccelerometer/Down" to ("Pitch Down" to "Pitch Up")) + + // Each direction locks out the other, so a flick's rebound can't cancel the wheelie. + private fun trickTrigger( + side: JoyconSide, + control: String, + sidewaysRemote: Boolean, + bound: List?, + ): String? { + val (own, opposite) = TRICK_AXES[control] ?: return null + val flick = if (sidewaysRemote) { + "(`Gyro $own` / $FLICK_RADIANS) & not(pulse(`Gyro $opposite` / $FLICK_RADIANS, $FLICK_LOCKOUT_SECONDS))" + } else { + null } - return IMU_CONTROLS.map { (control, input) -> "$control = `${bodyInputs[input] ?: input}`" } + - "IMUIR/Enabled = True" + val pressed = bound?.takeIf { control == UP }?.let { expressionFor(side, sidewaysRemote, it) } + return listOfNotNull(flick, pressed).takeIf { it.isNotEmpty() }?.joinToString(" | ") + } + + // Half-rectified, so every jerk goes the way the flick did. + private fun trickShake(trigger: String?): String? = trigger?.let { + "pulse($it, $TRICK_SECONDS) * max(sin(timer($TRICK_PERIOD_SECONDS) * $FULL_TURN), 0) * $TRICK_ACCELERATION" } - // Dolphin's emulated remote only ever translates through the Swing group — the IMU path feeds - // rotation alone — so the virtual remote stays pinned in space and the IR dots never change - // separation. Games that read a thrust as distance to the sensor bar (Wii Play Billiards charges - // cue strength that way) see nothing from accel and gyro alone. A push toward the screen lands - // on `Accel Forward` for a pair held like a Wii Remote, and on `Accel Up` for a solo sideways - // Joy-Con (out through the button face); pairing each with its opposite input makes the value - // signed, since Dolphin clamps a single input at zero. - // - // An accelerometer cannot tell gravity from sustained acceleration, so a tilted grip parks up to - // 1 g on that axis and Swing reads it as a thrust held forever. smooth() is a slew limiter, so - // subtracting it high-passes the axis: the tracker catches a static tilt within a third of a - // second and cancels it, while a thrust's ~80 ms transient outruns it. Range then trims the - // inputs, which arrive at 9.8 per g, to a full-distance lunge at roughly a 1.5 g thrust. - private fun swingLines(side: JoyconSide): List { - val thrust = if (side == JoyconSide.DUAL) "Accel Forward" else "Accel Up" - val pull = if (side == JoyconSide.DUAL) "Accel Backward" else "Accel Down" + private fun imuLines( + side: JoyconSide, + sidewaysRemote: Boolean, + bound: List?, + ): List { + val bodyInputs = bodyInputs(side, sidewaysRemote) + return IMU_CONTROLS.map { (control, input) -> + val read = "`${bodyInputs[input] ?: input}`" + val shake = trickShake(trickTrigger(side, control, sidewaysRemote, bound)) + "$control = " + (shake?.let { "$read + $it" } ?: read) + } + listOf("IMUIR/Enabled = True", "IMUIR/Total Yaw = $IMU_TOTAL_YAW_DEGREES") + } + + private fun swingLines(side: JoyconSide, sidewaysRemote: Boolean): List { + val body = bodyInputs(side, sidewaysRemote) + val thrust = body["Accel Forward"] ?: "Accel Forward" + val pull = body["Accel Backward"] ?: "Accel Backward" val signed = "(`$thrust` - `$pull`)" return listOf( "Swing/Forward = $signed - smooth($signed, $SWING_SETTLE_SECONDS)", @@ -132,21 +172,27 @@ object DolphinWiimoteConfig { ) } - // A pad packet carries one IMU, so the Nunchuk hand streams on a slot of its own and the - // extension reads it across devices: Dolphin splits a control on its last colon, so - // `:` reaches another pad. A real Nunchuk has no gyroscope, only this accel. + // Dolphin splits on the last colon, so `:` reads the second hand's slot. private fun nunchukImuLines(slot: Int): List = ACCEL_DIRECTIONS.map { "Nunchuk/IMUAccelerometer/$it = `DSUClient/$slot/Joycon2:Accel $it`" } - fun merge(existing: String?, players: List, mappingFor: (JoyconSide) -> Map): String = - IniEditor.mergeSections(existing, sections(players, mappingFor)) + fun merge( + existing: String?, + players: List, + sidewaysRemoteFor: (PlayerBody) -> Boolean, + mappingFor: (PlayerBody) -> Map, + ): String = IniEditor.mergeSections(existing, sections(players, sidewaysRemoteFor, mappingFor)) - private fun sections(players: List, mappingFor: (JoyconSide) -> Map): Map { + private fun sections( + players: List, + sidewaysRemoteFor: (PlayerBody) -> Boolean, + mappingFor: (PlayerBody) -> Map, + ): Map { val secondHands = DsuSlots.secondHands(players).associate { it.state.player to it.slot } return players.mapNotNull { player -> val slot = player.player.index - 1 if (slot !in 0..3) return@mapNotNull null - bodyFor(player, slot, secondHands[player.player], mappingFor) + bodyFor(player, slot, secondHands[player.player], sidewaysRemoteFor, mappingFor) ?.let { "[Wiimote${player.player.index}]" to it } }.toMap() } @@ -155,7 +201,8 @@ object DolphinWiimoteConfig { player: PlayerState, slot: Int, secondHandSlot: Int?, - mappingFor: (JoyconSide) -> Map, + sidewaysRemoteFor: (PlayerBody) -> Boolean, + mappingFor: (PlayerBody) -> Map, ): String? { val side = when { player.hasPro -> return null @@ -164,6 +211,8 @@ object DolphinWiimoteConfig { player.left != null -> JoyconSide.LEFT else -> return null } + val body = PlayerBody(player.player, side) + val sidewaysRemote = sidewaysRemoteFor(body) // Source = 1 forces this Wii Remote slot to Emulated, so the mappings actually apply val header = listOf("Source = 1", "Device = DSUClient/$slot/Joycon2") val nunchukImu = if (side == JoyconSide.DUAL && secondHandSlot != null) { @@ -171,15 +220,21 @@ object DolphinWiimoteConfig { } else { emptyList() } - return (header + lines(side, mappingFor(side)) + imuLines(side) + swingLines(side) + nunchukImu) + val mapping = mappingFor(body) + val shake = mapping.toSourceMap()[WiimoteButton.Shake] + return (header + lines(side, sidewaysRemote, mapping) + imuLines(side, sidewaysRemote, shake) + + swingLines(side, sidewaysRemote) + nunchukImu) .joinToString("\n", postfix = "\n") } - private fun lines(side: JoyconSide, mapping: Map): List { - val buttonLines = mapping.toSourceMap().mapNotNull { (target, source) -> - specFor(side, source)?.let { spec -> "${DOLPHIN_KEYS.getValue(target)} = `$spec`" } - } - val stickLines = nunchukStickLines(side, mapping) + private fun lines(side: JoyconSide, sidewaysRemote: Boolean, mapping: Map): List { + val sideways = sidewaysRemote && side != JoyconSide.DUAL + val buttonLines = (mapping.toSourceMap() - WiimoteButton.Shake) + .mapNotNull { (target, sources) -> + expressionFor(side, sidewaysRemote, sources) + ?.let { expression -> "${dolphinKey(target, sideways)} = $expression" } + } + val stickLines = nunchukStickLines(side, sidewaysRemote, mapping) val recenterSpec = if (side == JoyconSide.LEFT) "L1" else "R1" val extension = if (usesNunchuk(side, buttonLines + stickLines)) "Nunchuk" else "None" return buttonLines + listOf("IMUIR/Recenter = `$recenterSpec`", "Extension = $extension") + stickLines @@ -189,16 +244,21 @@ object DolphinWiimoteConfig { private fun usesNunchuk(side: JoyconSide, mappedLines: List) = side == JoyconSide.DUAL || mappedLines.any { it.startsWith("Nunchuk/") } - private fun nunchukStickLines(side: JoyconSide, mapping: Map): List = + private fun nunchukStickLines(side: JoyconSide, sidewaysRemote: Boolean, mapping: Map): List = mapping.toStickDirectionMap().values.flatMap { directions -> - directions.mapNotNull { (direction, source) -> - specFor(side, source)?.let { spec -> "Nunchuk/Stick/${direction.displayName} = `$spec`" } + directions.mapNotNull { (direction, sources) -> + expressionFor(side, sidewaysRemote, sources)?.let { expression -> "Nunchuk/Stick/${DolphinControls.DIRECTIONS.getValue(direction)} = $expression" } } } - private fun specFor(side: JoyconSide, source: MappingSource): String? = when (source) { + private fun expressionFor(side: JoyconSide, sidewaysRemote: Boolean, sources: List): String? = + sources.mapNotNull { specFor(side, sidewaysRemote, it) } + .takeIf { it.isNotEmpty() } + ?.joinToString(" | ") { "`$it`" } + + private fun specFor(side: JoyconSide, sidewaysRemote: Boolean, source: MappingSource): String? = when (source) { is MappingSource.Button -> source.button.emittedFor(side)?.let { DS4_NAMES[it] ?: PAD_NAMES[it] } - is MappingSource.Stick -> tiltSpec(source.emittedStick(side), source.direction) + is MappingSource.Stick -> tiltSpec(source.emittedStick(side), source.emittedDirection(side, sidewaysRemote)) } // DSU sticks report up as a positive Y, unlike Android's axes. diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt index 32c598b..c2ff1d6 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt @@ -2,6 +2,7 @@ package com.joegec.joycon2android.dsu.emulator import com.joegec.joycon2android.buttonmapping.JoyconSide import com.joegec.joycon2android.buttonmapping.MappingSource +import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.buttonmapping.StickDirection import com.joegec.joycon2android.buttonmapping.StickSource import com.joegec.joycon2android.buttonmapping.emittedFor @@ -20,29 +21,7 @@ import com.joegec.joycon2android.emulatorconfig.defineEdenKey import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.PlayerState -/** - * Binds Eden to our DSU server in `config.ini`'s `[Controls]`: the three server switches, then a - * whole controller per assigned player — buttons, sticks and motion — driven by the user's - * customizable Joy-Con -> Pro Controller mapping. A cemuhook pad carries the full DS4 button set - * and both sticks, so nothing else is needed for a player to play; the Virtual Gamepad is an - * alternative route to the same keys, not a prerequisite. - * - * Eden's cemuhook engine addresses a pad by `guid`, `port` and `pad`, and nothing else. The - * server's `guid` is its IPv4 address as a 32-bit integer, hex, right-aligned in an otherwise-zero - * UUID written raw (no dashes); `port` is the UDP port, not a controller index. `pad` is a global - * index, `client * 4 + slot`, so with our server as Eden's only client it is the DSU slot itself. - * - * [DS4_BITS] is the protocol wiring, not a preference: cemuhook's two button bytes packed - * low-then-high are exactly Eden's `PadButton` values, and Home and the touchpad click ride the - * bytes above them. Sticks arrive as raw bytes that Eden reads as `(v - 127) / 127`, so the - * axis pairs need no inversion. - * - * Motion is the one thing a pad cannot share: a pad packet carries a single accelerometer and - * gyroscope, so `motion` is always index 0 and each hand streams on a slot of its own - * ([DsuSlots]). The hand holding the player's own slot lands on `motionright`, its second hand on - * `motionleft`; one Joy-Con, or a pair that ran out of slots, binds both to the same pad, which is - * what Eden's own auto-mapping does for every device. - */ +/** How Eden addresses a cemuhook pad: docs/dsu-motion.md#edens-cemuhook-bindings */ object EdenDsuConfig { private const val ENGINE = "cemuhookudp" @@ -71,10 +50,8 @@ object EdenDsuConfig { fun merge( existing: String?, players: List, - mappingFor: (JoyconSide) -> Map, + mappingFor: (PlayerBody) -> Map, ): String { - // A reassignment leaves stale bindings on players who no longer hold a controller, and - // those would keep feeding an emulated pad from whoever now owns that slot. val cleared = IniEditor.removeKeys(existing, EdenControls.SECTION) { it.matches(EdenControls.PLAYER_KEY) } return IniEditor.setKeys( cleared, @@ -88,7 +65,7 @@ object EdenDsuConfig { val keys = LinkedHashMap() keys.defineEdenKey("motion_enabled", "true") keys.defineEdenKey("udp_input_servers", serverList(existing)) - // What makes Eden offer the UDP pads at all — off, and none of the bindings below resolve. + // Off, none of the bindings below resolve. keys.defineEdenKey("enable_udp_controller", "true") return keys } @@ -105,7 +82,7 @@ object EdenDsuConfig { private fun playerKeys( players: List, - mappingFor: (JoyconSide) -> Map, + mappingFor: (PlayerBody) -> Map, ): Map { val secondHands = DsuSlots.secondHands(players).associate { it.state.player to it.slot } val keys = LinkedHashMap() @@ -114,7 +91,7 @@ object EdenDsuConfig { if (slot !in 0 until DsuSlots.COUNT) return@forEach val type = EdenControls.npadType(player) ?: return@forEach val side = sideFor(player) ?: return@forEach - val mapping = mappingFor(side) + val mapping = mappingFor(PlayerBody(player.player, side)) val device = device(slot) keys.defineEdenKey("player_${slot}_type", type.toString()) @@ -136,8 +113,8 @@ object EdenDsuConfig { private fun motion(pad: Int) = EdenControls.quote("${device(pad)},motion:0") private fun buttonBindings(side: JoyconSide, mapping: Map): Map = - mapping.toSourceMap().mapNotNull { (target, source) -> - inputFor(side, source)?.let { EdenControls.BUTTON_KEYS.getValue(target) to it } + mapping.toSourceMap().mapNotNull { (target, sources) -> + inputFor(side, sources)?.let { EdenControls.BUTTON_KEYS.getValue(target) to it } }.toMap() private fun stickBindings(side: JoyconSide, mapping: Map, device: String): Map = @@ -145,13 +122,13 @@ object EdenDsuConfig { stickFor(side, directions, device)?.let { EdenControls.STICK_KEYS.getValue(target) to it } }.toMap() - private fun stickFor(side: JoyconSide, directions: Map, device: String): String? { + private fun stickFor(side: JoyconSide, directions: Map>, device: String): String? { directions.wholeEmittedStick(side)?.let { stick -> val (x, y) = axesOf(stick) return "$device,axis_x:$x,axis_y:$y" } - val inputs = directions.mapNotNull { (direction, source) -> - inputFor(side, source)?.let { direction to "$device,$it" } + val inputs = directions.mapNotNull { (direction, sources) -> + inputFor(side, sources)?.let { direction to "$device,$it" } } return inputs.takeIf { it.isNotEmpty() }?.let { EdenControls.stickFromButtons(it.toMap()) } } @@ -159,6 +136,9 @@ object EdenDsuConfig { private fun axesOf(stick: StickSource) = if (stick == StickSource.LEFT_STICK) LEFT_STICK_AXES else RIGHT_STICK_AXES + private fun inputFor(side: JoyconSide, sources: List): String? = + sources.firstNotNullOfOrNull { inputFor(side, it) } + private fun inputFor(side: JoyconSide, source: MappingSource): String? = when (source) { is MappingSource.Button -> source.button.emittedFor(side)?.let(DS4_BITS::get)?.let { "button:$it" } is MappingSource.Stick -> tiltOf(source.emittedStick(side), source.direction) diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/DeviceMotionBlocker.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/DeviceMotionBlocker.kt index 3b99fbe..59f3458 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/DeviceMotionBlocker.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/DeviceMotionBlocker.kt @@ -1,9 +1,6 @@ package com.joegec.joycon2android.dsu.motion -/** - * Stops emulators reading this device's own motion sensors. Eden's Android build feeds them into - * Player 1 on top of any mapped motion, so a DSU controller's readings alternate with the device's. - */ +/** docs/dsu-motion.md#eden-reads-the-devices-own-motion */ interface DeviceMotionBlocker { suspend fun setBlocked(blocked: Boolean) } diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/GyroCalibrator.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/GyroCalibrator.kt index 42572b3..2298bf1 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/GyroCalibrator.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/GyroCalibrator.kt @@ -2,14 +2,7 @@ package com.joegec.joycon2android.dsu.motion import com.joegec.joycon2android.model.JoyconInput -/** - * Removes per-controller gyro bias. Joy-Con 2 gyros idle with a constant offset - * (+0.2 dps yaw / +0.9 dps roll observed on hardware), which DSU clients integrate - * into a steady pointer drift. Whenever a controller's gyro stays within - * [maxSpreadLsb] (~2.4 dps) for [windowSize] consecutive samples (~2 s at 120 Hz — - * true at rest; hand tremor exceeds it), the window mean becomes that controller's - * bias. Mirrors the runtime recalibration the Switch itself performs. - */ +/** docs/dsu-motion.md#gyro-bias */ class GyroCalibrator( private val windowSize: Int = 240, private val maxSpreadLsb: Int = 40, diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/MotionConverter.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/MotionConverter.kt index 88b0e37..1de6b94 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/MotionConverter.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/MotionConverter.kt @@ -2,37 +2,7 @@ package com.joegec.joycon2android.dsu.motion import com.joegec.joycon2android.model.JoyconInput -/** - * Raw Joy-Con IMU → cemuhook/DS4 motion frame. - * - * Scale factors are the Switch 1 family values, verified on Joy-Con 2 hardware - * (2026-06: at rest gravity reads exactly −1.00 g): accel ±8 g → 0.000244 g/LSB, - * gyro ±2000 dps → 0.06103 dps/LSB. - * - * Measured Joy-Con (R) raw frame: X = controller's RIGHT, Y = toward the tail, Z = out of - * the button face. X was documented as "left" until 2026-09, when a rail-down static pose - * (SL/SR against the table, so gravity points toward the controller's right) read +1 g on - * the wire's left axis — mirrored. Left/right tilt reached games reversed, and because - * angular velocity is a pseudovector, roll had to be mirrored with it to stay physically - * consistent, which is why both flipped together. The cemuhook wire frame, - * anchored against Dolphin's on-screen Wii pointer (its complementary filter makes the - * ACCELEROMETER the authority on sustained pitch — gyro signs alone can't be judged - * from pointer direction): x = left, y = down through the controller, z = toward the - * player. Flat at rest → accel (0,−1,0); nose up → accel z = −1 and gyro pitch − - * (verified via pointer flicks: gyro shows up in the fast response, accel in the - * settled position); turn right → +yaw; roll right → +roll. The axis signs are DS4 - * hardware history, not a consistent right-handed frame — verify any change against - * the pointer itself, fast and slow movements separately. - * - * Yaw is the one sign no measurement here pins: it turns about gravity, so a static pose - * cannot see it and the accel/gyro consistency check cannot either. It is kept as the - * pointer's horizontal response reports it. Mirroring x strictly implies mirroring yaw too, - * so if horizontal pointing ever reads backwards, flip yaw rather than re-deriving this. - * - * This converts whatever frame it is given; a lone sideways Joy-Con is turned into its grip - * first by [SidewaysMotion]. Left Joy-Con and Pro are assumed to share the raw frame — - * unverified; recalibrate with tools/dsu_client if motion feels rotated. - */ +/** Frames, scales and signs: docs/dsu-motion.md#motion-frame. Verify changes against Dolphin's pointer. */ object MotionConverter { private const val ACCEL_G_PER_LSB = 0.000244140625f diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/SidewaysMotion.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/SidewaysMotion.kt index 997918c..da6642e 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/SidewaysMotion.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/SidewaysMotion.kt @@ -4,17 +4,7 @@ import com.joegec.joycon2android.dsu.DsuStream import com.joegec.joycon2android.model.JoyconInput import com.joegec.joycon2android.model.Side -/** - * Turns a lone Joy-Con's IMU 90° about its button face into the grip it is held in sideways. The - * pad's buttons and stick already arrive in that grip, so an emulator that presents it as a Pro - * Controller (Eden) needs its motion there too, the way SDL delivers a real horizontal Joy-Con; - * otherwise tilting reads as if the Joy-Con's nose pointed at the screen. - * - * Directions measured in Eden's Mario Kart 8 (2026-09), held up like a wheel: turning each body the - * way `SidewaysMapper` turns its stick steered upside down on both Joy-Cons, so the IMU turns the - * opposite way — its raw axes evidently don't line up with the stick's. Z (out of the face) is the - * rotation axis, so it passes through unchanged. - */ +/** Measured, and opposite to the stick's turn: docs/dsu-motion.md#sideways-joy-cons */ object SidewaysMotion { fun orient(stream: DsuStream, input: JoyconInput): JoyconInput { diff --git a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt index 5126a11..fbd3754 100644 --- a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt +++ b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt @@ -2,23 +2,29 @@ package com.joegec.joycon2android.dsu.emulator import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.JoyconSide -import com.joegec.joycon2android.buttonmapping.defaultMappingEntries +import com.joegec.joycon2android.buttonmapping.PlayerBody +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.PlayerState import com.joegec.joycon2android.model.Side +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -private fun defaultWiimoteMapping(side: JoyconSide) = defaultMappingEntries(Console.WIIMOTE_NUNCHUK, side) +private fun defaultWiimoteMapping(side: JoyconSide) = MappingPresets.default(Console.WIIMOTE_NUNCHUK).entries(side) + +private val wiimoteMapping: (PlayerBody) -> Map = { defaultWiimoteMapping(it.side) } + +private fun wiimoteMappingFor(body: PlayerBody) = defaultWiimoteMapping(body.side) class DolphinWiimoteConfigTest { private fun joycon(side: Side) = ConnectedJoycon(address = side.name, side = side, deviceName = "Joy-Con") - private fun merge(existing: String?, players: List) = - DolphinWiimoteConfig.merge(existing, players, ::defaultWiimoteMapping) + private fun merge(existing: String?, players: List, sidewaysRemote: Boolean = false) = + DolphinWiimoteConfig.merge(existing, players, { sidewaysRemote }, wiimoteMapping) @Test fun `right-only player maps the stick to the d-pad and uses no extension`() { @@ -28,7 +34,7 @@ class DolphinWiimoteConfigTest { assertTrue(result.contains("Source = 1")) assertTrue(result.contains("Device = DSUClient/0/Joycon2")) assertTrue(result.contains("Buttons/A = `Cross`")) // physical A rotates onto B - assertTrue(result.contains("D-Pad/Up = `Left Y+`")) + assertTrue(result.contains("D-Pad/Up = `Left X+`")) assertTrue(result.contains("IMUIR/Recenter = `R1`")) assertTrue(result.contains("Extension = None")) } @@ -37,7 +43,7 @@ class DolphinWiimoteConfigTest { fun `left-only player maps its directions onto faces and recenters on L`() { val result = merge(null, listOf(PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT)))) - assertTrue(result.contains("Buttons/A = `Circle`")) // Down rotates onto A + assertTrue(result.contains("Buttons/A = `Triangle`")) // Right rotates onto X assertTrue(result.contains("Buttons/Home = `Touch Button`")) assertTrue(result.contains("IMUIR/Recenter = `L1`")) } @@ -61,7 +67,7 @@ class DolphinWiimoteConfigTest { val pair = PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT), right = joycon(Side.RIGHT)) val mapping = defaultWiimoteMapping(JoyconSide.DUAL) + mapOf("NunchukStick_UP" to "Up") - val result = DolphinWiimoteConfig.merge(null, listOf(pair)) { mapping } + val result = DolphinWiimoteConfig.merge(null, listOf(pair), { false }) { mapping } assertTrue(result.contains("Nunchuk/Stick/Up = `Pad N`")) assertTrue(result.contains("Nunchuk/Stick/Down = `Left Y-`")) @@ -70,15 +76,26 @@ class DolphinWiimoteConfigTest { @Test fun `a lone Joy-Con plugs in a nunchuk once its stick is mapped`() { val mapping = defaultWiimoteMapping(JoyconSide.RIGHT) + mapOf("NunchukStick_UP" to "X", "NunchukStick_DOWN" to "B") + val player = listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))) - val result = DolphinWiimoteConfig.merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT)))) { mapping } + val result = DolphinWiimoteConfig.merge(null, player, { false }) { mapping } assertTrue(result.contains("Extension = Nunchuk")) assertTrue(result.contains("Nunchuk/Stick/Up = `Circle`")) // physical X rotates onto A - assertTrue(result.contains("D-Pad/Up = `Left Y+`")) // its own stick still steers the d-pad + assertTrue(result.contains("D-Pad/Up = `Left X+`")) // its own stick still steers the d-pad assertFalse(result.contains("Nunchuk/IMUAccelerometer")) // no second hand to stream one } + @Test + fun `a target bound to several sources fires from any of them`() { + val mapping = defaultWiimoteMapping(JoyconSide.RIGHT) + mapOf("DPadUp" to "RIGHT_STICK_UP|SlRight") + val player = listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))) + + val result = DolphinWiimoteConfig.merge(null, player, { false }) { mapping } + + assertTrue(result.contains("D-Pad/Up = `Left X+` | `L1`")) // SL rotates onto L held sideways + } + @Test fun `a pair points the nunchuk accelerometer at the second hand's own slot`() { val both = PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT), right = joycon(Side.RIGHT)) @@ -128,42 +145,134 @@ class DolphinWiimoteConfigTest { } @Test - fun `a sideways Joy-Con thrusts out through its button face`() { + fun `a lone Joy-Con thrusts along the axis its nose reads`() { val result = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT)))) - assertTrue(result.contains("Swing/Forward = (`Accel Up` - `Accel Down`) - smooth(")) + assertTrue(result.contains("Swing/Forward = (`Accel Right` - `Accel Left`) - smooth(")) } @Test - fun `a pair maps its motion name-to-name`() { - val both = PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT), right = joycon(Side.RIGHT)) - - val result = merge(null, listOf(both)) + fun `the pointer's yaw clamp is widened past a living room's worth`() { + val result = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT)))) - assertTrue(result.contains("IMUAccelerometer/Forward = `Accel Forward`")) - assertTrue(result.contains("IMUGyroscope/Pitch Up = `Gyro Pitch Up`")) + assertTrue(result.contains("IMUIR/Total Yaw = 60")) } @Test - fun `a sideways right Joy-Con turns its streamed grip back into the remote's body`() { + fun `a right Joy-Con keeps its own body until the layout plays sideways`() { val result = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT)))) assertTrue(result.contains("IMUAccelerometer/Up = `Accel Up`")) assertTrue(result.contains("IMUAccelerometer/Forward = `Accel Right`")) - assertTrue(result.contains("IMUAccelerometer/Left = `Accel Forward`")) assertTrue(result.contains("IMUGyroscope/Pitch Up = `Gyro Roll Left`")) - assertTrue(result.contains("IMUGyroscope/Roll Right = `Gyro Pitch Up`")) assertTrue(result.contains("IMUGyroscope/Yaw Left = `Gyro Yaw Left`")) } @Test - fun `a sideways left Joy-Con turns its streamed grip back the other way`() { - val result = merge(null, listOf(PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT)))) + fun `a left Joy-Con reads the same either way`() { + val player = listOf(PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT))) + + listOf(merge(null, player), merge(null, player, sidewaysRemote = true)).forEach { result -> + assertTrue(result.contains("IMUAccelerometer/Forward = `Accel Left`")) + assertTrue(result.contains("IMUGyroscope/Pitch Up = `Gyro Roll Right`")) + } + // ...though only the sideways one turns its flick into a trick. + assertFalse(merge(null, player).contains("pulse(")) + } + @Test + fun `playing sideways turns a right Joy-Con onto the sideways remote's frame`() { + val player = listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))) + + val result = merge(null, player, sidewaysRemote = true) + + assertTrue(result.contains("IMUAccelerometer/Up = `Accel Up`")) assertTrue(result.contains("IMUAccelerometer/Forward = `Accel Left`")) - assertTrue(result.contains("IMUAccelerometer/Left = `Accel Backward`")) assertTrue(result.contains("IMUGyroscope/Pitch Up = `Gyro Roll Right`")) - assertTrue(result.contains("IMUGyroscope/Roll Right = `Gyro Pitch Down`")) + assertTrue(result.contains("IMUGyroscope/Yaw Left = `Gyro Yaw Left`")) + } + + @Test + fun `off a sideways remote, a lone Joy-Con's stick points up toward its L or R`() { + val right = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT)))) + val left = merge(null, listOf(PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT)))) + + assertTrue(right.contains("D-Pad/Up = `Left X+`")) + assertTrue(right.contains("D-Pad/Left = `Left Y+`")) // the rail + assertTrue(left.contains("D-Pad/Up = `Left X-`")) + assertTrue(left.contains("D-Pad/Right = `Left Y+`")) // the rail + } + + @Test + fun `playing sideways turns the d-pad a quarter, on both bodies`() { + val right = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))), sidewaysRemote = true) + val left = merge(null, listOf(PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT))), sidewaysRemote = true) + + listOf(right, left).forEach { result -> + assertTrue(result.contains("D-Pad/Right = `Left Y+`")) // the stick's up is the remote's right + assertTrue(result.contains("D-Pad/Down = `Left X+`")) + assertTrue(result.contains("D-Pad/Left = `Left Y-`")) + assertTrue(result.contains("D-Pad/Up = `Left X-`")) + } + } + + @Test + fun `a pair is held like a remote already, so it never turns`() { + val both = PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT), right = joycon(Side.RIGHT)) + + val result = merge(null, listOf(both), sidewaysRemote = true) + + assertTrue(result.contains("IMUAccelerometer/Forward = `Accel Forward`")) + assertTrue(result.contains("IMUGyroscope/Pitch Up = `Gyro Pitch Up`")) + assertTrue(result.contains("D-Pad/Up = `Pad N`")) + } + + @Test + fun `playing sideways turns a wrist flick into a jerk the way it was flicked`() { + val result = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))), sidewaysRemote = true) + + val up = "(`Gyro Pitch Up` / 9) & not(pulse(`Gyro Pitch Down` / 9, 0.4))" + assertTrue( + result.contains( + "IMUAccelerometer/Up = `Accel Up` + pulse($up, 0.6) * " + + "max(sin(timer(0.15) * 6.2832), 0) * 50", + ), + ) + assertFalse(result.contains("Shake/")) // Dolphin's own group never landed one + } + + @Test + fun `each flick direction locks the other out`() { + val result = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))), sidewaysRemote = true) + + assertTrue(result.contains("IMUAccelerometer/Down = `Accel Down` + pulse((`Gyro Pitch Down` / 9) & " + + "not(pulse(`Gyro Pitch Up` / 9, 0.4))")) + } + + @Test + fun `the jerks all go the way the flick did`() { + val result = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))), sidewaysRemote = true) + + assertEquals(2, result.split("pulse((`Gyro").size - 1) // one per direction, nothing sideways + assertFalse(result.contains("3.1416")) // no antiphase left to cancel it + } + + @Test + fun `a bound source jerks without a flick, so any body can trick at all`() { + val pair = listOf(PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT), right = joycon(Side.RIGHT))) + val mapping = defaultWiimoteMapping(JoyconSide.DUAL) + mapOf("Shake" to "R") + + val result = DolphinWiimoteConfig.merge(null, pair, { false }) { mapping } + + assertTrue(result.contains("IMUAccelerometer/Up = `Accel Up` + pulse(`R1`, 0.6)")) + assertFalse(result.contains("IMUAccelerometer/Down = `Accel Down` + pulse")) // a button has no direction + } + + @Test + fun `nothing jerks the accelerometer when there is no flick to fire`() { + val result = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT)))) + + assertFalse(result.contains("pulse(")) } @Test diff --git a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfigTest.kt b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfigTest.kt index 5920034..2f380d8 100644 --- a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfigTest.kt +++ b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfigTest.kt @@ -2,7 +2,8 @@ package com.joegec.joycon2android.dsu.emulator import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.JoyconSide -import com.joegec.joycon2android.buttonmapping.defaultMappingEntries +import com.joegec.joycon2android.buttonmapping.PlayerBody +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import com.joegec.joycon2android.dsu.DsuConfig import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.PlayerNumber @@ -13,7 +14,9 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -private fun defaultSwitchProMapping(side: JoyconSide) = defaultMappingEntries(Console.SWITCH_PRO, side) +private fun defaultSwitchProMapping(side: JoyconSide) = MappingPresets.default(Console.SWITCH_PRO).entries(side) + +private val switchProMapping: (PlayerBody) -> Map = { defaultSwitchProMapping(it.side) } class EdenDsuConfigTest { @@ -30,7 +33,7 @@ class EdenDsuConfigTest { config.lines().first { it.substringBefore('=').trim() == key }.substringAfter('=').trim() private fun merge(existing: String?, players: List) = - EdenDsuConfig.merge(existing, players, ::defaultSwitchProMapping) + EdenDsuConfig.merge(existing, players, switchProMapping) private fun device(pad: Int) = "engine:cemuhookudp,guid:0000000000000000000000007f000001,port:${DsuConfig.PORT},pad:$pad" @@ -172,6 +175,15 @@ class EdenDsuConfigTest { assertEquals("\"${device(0)},axis:1,threshold:0.5,invert:+\"", valueOf(result, "player_0_button_a")) } + @Test + fun `a target with several sources keeps the first one Eden can bind`() { + val mapping = defaultSwitchProMapping(JoyconSide.DUAL) + mapOf("A" to "X|Y") + + val result = EdenDsuConfig.merge(null, listOf(pair(PlayerNumber.P1))) { mapping } + + assertEquals("\"${device(0)},button:4096\"", valueOf(result, "player_0_button_a")) + } + @Test fun `unrelated controls keys and other sections survive`() { val existing = "[Controls]\nvibration_enabled=true\n[Core]\nuse_multi_core=true" diff --git a/feature/dsu/presentation/src/main/kotlin/com/joegec/joycon2android/dsu/presentation/DsuViewModel.kt b/feature/dsu/presentation/src/main/kotlin/com/joegec/joycon2android/dsu/presentation/DsuViewModel.kt index 61b0f5d..722565a 100644 --- a/feature/dsu/presentation/src/main/kotlin/com/joegec/joycon2android/dsu/presentation/DsuViewModel.kt +++ b/feature/dsu/presentation/src/main/kotlin/com/joegec/joycon2android/dsu/presentation/DsuViewModel.kt @@ -12,7 +12,7 @@ import com.joegec.joycon2android.dsu.motion.SetBlockDeviceMotionUseCase import com.joegec.joycon2android.dsu.motion.SetFastMotionUseCase import com.joegec.joycon2android.model.EmulatorSetupResult import com.joegec.joycon2android.model.PlayerState -import com.joegec.joycon2android.ui.components.DolphinSetupPhase // shared, in :core:designsystem +import com.joegec.joycon2android.ui.components.DolphinSetupPhase import com.joegec.joycon2android.ui.components.EmulatorOption import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -22,7 +22,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -/** Feature-scoped state holder for the DSU card. Use cases are injected by the app. */ class DsuViewModel( observeDsuStatus: ObserveDsuStatusUseCase, private val enableDsu: EnableDsuUseCase, @@ -47,11 +46,9 @@ class DsuViewModel( private val _setupPhase = MutableStateFlow(DolphinSetupPhase.IDLE) val setupPhase: StateFlow = _setupPhase.asStateFlow() - /** The emulator that has to be closed before its config can be written, once the user agrees. */ private val _emulatorToClose = MutableStateFlow(null) val emulatorToClose: StateFlow = _emulatorToClose.asStateFlow() - /** The emulator to offer to start once its config has been written. */ private val _emulatorToStart = MutableStateFlow(null) val emulatorToStart: StateFlow = _emulatorToStart.asStateFlow() @@ -74,7 +71,6 @@ class DsuViewModel( resetSetupPhase() } - /** Clears a stale Done/Failed and its start prompt once the written config no longer matches the assignment. */ fun resetSetupPhase() { if (_setupPhase.value == DolphinSetupPhase.WORKING) return _setupPhase.value = DolphinSetupPhase.IDLE @@ -83,7 +79,6 @@ class DsuViewModel( fun configureDsu(players: List) = write(players, closeEmulator = false) - /** The user accepted losing unsaved progress, so stop the emulator and write. */ fun closeEmulatorAndConfigure(players: List) { _emulatorToClose.value = null write(players, closeEmulator = true) diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt index 4a945cb..03048e7 100644 --- a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt +++ b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt @@ -7,11 +7,7 @@ object ReportMapper { private const val REPORT_SIZE = 14 - // Bit n of the button bytes is the descriptor's Button n+1, which Linux maps to BTN_GAMEPAD + n - // and Android's key layout then names. Each Joy-Con button takes the bit whose keycode carries - // its own name — A on BTN_SOUTH (BUTTON_A), ZL on BTN_TL2 (BUTTON_L2), Minus on BTN_SELECT, and - // so on — so nothing downstream has to know about a shift, and ZL/ZR agree with the brake and - // accelerator axes below. Capture and GL take the two leftover slots (BUTTON_C, BUTTON_Z). + // Each bit's keycode carries the button's own name: docs/virtual-gamepad.md#buttons-and-keycodes private val BUTTON_MAP: Map = mapOf( JoyconButton.A.id to 0, JoyconButton.B.id to 1, diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/UhidRelay.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/UhidRelay.kt index 4d258d9..b1cdb38 100644 --- a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/UhidRelay.kt +++ b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/UhidRelay.kt @@ -42,7 +42,6 @@ class UhidRelay(private val name: String, private val playerIndex: Int) { read += n } - // Send UHID_CREATE2 event val createEvent = buildCreateEvent() val header = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN) header.putInt(createEvent.size) @@ -167,11 +166,8 @@ class UhidRelay(private val name: String, private val playerIndex: Int) { 0x09, 0x05, // Usage (Game Pad) 0xA1.toByte(), 0x01, // Collection (Application) - // Buttons 1-15, the most a Game Pad collection can spend: Linux maps Button n to - // BTN_GAMEPAD + n - 1, and that range ends at BTN_THUMBR (Button 15). A 16th would land - // on 0x13F, which no Android key layout names, so it would reach no app at all. - // ReportMapper picks which Joy-Con button takes which bit so that each lands on its - // same-named Android keycode. + // Buttons 1-15, the most a Game Pad collection can spend before a button reaches no + // app at all: docs/virtual-gamepad.md#buttons-and-keycodes. 0x05, 0x09, // Usage Page (Button) 0x19, 0x01, // Usage Minimum (Button 1) 0x29, 0x0F, // Usage Maximum (Button 15) @@ -220,10 +216,7 @@ class UhidRelay(private val name: String, private val playerIndex: Int) { 0x09, 0x35, // Usage (Rz) 0x81.toByte(), 0x02, // Input (Data, Var, Abs) - // Left Trigger. Brake is the left one and Accelerator the right one, never the other - // way round: Android aliases AXIS_LTRIGGER to AXIS_BRAKE and AXIS_RTRIGGER to AXIS_GAS, - // and firmware that re-publishes a pad (AYN's Odin/Thor) synthesises its L2/R2 buttons - // from those axes — inverted, it hands the emulator an L2 press for a ZR pull. + // Left Trigger. Never swap Brake and Accelerator: docs/virtual-gamepad.md#buttons-and-keycodes 0x05, 0x02, // Usage Page (Simulation Controls) 0x09, 0xC5.toByte(), // Usage (Brake) 0x15, 0x00, // Logical Minimum (0) @@ -238,11 +231,7 @@ class UhidRelay(private val name: String, private val playerIndex: Int) { 0xC0.toByte(), // End Collection - // The Switch 2 controllers have 17 buttons, two more than one Game Pad collection can - // carry. A second application collection outside the gamepad usages takes the overflow: - // Linux falls back to BTN_MISC + n - 1 for a Button usage whose application is neither - // pointer, joystick nor gamepad, and every Android key layout names that range - // BUTTON_1..BUTTON_16. Vendor-defined so nothing tries to interpret the collection. + // Overflow buttons GR and C: docs/virtual-gamepad.md#buttons-and-keycodes 0x06, 0x00, 0xFF.toByte(), // Usage Page (Vendor Defined FF00) 0x09, 0x01, // Usage (Vendor 1) 0xA1.toByte(), 0x01, // Collection (Application) diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/privileged/PrivilegedAccess.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/privileged/PrivilegedAccess.kt index 0f495af..99d2d11 100644 --- a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/privileged/PrivilegedAccess.kt +++ b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/privileged/PrivilegedAccess.kt @@ -9,10 +9,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import rikka.shizuku.Shizuku -/** - * Grants the virtual gamepad and emulator-config writes shell-uid access to `/dev/uhid` - * and app config files through Shizuku, the app's only privileged backend. - */ class PrivilegedAccess : PrivilegedAccessRepository { private val shizuku = ShizukuShell() diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/privileged/PrivilegedShell.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/privileged/PrivilegedShell.kt index ecd37de..011830e 100644 --- a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/privileged/PrivilegedShell.kt +++ b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/privileged/PrivilegedShell.kt @@ -3,11 +3,7 @@ package com.joegec.joycon2android.gamepad.privileged import java.io.InputStream import java.io.OutputStream -/** - * A source of shell-uid processes — the one privilege the UHID relay needs (to reach - * `/dev/uhid`). Implemented over Shizuku and over an in-app ADB/wireless-debugging - * connection, so the relay layer is unaware of which grants the privilege. - */ +/** Shell-uid processes, for `/dev/uhid` and other apps' config files. */ interface PrivilegedShell { val isReady: Boolean fun newProcess(argv: Array): ShellProcess? diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/shizuku/ShizukuPermissionHandler.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/shizuku/ShizukuPermissionHandler.kt index f13dc9e..b493c64 100644 --- a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/shizuku/ShizukuPermissionHandler.kt +++ b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/shizuku/ShizukuPermissionHandler.kt @@ -27,9 +27,7 @@ object ShizukuPermissionHandler { false } - // Shizuku dispatches the result to a main-thread listener, so the request must be issued - // on the main thread — issuing it from a background thread drops the callback and the - // caller waits forever. + // Must be issued on the main thread: from any other, Shizuku drops the callback and the caller hangs. fun requestPermission(callback: (granted: Boolean) -> Unit) { if (isPermissionGranted) { callback(true) diff --git a/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt b/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt index 7e0642f..4967c45 100644 --- a/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt +++ b/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt @@ -9,11 +9,7 @@ import com.joegec.joycon2android.model.Side import org.junit.Assert.assertEquals import org.junit.Test -/** - * Pins the bit each button takes, because the bit *is* the Android keycode: Linux maps HID Button - * n to BTN_GAMEPAD + n - 1 and the key layout names that. Move a button and every emulator config - * the app writes points at the wrong control. - */ +/** The bit is the Android keycode, so a moved button breaks every emulator config the app writes. */ class ReportMapperTest { private fun report(vararg pressed: JoyconButton): ByteArray { diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadRepository.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadRepository.kt index e1d35e6..deb00fb 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadRepository.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadRepository.kt @@ -4,7 +4,6 @@ import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.PlayerState import kotlinx.coroutines.flow.StateFlow -/** The system-wide virtual gamepad output, as the domain sees it. */ interface GamepadRepository { val enabled: StateFlow val error: StateFlow diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/PrivilegedAccessRepository.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/PrivilegedAccessRepository.kt index f92f7f0..ea87076 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/PrivilegedAccessRepository.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/PrivilegedAccessRepository.kt @@ -2,7 +2,6 @@ package com.joegec.joycon2android.gamepad import kotlinx.coroutines.flow.StateFlow -/** Availability of the privileged backend (Shizuku) that the virtual gamepad depends on. */ interface PrivilegedAccessRepository { val shizukuAvailable: StateFlow } diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt index 7ccd450..2b22a53 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt @@ -2,6 +2,7 @@ package com.joegec.joycon2android.gamepad.emulator import com.joegec.joycon2android.buttonmapping.JoyconSide import com.joegec.joycon2android.buttonmapping.MappingSource +import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.buttonmapping.StickDirection import com.joegec.joycon2android.buttonmapping.StickSource import com.joegec.joycon2android.buttonmapping.emittedFor @@ -10,23 +11,13 @@ import com.joegec.joycon2android.buttonmapping.target.GameCubeButton import com.joegec.joycon2android.buttonmapping.target.GameCubeStick import com.joegec.joycon2android.buttonmapping.toSourceMap import com.joegec.joycon2android.buttonmapping.toStickDirectionMap +import com.joegec.joycon2android.emulatorconfig.DolphinControls import com.joegec.joycon2android.emulatorconfig.DolphinPaths import com.joegec.joycon2android.emulatorconfig.IniEditor import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.PlayerState -/** - * Generates Dolphin's GCPadNew.ini mappings for the Virtual Gamepad, one `[GCPadN]` section per - * assigned player, driven by the user's customizable Joy-Con -> GameCube mapping. Each player's - * UHID pad shows up to Dolphin as a distinct Android input device - * (`Android//Joy-Con Virtual Gamepad `); the relay remaps buttons/sticks - * by orientation (see [SidewaysMapper]), so which physical button reaches a given Android control differs - * between a sideways single Joy-Con and a pair. [ANDROID_NAMES]/[HAT_NAMES] are the fixed, - * body-independent Dolphin names for each Android keycode/hat direction our virtual pad emits - * (captured from a real mapping); [specFor] resolves a customized source to the one its body - * actually emits. Every stick direction is its own Dolphin input, so a stick target can mix - * stick tilts and buttons freely without losing analog range on the tilts. - */ +/** Device qualifier and name tables: docs/virtual-gamepad.md#emulator-config */ object DolphinGcpadConfig { val path = DolphinPaths.config("GCPadNew.ini") val corePath = DolphinPaths.config("Dolphin.ini") @@ -48,9 +39,7 @@ object DolphinGcpadConfig { GameCubeButton.DPadRight to "D-Pad/Right", ) - // Dolphin's name for each Android keycode our virtual pad emits, fixed regardless of body. - // GR and Chat are absent: they land on BUTTON_1/BUTTON_2, and a GameCube pad has no target - // left for them anyway. + // GR and Chat are absent: a GameCube pad has no target left for them. private val ANDROID_NAMES = mapOf( JoyconButton.A to "Button A", JoyconButton.B to "Button B", @@ -83,10 +72,9 @@ object DolphinGcpadConfig { existing: String?, players: List, controllerNumbers: Map, - mappingFor: (JoyconSide) -> Map, + mappingFor: (PlayerBody) -> Map, ): String = IniEditor.mergeSections(existing, sections(players, controllerNumbers, mappingFor)) - /** Sets each configured player's GameCube port to a Standard Controller in Dolphin.ini. */ fun mergeCore(existing: String?, players: List): String { val siDevices = players .filter { it.hasController && !it.hasPro && it.player.index in 1..4 } @@ -94,14 +82,11 @@ object DolphinGcpadConfig { return IniEditor.setKeys(existing, "[Core]", siDevices) } - // Dolphin's device id comes from Android's own gamepad enumeration counter - // (InputDevice.getControllerNumber()), so it has to be read from the live device list rather - // than derived — any built-in controller already holds number 1. A player whose pad isn't - // enumerated yet is skipped: a guessed id binds the section to the wrong device, or to none. + // A player whose pad isn't enumerated yet is skipped: docs/virtual-gamepad.md#device-identity private fun sections( players: List, controllerNumbers: Map, - mappingFor: (JoyconSide) -> Map, + mappingFor: (PlayerBody) -> Map, ): Map = players.filter { it.hasController } .sortedBy { it.player.index } @@ -112,7 +97,12 @@ object DolphinGcpadConfig { bodyFor(player, index, deviceId, mappingFor)?.let { "[GCPad$index]" to it } }.toMap() - private fun bodyFor(player: PlayerState, index: Int, deviceId: Int, mappingFor: (JoyconSide) -> Map): String? { + private fun bodyFor( + player: PlayerState, + index: Int, + deviceId: Int, + mappingFor: (PlayerBody) -> Map, + ): String? { val side = when { player.hasPro -> return null player.hasFullController -> JoyconSide.DUAL @@ -121,28 +111,35 @@ object DolphinGcpadConfig { else -> return null } val device = "Device = Android/$deviceId/Joy-Con Virtual Gamepad $index" - return (listOf(device) + lines(side, mappingFor(side))).joinToString("\n", postfix = "\n") + return (listOf(device) + lines(side, mappingFor(PlayerBody(player.player, side)))) + .joinToString("\n", postfix = "\n") } private fun lines(side: JoyconSide, mapping: Map): List { - val buttonLines = mapping.toSourceMap().mapNotNull { (target, source) -> - specFor(side, source)?.let { spec -> "${DOLPHIN_KEYS.getValue(target)} = `$spec`" } + val buttonLines = mapping.toSourceMap().mapNotNull { (target, sources) -> + expressionFor(side, sources)?.let { expression -> "${DOLPHIN_KEYS.getValue(target)} = $expression" } } val stickLines = mapping.toStickDirectionMap().flatMap { (target, directions) -> - directions.mapNotNull { (direction, source) -> - specFor(side, source)?.let { spec -> "${STICK_PREFIXES.getValue(target)}/${direction.displayName} = `$spec`" } + directions.mapNotNull { (direction, sources) -> + expressionFor(side, sources)?.let { expression -> + "${STICK_PREFIXES.getValue(target)}/${DolphinControls.DIRECTIONS.getValue(direction)} = $expression" + } } } return buttonLines + stickLines } + private fun expressionFor(side: JoyconSide, sources: List): String? = + sources.mapNotNull { specFor(side, it) } + .takeIf { it.isNotEmpty() } + ?.joinToString(" | ") { "`$it`" } + private fun specFor(side: JoyconSide, source: MappingSource): String? = when (source) { is MappingSource.Button -> source.button.emittedFor(side)?.let { ANDROID_NAMES[it] ?: HAT_NAMES[it] } is MappingSource.Stick -> tiltSpec(source.emittedStick(side), source.direction) } - // Physical left stick lands on Android axes 0/1, physical right stick on axes 11/14 (see - // ReportMapper); Android's Y axis grows downward. + // Android's Y axis grows downward. private fun tiltSpec(stick: StickSource, direction: StickDirection): String { val (x, y) = if (stick == StickSource.LEFT_STICK) 0 to 1 else 11 to 14 return when (direction) { diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepad.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepad.kt index 5e97dbc..df13199 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepad.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepad.kt @@ -1,15 +1,6 @@ package com.joegec.joycon2android.gamepad.emulator -/** - * How Eden addresses one of our virtual gamepads: the `port` it assigns while enumerating input - * devices, plus the `guid` it derives from the device's USB ids — product then vendor, each as a - * 16-digit hex half. - * - * Both are read from the live input device, never assumed. Handhelds whose firmware re-publishes - * external gamepads under the built-in controller's vendor/product (AYN's Odin/Thor line does this) - * hand the emulator ids that are not the ones our uhid device was created with, and a binding whose - * guid doesn't match the device Eden sees is silently ignored. - */ +/** Read from the live device, never assumed: docs/virtual-gamepad.md#device-identity */ data class EdenGamepad(val port: Int, val guid: String) { companion object { fun of(port: Int, vendorId: Int, productId: Int) = diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt index f9d8909..5f7a750 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt @@ -2,6 +2,7 @@ package com.joegec.joycon2android.gamepad.emulator import com.joegec.joycon2android.buttonmapping.JoyconSide import com.joegec.joycon2android.buttonmapping.MappingSource +import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.buttonmapping.StickDirection import com.joegec.joycon2android.buttonmapping.StickSource import com.joegec.joycon2android.buttonmapping.emittedFor @@ -17,32 +18,9 @@ import com.joegec.joycon2android.emulatorconfig.defineEdenKey import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.PlayerState -/** - * Generates Eden's `config.ini` `[Controls]` bindings for the Virtual Gamepad, driven by the - * user's customizable Joy-Con -> Pro Controller mapping. - * - * The relay exposes every player as one standard Android HID gamepad, wired so each Joy-Con button - * lands on the keycode of the same name (Switch A is BUTTON_A, ZL is BUTTON_L2, − is BUTTON_SELECT). - * The d-pad is the HID hat (HAT_X = axis 15, HAT_Y = axis 16); [KEY_CODES]/[HAT_AXES] are that - * fixed, body-independent wiring. - * - * Eden does not translate a sideways single Joy-Con: it only sets an `is_horizontal` flag (which on - * hardware the game's own nn::hid honours, but Eden has no equivalent), and it masks an npad by - * type — a JoyconLeft can't even report A/B/X/Y. So we present each single Joy-Con as a Pro - * Controller and apply the sideways rotation ourselves: [inputFor] resolves a customized source to - * what its body actually emits, so e.g. the left Joy-Con's d-pad resolves to the face-button - * keycodes it is rotated onto. - * - * A target stick whose directions still follow one real stick binds its axes, keeping the analog - * range; any other arrangement is assembled from its directions with [EdenControls.stickFromButtons]. - * - * Each pad's [EdenGamepad] — port and guid both — comes from the app's read of the live - * input-device list, since neither can be derived from the player number. - */ +/** Why a lone Joy-Con is a Pro Controller: docs/virtual-gamepad.md#why-theyre-set-up-as-pro-controllers */ object EdenGamepadConfig { - // Joy-Con button -> the Android keycode the relay's HID gamepad emits for it. ReportMapper - // places each one so the keycode carries its own name; Capture and GL take the two gamepad slots - // with no Switch equivalent, and GR/Chat the trailing vendor collection's BUTTON_1/BUTTON_2. + // Keycodes the relay emits: docs/virtual-gamepad.md#buttons-and-keycodes private const val A = 96 private const val B = 97 private const val CAPTURE = 98 @@ -87,10 +65,8 @@ object EdenGamepadConfig { existing: String?, players: List, gamepads: Map, - mappingFor: (JoyconSide) -> Map, + mappingFor: (PlayerBody) -> Map, ): String { - // Drop every player's prior bindings first: a layout or port change leaves stale keys that - // would otherwise linger and cross-fire onto another player's port. val cleared = IniEditor.removeKeys(existing, EdenControls.SECTION) { it.matches(PLAYER_KEY) } return IniEditor.setKeys(cleared, EdenControls.SECTION, controlKeys(players, gamepads, mappingFor), assign = "=") } @@ -98,7 +74,7 @@ object EdenGamepadConfig { private fun controlKeys( players: List, gamepads: Map, - mappingFor: (JoyconSide) -> Map, + mappingFor: (PlayerBody) -> Map, ): Map { val keys = LinkedHashMap() players.forEach { player -> @@ -107,7 +83,7 @@ object EdenGamepadConfig { val gamepad = gamepads[index] ?: return@forEach val type = EdenControls.npadType(player) ?: return@forEach val side = sideFor(player) ?: return@forEach - val layout = layoutFor(side, mappingFor(side)) + val layout = layoutFor(side, mappingFor(PlayerBody(player.player, side))) val p = index - 1 val device = "engine:android,port:${gamepad.port},guid:${gamepad.guid},pad:0" val display = "Joy-Con Virtual Gamepad $index ${gamepad.port}" @@ -139,8 +115,8 @@ object EdenGamepadConfig { } private fun layoutFor(side: JoyconSide, mapping: Map): Layout { - val buttons = mapping.toSourceMap().mapNotNull { (target, source) -> - inputFor(side, source)?.let { EdenControls.BUTTON_KEYS.getValue(target) to it } + val buttons = mapping.toSourceMap().mapNotNull { (target, sources) -> + inputFor(side, sources)?.let { EdenControls.BUTTON_KEYS.getValue(target) to it } }.toMap() val sticks = mapping.toStickDirectionMap().mapNotNull { (target, directions) -> stickFor(side, directions)?.let { EdenControls.STICK_KEYS.getValue(target) to it } @@ -148,19 +124,20 @@ object EdenGamepadConfig { return Layout(buttons, sticks) } - private fun stickFor(side: JoyconSide, directions: Map): Stick? { + private fun stickFor(side: JoyconSide, directions: Map>): Stick? { directions.wholeEmittedStick(side)?.let { return AnalogStick(axesOf(it)) } - val inputs = directions.mapNotNull { (direction, source) -> inputFor(side, source)?.let { direction to it } } + val inputs = directions.mapNotNull { (direction, sources) -> inputFor(side, sources)?.let { direction to it } } return inputs.takeIf { it.isNotEmpty() }?.let { DigitalStick(it.toMap()) } } + private fun inputFor(side: JoyconSide, sources: List): Input? = + sources.firstNotNullOfOrNull { inputFor(side, it) } + private fun inputFor(side: JoyconSide, source: MappingSource): Input? = when (source) { is MappingSource.Button -> source.button.emittedFor(side)?.let { KEY_CODES[it]?.let(::Key) ?: HAT_AXES[it] } is MappingSource.Stick -> tiltOf(source.emittedStick(side), source.direction) } - // Physical left stick lands on Android axes 0/1, physical right stick on axes 11/14 (see - // ReportMapper); Android's Y axis grows downward. private fun axesOf(stick: StickSource) = if (stick == StickSource.LEFT_STICK) 0 to 1 else 11 to 14 private fun tiltOf(stick: StickSource, direction: StickDirection): Axis { diff --git a/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfigTest.kt b/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfigTest.kt index 7c96d71..d78a689 100644 --- a/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfigTest.kt +++ b/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfigTest.kt @@ -2,7 +2,8 @@ package com.joegec.joycon2android.gamepad.emulator import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.JoyconSide -import com.joegec.joycon2android.buttonmapping.defaultMappingEntries +import com.joegec.joycon2android.buttonmapping.PlayerBody +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.PlayerState @@ -19,8 +20,8 @@ class DolphinGcpadConfigTest { existing: String?, players: List, controllerNumbers: Map = players.associate { it.player.index to it.player.index }, - ) = DolphinGcpadConfig.merge(existing, players, controllerNumbers) { side -> - defaultMappingEntries(Console.GAMECUBE, side) + ) = DolphinGcpadConfig.merge(existing, players, controllerNumbers) { body -> + MappingPresets.default(Console.GAMECUBE).entries(body.side) } @Test @@ -88,7 +89,7 @@ class DolphinGcpadConfigTest { @Test fun `a stick direction can be driven by a button, and a button by a stick direction`() { val both = PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT), right = joycon(Side.RIGHT)) - val mapping = defaultMappingEntries(Console.GAMECUBE, JoyconSide.DUAL) + + val mapping = MappingPresets.default(Console.GAMECUBE).entries(JoyconSide.DUAL) + mapOf("MainStick_UP" to "X", "A" to "RIGHT_STICK_DOWN", "CStick_LEFT" to "") val result = DolphinGcpadConfig.merge(null, listOf(both), mapOf(1 to 1)) { mapping } diff --git a/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt b/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt index 31df18f..1a0e6e7 100644 --- a/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt +++ b/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt @@ -2,7 +2,8 @@ package com.joegec.joycon2android.gamepad.emulator import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.JoyconSide -import com.joegec.joycon2android.buttonmapping.defaultMappingEntries +import com.joegec.joycon2android.buttonmapping.PlayerBody +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.PlayerState @@ -11,7 +12,9 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -private fun defaultSwitchProMapping(side: JoyconSide) = defaultMappingEntries(Console.SWITCH_PRO, side) +private fun defaultSwitchProMapping(side: JoyconSide) = MappingPresets.default(Console.SWITCH_PRO).entries(side) + +private val switchProMapping: (PlayerBody) -> Map = { defaultSwitchProMapping(it.side) } class EdenGamepadConfigTest { @@ -22,7 +25,7 @@ class EdenGamepadConfigTest { existing, players, ports.mapValues { (_, port) -> EdenGamepad.of(port, VENDOR_ID, PRODUCT_ID) }, - ::defaultSwitchProMapping, + switchProMapping, ) @Test @@ -128,7 +131,7 @@ class EdenGamepadConfigTest { // A handheld that re-publishes our pad under its built-in controller's vendor/product. val republished = mapOf(1 to EdenGamepad.of(port = 2, vendorId = 0x2020, productId = 0x0111)) - val result = EdenGamepadConfig.merge(null, players, republished, ::defaultSwitchProMapping) + val result = EdenGamepadConfig.merge(null, players, republished, switchProMapping) assertTrue(result.contains("guid:00000000000001110000000000002020")) assertFalse(result.contains(GUID)) diff --git a/feature/gamepad/presentation/src/main/kotlin/com/joegec/joycon2android/gamepad/presentation/GamepadViewModel.kt b/feature/gamepad/presentation/src/main/kotlin/com/joegec/joycon2android/gamepad/presentation/GamepadViewModel.kt index 1505c70..7630629 100644 --- a/feature/gamepad/presentation/src/main/kotlin/com/joegec/joycon2android/gamepad/presentation/GamepadViewModel.kt +++ b/feature/gamepad/presentation/src/main/kotlin/com/joegec/joycon2android/gamepad/presentation/GamepadViewModel.kt @@ -19,7 +19,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -/** Feature-scoped state holder for the virtual gamepad and its privileged-access setup. */ class GamepadViewModel( observeGamepadStatus: ObserveGamepadStatusUseCase, observeShizukuAvailability: ObserveShizukuAvailabilityUseCase, @@ -42,11 +41,9 @@ class GamepadViewModel( private val _setupPhase = MutableStateFlow(DolphinSetupPhase.IDLE) val setupPhase: StateFlow = _setupPhase.asStateFlow() - /** The emulator that has to be closed before its config can be written, once the user agrees. */ private val _emulatorToClose = MutableStateFlow(null) val emulatorToClose: StateFlow = _emulatorToClose.asStateFlow() - /** The emulator to offer to start once its config has been written. */ private val _emulatorToStart = MutableStateFlow(null) val emulatorToStart: StateFlow = _emulatorToStart.asStateFlow() @@ -61,7 +58,6 @@ class GamepadViewModel( resetSetupPhase() } - /** Clears a stale Done/Failed and its start prompt once the written config no longer matches the assignment. */ fun resetSetupPhase() { if (_setupPhase.value == DolphinSetupPhase.WORKING) return _setupPhase.value = DolphinSetupPhase.IDLE @@ -70,7 +66,6 @@ class GamepadViewModel( fun configureGamepad(players: List) = write(players, closeEmulator = false) - /** The user accepted losing unsaved progress, so stop the emulator and write. */ fun closeEmulatorAndConfigure(players: List) { _emulatorToClose.value = null write(players, closeEmulator = true) diff --git a/feature/gamepad/presentation/src/main/kotlin/com/joegec/joycon2android/gamepad/presentation/ShizukuSetupCard.kt b/feature/gamepad/presentation/src/main/kotlin/com/joegec/joycon2android/gamepad/presentation/ShizukuSetupCard.kt index 9dc89a7..72f5a7b 100644 --- a/feature/gamepad/presentation/src/main/kotlin/com/joegec/joycon2android/gamepad/presentation/ShizukuSetupCard.kt +++ b/feature/gamepad/presentation/src/main/kotlin/com/joegec/joycon2android/gamepad/presentation/ShizukuSetupCard.kt @@ -28,7 +28,7 @@ import com.joegec.joycon2android.ui.theme.CardBg import com.joegec.joycon2android.ui.theme.Dimens import com.joegec.joycon2android.ui.theme.TextDim -/** Shown when Shizuku isn't running — the privileged backend the gamepad depends on. */ +/** Shown while Shizuku isn't running. */ @Composable fun ShizukuSetupCard(modifier: Modifier = Modifier) { val uriHandler = LocalUriHandler.current diff --git a/feature/update/data/src/main/kotlin/com/joegec/joycon2android/update/ApkUpdateInstaller.kt b/feature/update/data/src/main/kotlin/com/joegec/joycon2android/update/ApkUpdateInstaller.kt index 96d6afe..3c3573e 100644 --- a/feature/update/data/src/main/kotlin/com/joegec/joycon2android/update/ApkUpdateInstaller.kt +++ b/feature/update/data/src/main/kotlin/com/joegec/joycon2android/update/ApkUpdateInstaller.kt @@ -41,8 +41,7 @@ class ApkUpdateInstaller( emit(InstallProgress.HandedOff) }.flowOn(Dispatchers.IO) - // The previous attempt's APK is dead weight once a new one starts, and a half-written file - // from an interrupted download would install as a corrupt package. + // A half-written file from an interrupted download would install as a corrupt package. private fun emptyApkFile(update: AvailableUpdate): File { downloadDirectory.mkdirs() downloadDirectory.listFiles()?.forEach { it.delete() } diff --git a/feature/update/data/src/main/kotlin/com/joegec/joycon2android/update/GitHubReleaseParser.kt b/feature/update/data/src/main/kotlin/com/joegec/joycon2android/update/GitHubReleaseParser.kt index 898937f..589f134 100644 --- a/feature/update/data/src/main/kotlin/com/joegec/joycon2android/update/GitHubReleaseParser.kt +++ b/feature/update/data/src/main/kotlin/com/joegec/joycon2android/update/GitHubReleaseParser.kt @@ -3,10 +3,6 @@ package com.joegec.joycon2android.update import org.json.JSONArray import org.json.JSONObject -/** - * Reads what the prompt needs out of a GitHub `releases/latest` payload: the tag as a version, - * the body as highlights, and the APK asset's download URL. - */ class GitHubReleaseParser { fun parse(json: String): AvailableUpdate? { diff --git a/feature/update/data/src/main/kotlin/com/joegec/joycon2android/update/GitHubReleases.kt b/feature/update/data/src/main/kotlin/com/joegec/joycon2android/update/GitHubReleases.kt index d56d864..c98f0bf 100644 --- a/feature/update/data/src/main/kotlin/com/joegec/joycon2android/update/GitHubReleases.kt +++ b/feature/update/data/src/main/kotlin/com/joegec/joycon2android/update/GitHubReleases.kt @@ -8,9 +8,8 @@ import java.net.HttpURLConnection import java.net.URL /** - * The `releases/latest` endpoint never returns a prerelease, so the `-debug.N` builds cut for - * sharing are invisible here. Unauthenticated calls are rate limited to 60 an hour per address; - * exceeding it reads as "no update", like being offline. + * `releases/latest` never returns a prerelease, so `-debug.N` builds are invisible here. The + * unauthenticated limit is 60 calls an hour per address; past it reads as "no update". */ class GitHubReleases( private val repository: String, diff --git a/feature/update/domain/src/main/kotlin/com/joegec/joycon2android/update/ReleaseNotes.kt b/feature/update/domain/src/main/kotlin/com/joegec/joycon2android/update/ReleaseNotes.kt index 3ca9dd4..b3553bd 100644 --- a/feature/update/domain/src/main/kotlin/com/joegec/joycon2android/update/ReleaseNotes.kt +++ b/feature/update/domain/src/main/kotlin/com/joegec/joycon2android/update/ReleaseNotes.kt @@ -1,9 +1,6 @@ package com.joegec.joycon2android.update -/** - * Reduces a GitHub release body to the few lines the prompt shows: the bullets under its - * "What's new" heading, stripped of markdown the dialog cannot render. - */ +/** The bullets under "What's new", stripped of markdown the dialog can't render. */ object ReleaseNotes { fun highlights(body: String): List { @@ -35,8 +32,7 @@ private fun String.asHighlight(): String { return (bullet.boldLead() ?: bullet).withoutMarkdown() } -// Release bullets lead with a bolded summary and follow it with a sentence of consequence. The -// prompt is a glance before an install, so it shows the summary and leaves the detail to the notes. +// A bullet opens with a bold summary; the prompt shows only that. private fun String.boldLead(): String? { if (!startsWith(BOLD)) return null val close = indexOf(BOLD, startIndex = BOLD.length) diff --git a/konsist/src/test/kotlin/com/joegec/joycon2android/konsist/ArchitectureTest.kt b/konsist/src/test/kotlin/com/joegec/joycon2android/konsist/ArchitectureTest.kt index d985703..f023967 100644 --- a/konsist/src/test/kotlin/com/joegec/joycon2android/konsist/ArchitectureTest.kt +++ b/konsist/src/test/kotlin/com/joegec/joycon2android/konsist/ArchitectureTest.kt @@ -5,11 +5,7 @@ import com.lemonappdev.konsist.api.ext.list.withNameEndingWith import com.lemonappdev.konsist.api.verify.assertTrue import org.junit.Test -/** - * Layer conventions the Gradle module graph cannot enforce on its own — *where* a kind of class - * is allowed to live. The graph already stops presentation from seeing data; these stop a - * ViewModel, use case, or repository interface from landing in the wrong layer in the first place. - */ +/** Placement rules the module graph can't enforce: docs/architecture.md#dependency-rules */ class ArchitectureTest { @Test diff --git a/tools/README.md b/tools/README.md index cbe3724..15866b9 100644 --- a/tools/README.md +++ b/tools/README.md @@ -17,19 +17,47 @@ via adb: ``` The third argument sets the motion print interval; it defaults to a readable 0.25 s, and -`0` prints every packet (~90 Hz), which is what differentiating the gravity vector needs. +`0` prints every packet, which is what differentiating the gravity vector needs. +## Flick measurement -### Axis calibration workflow +`flick_stats.py` lists each flick in a `dsu_client` capture with its signed pitch, yaw and roll, +for setting `FLICK_RADIANS` from a hand rather than an assumption. + +> [!WARNING] +> Its `residual` column still models the retired discriminator (summed `|pitch| + |yaw| + |roll|` +> minus a slew limiter). `DolphinWiimoteConfig` reads raw pitch alone +> ([why](../docs/dsu-motion.md#tricks-and-wheelies)), so go by the pitch figures. + +Enable DSU in the app with a single Joy-Con on P1 (slot 0), then capture twice: + +```sh +adb shell /data/local/tmp/dsu_client 127.0.0.1 20 0 > flick.log # ~10 flicks, as if tricking +adb shell /data/local/tmp/dsu_client 127.0.0.1 20 0 > steer.log # steering hard, as if racing +tools/flick_stats.py flick.log +tools/flick_stats.py steer.log +``` + +The motion interval must be `0`, or the peaks are averaged away before the file is written. + +Three things to read out of it: + +- **The interval.** A flick lasts 40–80 ms. At the ~30 ms of a balanced connection it is + sampled once or twice and its crest is often missed entirely, which no threshold can + recover; fast motion roughly halves that. +- **The spread across events.** Flicks of the same strength reading very different residuals + means the stream is catching them at different points, not that the hand varied. +- **The gap between the two captures.** `FLICK_RADIANS` has to sit under twice the weakest + flick and over twice the strongest steering. If those cross, no threshold will do. + +## Axis calibration 1. Capture while performing slow single-axis motions with holds (still → yaw left → pitch up → roll right), or any rich motion if direction labels aren't trusted. -2. Static holds anchor the accel frame (cemuhook: x=left, y=down, z=forward; flat at - rest reads (0,−1,0)). +2. Static holds anchor the accel frame ([the frames](../docs/dsu-motion.md#motion-frame)). 3. Gyro signs follow from the physics constraint `dv/dt = v × ω` applied to the normalized accel vector — fit the 16 sign combinations and break the mirror - degeneracy with one static-hold anchor. (Done for the right Joy-Con, 2026-06; - see `MotionConverter`.) + degeneracy with one static-hold anchor. (Done for the right Joy-Con, 2026-06.) ### Checking a gyro sign against gravity diff --git a/tools/flick_stats.py b/tools/flick_stats.py new file mode 100755 index 0000000..fd17f68 --- /dev/null +++ b/tools/flick_stats.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Works out what a flick actually looks like on the wire, so the trick threshold can be set +from a hand rather than from an assumption. + +Feed it a capture from dsu_client with the motion interval set to 0 (every packet): + + adb shell /data/local/tmp/dsu_client 127.0.0.1 20 0 > flick.log + tools/flick_stats.py flick.log + +Capture twice — once flicking as you would to trick, once steering as hard as you would +race — and compare. Dolphin sees `rate`, the sum of the six one-way gyro inputs, which is +|pitch| + |yaw| + |roll| in rad/s. DolphinWiimoteConfig subtracts a slew limiter from it and +fires a trick when what is left passes half of FLICK_RADIANS, so `residual` below is the +number that decides whether a flick lands. +""" +import argparse +import math +import re +import statistics +import sys + +LINE = re.compile( + r"\[\s*([\d.]+)\] slot=(\d) accel=\([^)]*\)g gyro\(pitch,yaw,roll\)=\(([^)]*)\)dps" +) + + +def samples(path, slot): + """(timestamp, rate, axes) per packet — rate is what Dolphin sums from the six one-way inputs, + axes keeps the signed pitch/yaw/roll so a gesture's direction can be read back.""" + for line in open(path): + found = LINE.match(line.strip()) + if not found or int(found.group(2)) != slot: + continue + axes = tuple(math.radians(float(v)) for v in found.group(3).split(",")) + yield float(found.group(1)), sum(abs(a) for a in axes), axes + + +def residuals(rates, settle): + """What survives Dolphin's `rate - smooth(rate, settle)`: a limiter moving 1/settle per second.""" + state = rates[0][1] + for previous, (at, rate, axes) in zip(rates, rates[1:]): + most = (at - previous[0]) / settle + state += max(-most, min(most, rate - state)) + yield at, rate, rate - state, axes + + +def peaks(measured, floor, apart): + """One entry per burst, so a single flick is not counted as several.""" + burst = [] + for sample in measured: + if sample[2] < floor: + continue + if burst and sample[0] - burst[-1][0] > apart: + yield max(burst, key=lambda it: it[2]) + burst = [] + burst.append(sample) + if burst: + yield max(burst, key=lambda it: it[2]) + + +AXES = ("pitch", "yaw", "roll") + + +def direction(axes): + """The turn the gesture mostly is, named as Dolphin names its two one-way inputs for that axis.""" + size = max(range(3), key=lambda i: abs(axes[i])) + ends = {"pitch": ("Pitch Up", "Pitch Down"), "yaw": ("Yaw Left", "Yaw Right"), + "roll": ("Roll Left", "Roll Right")}[AXES[size]] + share = abs(axes[size]) / sum(abs(a) for a in axes) + return f"{ends[0] if axes[size] > 0 else ends[1]:11} {share:.0%} of the turn" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("capture") + parser.add_argument("--slot", type=int, default=0) + parser.add_argument("--settle", type=float, default=0.01, help="FLICK_SETTLE_SECONDS") + parser.add_argument("--floor", type=float, default=2.0, help="rad/s of residual worth reporting") + parser.add_argument("--apart", type=float, default=0.3, help="seconds between separate events") + args = parser.parse_args() + + rates = list(samples(args.capture, args.slot)) + if len(rates) < 3: + sys.exit(f"{args.capture}: no motion lines for slot {args.slot} — run dsu_client with interval 0") + + gaps = [b[0] - a[0] for a, b in zip(rates, rates[1:])] + span = rates[-1][0] - rates[0][0] + print(f"{len(rates)} packets over {span:.1f} s") + print( + f"interval: mean {statistics.mean(gaps) * 1e3:.1f} ms, " + f"median {statistics.median(gaps) * 1e3:.1f} ms, worst {max(gaps) * 1e3:.1f} ms" + ) + print(" a flick lasts 40-80 ms, so anything near that is sampling it once or twice\n") + + measured = list(residuals(rates, args.settle)) + found = list(peaks(measured, args.floor, args.apart)) + if not found: + sys.exit(f"nothing above {args.floor} rad/s of residual — flick harder, or lower --floor") + + plural = "" if len(found) == 1 else "s" + print(f"{len(found)} event{plural} (peak residual >= {args.floor}, at least {args.apart}s apart):") + for at, rate, residual, axes in found: + signed = " ".join(f"{n} {v:+6.1f}" for n, v in zip(AXES, axes)) + print(f" t={at:8.2f} residual {residual:6.1f} [{signed}] {direction(axes)}") + + weakest = min(it[2] for it in found) + print(f"\nweakest event leaves {weakest:.1f} rad/s of residual.") + print(f" to catch every one of these, FLICK_RADIANS <= {2 * weakest:.0f}") + print(" run this over a steering-only capture too: FLICK_RADIANS must stay above twice its") + print(" largest residual, or racing will fire tricks. No gap between the two means the") + print(" limiter is the wrong discriminator, not the threshold.") + + +if __name__ == "__main__": + main()