diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100755 index 0000000..00883bb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,9 @@ +## Summary + +What does this PR do? + +## Screenshots + +## Manual verification + +What did you test on hardware? (Steam Controller chord open/close, touchpad typing, split layout, touchscreen taps, tray toggles.) \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ec5163e..71e1790 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,11 +18,8 @@ jobs: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1 - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.12" - cache: pip + - name: Set up mise + uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 - name: Install dependencies run: python -m pip install --upgrade pip && python -m pip install --prefer-binary -r requirements.txt diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100755 index 0000000..19b0293 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,112 @@ +name: Tests + +on: + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + tests: + name: tests + runs-on: windows-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up mise + id: setup-mise + uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 + - name: Install dependencies + id: install-deps + working-directory: windows + run: | + python -m pip install --upgrade pip + python -m pip install --prefer-binary -r ../requirements.txt -r requirements-dev.txt + - name: Run tests + id: run-tests + working-directory: windows + run: | + python -m pytest tests -q 2>&1 | Out-File -FilePath "$env:RUNNER_TEMP\pytest-report.txt" -Encoding utf8 + $code = $LASTEXITCODE + "TEST_EXIT=$code" | Out-File -FilePath $env:GITHUB_ENV -Append + exit 0 + - name: Report result to PR + if: always() + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $ErrorActionPreference = 'Stop' + $event = Get-Content $env:GITHUB_EVENT_PATH | ConvertFrom-Json + $pr = $event.pull_request.number + if (-not $pr) { + Write-Host "Not a pull_request run - skipping comment" + exit 0 + } + $report = "$env:RUNNER_TEMP\pytest-report.txt" + $out = "" + if (Test-Path $report) { + $out = Get-Content -Raw $report + } + $exit = -1 + if ($env:TEST_EXIT -ne $null) { $exit = [int]$env:TEST_EXIT } + + $summary = "no pytest summary captured" + $matches = [regex]::Matches($out, '(?m)^\s*\d+ passed.*$') + if ($matches.Count -gt 0) { + $summary = $matches[$matches.Count - 1].Value.Trim() + } + + $failedTests = @() + foreach ($line in ($out -split "`r?`n")) { + if ($line -match '^FAILED\s+([^\s]+)') { $failedTests += $Matches[1] } + } + + $verdict = if ($exit -eq 0) { "**Result:** PASSED" } else { "**Result:** FAILED (exit $exit)" } + $conclusions = "mise=${{ steps.setup-mise.conclusion }}, deps=${{ steps.install-deps.conclusion }}, tests=${{ steps.run-tests.conclusion }}" + if ($conclusions -match 'failure|skipped') { + $verdict = $verdict + "`n`n_Step conclusions — $conclusions — check the workflow log._" + } + $failBlock = "" + if ($failedTests.Count -gt 0) { + $failBlock = "**Failed tests:**`n" + ($failedTests -join "`n") + } + + $fence = '```' + $body = '' + "`n" + + "## Tests on $env:GITHUB_SHA" + "`n`n" + + $verdict + "`n`n" + + "**Summary:** $summary" + "`n" + + $failBlock + "`n`n" + + '
Full output' + "`n`n" + + $fence + 'text' + "`n" + + $out + "`n" + + $fence + "`n" + + '
' + + $headers = @{ + Authorization = "Bearer $env:GH_TOKEN" + "X-GitHub-Api-Version" = "2022-11-28" + } + $api = "https://api.github.com/repos/$env:GITHUB_REPOSITORY" + $existing = Invoke-RestMethod -Uri "$api/issues/$pr/comments?per_page=100" -Headers $headers -Method Get + $mine = $existing | Where-Object { $_.body -like '*' } | Select-Object -First 1 + $payload = @{ body = $body } | ConvertTo-Json + if ($mine) { + Invoke-RestMethod -Uri "$api/issues/comments/$($mine.id)" -Headers $headers -Method Patch -Body $payload -ContentType "application/json" | Out-Null + } else { + Invoke-RestMethod -Uri "$api/issues/$pr/comments" -Headers $headers -Method Post -Body $payload -ContentType "application/json" | Out-Null + } + Write-Host "Comment posted for PR #$pr" + + if ($exit -ne 0) { + Write-Host "::error::Tests failed (exit $exit)" + exit 1 + } diff --git a/README.md b/README.md index 3cca72e..7f3160c 100755 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ menus), or by hand-editing `settings.json` for finer control. | `skin` | `"Gruvbox"` | Keyboard skin: Steam OSK themes load from the Steam install; Gruvbox is the bundled original | | `osk_size` | `"medium"` | `"small"`/`"medium"`/`"full"` | | `osk_transparency` | `"off"` | `"off"`/`"low"`/`"medium"`/`"high"` | +| `osk_split_layout` | `false` | Split the keyboard into left/right halves with a middle gap; each touchpad covers its own half | | `key_sound_enabled_sc` | `true` | Steam keyboard click sound | | `rumble_enabled_sc` | `true` | Haptics on key clicks and trigger pulls | | `steam_kbd_layer` | `true` | Dispatch the `forceinputappid` keyboard layer while open | diff --git a/mise.lock b/mise.lock index 1234b32..dabdb23 100755 --- a/mise.lock +++ b/mise.lock @@ -1,38 +1,51 @@ # @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html -[[tools.pre-commit]] -version = "4.6.2" -backend = "aqua:pre-commit/pre-commit" - -[tools.pre-commit."platforms.linux-arm64"] -checksum = "sha256:43c09a73d82af8efff09900e60d76d67a2c3d990613c3c770b2b5c4418a16c4b" -url = "https://github.com/pre-commit/pre-commit/releases/download/v4.6.2/pre-commit-4.6.2.pyz" -url_api = "https://api.github.com/repos/pre-commit/pre-commit/releases/assets/509317714" - -[tools.pre-commit."platforms.linux-arm64-musl"] -checksum = "sha256:43c09a73d82af8efff09900e60d76d67a2c3d990613c3c770b2b5c4418a16c4b" -url = "https://github.com/pre-commit/pre-commit/releases/download/v4.6.2/pre-commit-4.6.2.pyz" -url_api = "https://api.github.com/repos/pre-commit/pre-commit/releases/assets/509317714" - -[tools.pre-commit."platforms.linux-x64"] -checksum = "sha256:43c09a73d82af8efff09900e60d76d67a2c3d990613c3c770b2b5c4418a16c4b" -url = "https://github.com/pre-commit/pre-commit/releases/download/v4.6.2/pre-commit-4.6.2.pyz" -url_api = "https://api.github.com/repos/pre-commit/pre-commit/releases/assets/509317714" - -[tools.pre-commit."platforms.linux-x64-musl"] -checksum = "sha256:43c09a73d82af8efff09900e60d76d67a2c3d990613c3c770b2b5c4418a16c4b" -url = "https://github.com/pre-commit/pre-commit/releases/download/v4.6.2/pre-commit-4.6.2.pyz" -url_api = "https://api.github.com/repos/pre-commit/pre-commit/releases/assets/509317714" - -[tools.pre-commit."platforms.macos-arm64"] -checksum = "sha256:43c09a73d82af8efff09900e60d76d67a2c3d990613c3c770b2b5c4418a16c4b" -url = "https://github.com/pre-commit/pre-commit/releases/download/v4.6.2/pre-commit-4.6.2.pyz" -url_api = "https://api.github.com/repos/pre-commit/pre-commit/releases/assets/509317714" - -[tools.pre-commit."platforms.macos-x64"] -checksum = "sha256:43c09a73d82af8efff09900e60d76d67a2c3d990613c3c770b2b5c4418a16c4b" -url = "https://github.com/pre-commit/pre-commit/releases/download/v4.6.2/pre-commit-4.6.2.pyz" -url_api = "https://api.github.com/repos/pre-commit/pre-commit/releases/assets/509317714" +[[tools."github:DetachHead/basedpyright"]] +version = "1.39.10" +backend = "github:DetachHead/basedpyright" + +[tools."github:DetachHead/basedpyright"."platforms.linux-arm64"] +checksum = "sha256:c8eaf5302f3265e275c7df4fba194d7afa7c1cb53fbfd448e90098360aca2c2e" +url = "https://github.com/DetachHead/basedpyright/releases/download/v1.39.10/basedpyright-1.39.10.tar.gz" +url_api = "https://api.github.com/repos/DetachHead/basedpyright/releases/assets/513265626" +provenance = "github-attestations" + +[tools."github:DetachHead/basedpyright"."platforms.linux-arm64-musl"] +checksum = "sha256:c8eaf5302f3265e275c7df4fba194d7afa7c1cb53fbfd448e90098360aca2c2e" +url = "https://github.com/DetachHead/basedpyright/releases/download/v1.39.10/basedpyright-1.39.10.tar.gz" +url_api = "https://api.github.com/repos/DetachHead/basedpyright/releases/assets/513265626" +provenance = "github-attestations" + +[tools."github:DetachHead/basedpyright"."platforms.linux-x64"] +checksum = "sha256:c8eaf5302f3265e275c7df4fba194d7afa7c1cb53fbfd448e90098360aca2c2e" +url = "https://github.com/DetachHead/basedpyright/releases/download/v1.39.10/basedpyright-1.39.10.tar.gz" +url_api = "https://api.github.com/repos/DetachHead/basedpyright/releases/assets/513265626" +provenance = "github-attestations" + +[tools."github:DetachHead/basedpyright"."platforms.linux-x64-musl"] +checksum = "sha256:c8eaf5302f3265e275c7df4fba194d7afa7c1cb53fbfd448e90098360aca2c2e" +url = "https://github.com/DetachHead/basedpyright/releases/download/v1.39.10/basedpyright-1.39.10.tar.gz" +url_api = "https://api.github.com/repos/DetachHead/basedpyright/releases/assets/513265626" +provenance = "github-attestations" + +[tools."github:DetachHead/basedpyright"."platforms.macos-arm64"] +checksum = "sha256:c8eaf5302f3265e275c7df4fba194d7afa7c1cb53fbfd448e90098360aca2c2e" +url = "https://github.com/DetachHead/basedpyright/releases/download/v1.39.10/basedpyright-1.39.10.tar.gz" +url_api = "https://api.github.com/repos/DetachHead/basedpyright/releases/assets/513265626" +provenance = "github-attestations" + +[tools."github:DetachHead/basedpyright"."platforms.macos-x64"] +checksum = "sha256:c8eaf5302f3265e275c7df4fba194d7afa7c1cb53fbfd448e90098360aca2c2e" +url = "https://github.com/DetachHead/basedpyright/releases/download/v1.39.10/basedpyright-1.39.10.tar.gz" +url_api = "https://api.github.com/repos/DetachHead/basedpyright/releases/assets/513265626" +provenance = "github-attestations" + +[tools."github:DetachHead/basedpyright"."platforms.windows-x64"] +checksum = "sha256:c8eaf5302f3265e275c7df4fba194d7afa7c1cb53fbfd448e90098360aca2c2e" +url = "https://github.com/DetachHead/basedpyright/releases/download/v1.39.10/basedpyright-1.39.10.tar.gz" +url_api = "https://api.github.com/repos/DetachHead/basedpyright/releases/assets/513265626" +provenance = "github-attestations" +provenance_verified = true [[tools.prek]] version = "0.4.13" @@ -73,3 +86,94 @@ checksum = "sha256:dbd9dbe4f1e0142a3d264c84fbc1e0b1816392507c617cc71d971c8fdde9d url = "https://github.com/j178/prek/releases/download/v0.4.13/prek-x86_64-apple-darwin.tar.gz" url_api = "https://api.github.com/repos/j178/prek/releases/assets/508529818" provenance = "github-attestations" + +[tools.prek."platforms.windows-x64"] +checksum = "sha256:bf7adf70705a6ba0d2e1fd8a4016397e2609bc1765474eddec466db460a9f4ea" +url = "https://github.com/j178/prek/releases/download/v0.4.13/prek-x86_64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/j178/prek/releases/assets/508529822" +provenance = "github-attestations" + +[[tools.python]] +version = "3.12.14" +backend = "core:python" + +[tools.python."platforms.linux-arm64"] +checksum = "sha256:2d8e17dfd732102cfeb18e0e1fa6769b24caa034e159981129590fe409c7157a" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.12.14+20260814-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz" +provenance = "github-attestations" + +[tools.python."platforms.linux-arm64-musl"] +checksum = "sha256:4167705abc3e40767c7bd8f006ae5dbe677397391a2d9f7549e7759e3806180a" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.12.14+20260814-aarch64-unknown-linux-musl-install_only_stripped.tar.gz" +provenance = "github-attestations" + +[tools.python."platforms.linux-x64"] +checksum = "sha256:5acfa3e9ba26b51ae161c83aff278da915b590d22373a424b2ba55b8afe91fcc" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.12.14+20260814-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" +provenance = "github-attestations" + +[tools.python."platforms.linux-x64-musl"] +checksum = "sha256:25adbf9d865ada81c728ba9ee6cd042736b6b46dcd3a889198038da04eed9c4a" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.12.14+20260814-x86_64-unknown-linux-musl-install_only_stripped.tar.gz" +provenance = "github-attestations" + +[tools.python."platforms.macos-arm64"] +checksum = "sha256:dd5b76ab11451a4a4367c17c61d944dded56b425396b07f102922a7ebef7d55f" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.12.14+20260814-aarch64-apple-darwin-install_only_stripped.tar.gz" +provenance = "github-attestations" + +[tools.python."platforms.macos-x64"] +checksum = "sha256:aec265e3cddaccdb2a3d783331596351b24d4a63c97af0a38f75f643c9451de9" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.12.14+20260814-x86_64-apple-darwin-install_only_stripped.tar.gz" +provenance = "github-attestations" + +[tools.python."platforms.windows-x64"] +checksum = "sha256:89f18f6932917163b74339ebcec2645c8e47ae7f1c5f2ac37f2b4f4cf3beb647" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.12.14+20260814-x86_64-pc-windows-msvc-install_only_stripped.tar.gz" +provenance = "github-attestations" + +[[tools.ruff]] +version = "0.16.3" +backend = "aqua:astral-sh/ruff" + +[tools.ruff."platforms.linux-arm64"] +checksum = "sha256:8319ba22f655e6efc086103486d7165bf0de73f71ff1c5f25ba580153ad05feb" +url = "https://github.com/astral-sh/ruff/releases/download/0.16.3/ruff-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/ruff/releases/assets/513140238" +provenance = "github-attestations" + +[tools.ruff."platforms.linux-arm64-musl"] +checksum = "sha256:8319ba22f655e6efc086103486d7165bf0de73f71ff1c5f25ba580153ad05feb" +url = "https://github.com/astral-sh/ruff/releases/download/0.16.3/ruff-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/ruff/releases/assets/513140238" +provenance = "github-attestations" + +[tools.ruff."platforms.linux-x64"] +checksum = "sha256:d67c9b5949981698c48915abf65e0b3406ba9184ad73521cdf20a926bc889c73" +url = "https://github.com/astral-sh/ruff/releases/download/0.16.3/ruff-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/ruff/releases/assets/513140356" +provenance = "github-attestations" + +[tools.ruff."platforms.linux-x64-musl"] +checksum = "sha256:d67c9b5949981698c48915abf65e0b3406ba9184ad73521cdf20a926bc889c73" +url = "https://github.com/astral-sh/ruff/releases/download/0.16.3/ruff-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/ruff/releases/assets/513140356" +provenance = "github-attestations" + +[tools.ruff."platforms.macos-arm64"] +checksum = "sha256:136a4db6512d9b16dda56ac8604696ed65c3b1a914a142de029e7f8d5006f1d9" +url = "https://github.com/astral-sh/ruff/releases/download/0.16.3/ruff-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/ruff/releases/assets/513140218" +provenance = "github-attestations" + +[tools.ruff."platforms.macos-x64"] +checksum = "sha256:05c2a6705e7c0c056d6d93ff538978583f0c47b4c28d334ab9d58d2e8daf4c24" +url = "https://github.com/astral-sh/ruff/releases/download/0.16.3/ruff-x86_64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/ruff/releases/assets/513140338" +provenance = "github-attestations" + +[tools.ruff."platforms.windows-x64"] +checksum = "sha256:f10c709755b393fd9821506b21070bcca969b9966504edd1e490efd08e3662ba" +url = "https://github.com/astral-sh/ruff/releases/download/0.16.3/ruff-x86_64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/astral-sh/ruff/releases/assets/513140346" +provenance = "github-attestations" diff --git a/mise.toml b/mise.toml index d6c9236..6ee1c1b 100755 --- a/mise.toml +++ b/mise.toml @@ -1,2 +1,5 @@ [tools] -prek = "1.39.10" +python = "3.12" +prek = "0.4.13" +ruff = "0.16.3" +"github:DetachHead/basedpyright" = "1.39.10" \ No newline at end of file diff --git a/windows/appsettings.py b/windows/appsettings.py index ba94c61..9f3d649 100755 --- a/windows/appsettings.py +++ b/windows/appsettings.py @@ -101,6 +101,12 @@ # watcher (works elevated, unlike Steam Input's injected key chords). # Tray "Startup → Open Keyboard Chord". Default "X" (Steam+X). "sc_osk_open_chord": "X", + # Split keyboard layout (tray "Steam Controller -> Split Keyboard"): + # split the keyboard into left/right halves with a middle gap, each + # touchpad covering its own half (better ergonomics — no cross-body + # reach). Applied on the next OSK open (or live while open, like the + # other SC toggles). + "osk_split_layout": False, # Per-foreground-app remembered OSK "Move" position: {exe name (lowercase): # position index 0-5}. Remembered per app so the keyboard reopens where the # user left it in each app; apps without an entry keep the current spot diff --git a/windows/sdl3w/__init__.py b/windows/sdl3w/__init__.py index 0498654..7f5cbbb 100755 --- a/windows/sdl3w/__init__.py +++ b/windows/sdl3w/__init__.py @@ -130,6 +130,7 @@ class SDL_Surface(ctypes.Structure): # Hints SDL_HINT_WINDOW_ACTIVATE_WHEN_SHOWN = b"SDL_WINDOW_ACTIVATE_WHEN_SHOWN" +SDL_HINT_TOUCH_MOUSE_EVENTS = b"SDL_TOUCH_MOUSE_EVENTS" # =========================================================================== @@ -253,6 +254,12 @@ def get_error(): ctypes.POINTER(ctypes.c_int), ], ) +SDL_SetWindowSize = _bind( + SDL, + "SDL_SetWindowSize", + ctypes.c_bool, + [ctypes.c_void_p, ctypes.c_int, ctypes.c_int], +) SDL_ShowWindow = _bind(SDL, "SDL_ShowWindow", ctypes.c_bool, [ctypes.c_void_p]) SDL_HideWindow = _bind(SDL, "SDL_HideWindow", ctypes.c_bool, [ctypes.c_void_p]) SDL_SetWindowAlwaysOnTop = _bind( diff --git a/windows/tests/test_config.py b/windows/tests/test_config.py index 19fd612..0756963 100755 --- a/windows/tests/test_config.py +++ b/windows/tests/test_config.py @@ -180,3 +180,86 @@ def test_75pct_layout_has_function_row_and_nav_cluster(): # Home/End repeat when held (like a real board); Ins does not need to. assert vkb.is_repeatable(fn_by_code["KEY_HOME"]) assert vkb.is_repeatable(fn_by_code["KEY_END"]) + + +def test_split_layout_splits_rows_into_two_halves_with_gap(): + """Split layout (tray "Steam Controller -> Split Keyboard") must lay each + row out as TWO halves — the left half starting at the left edge, the right + half pushed against the right edge — with an empty middle gap. Guards the + _row_key_positions split math: every key lands in one half, the two halves + never overlap, and hit-testing still resolves both halves.""" + import triton.screen as screen + from triton import state + + state.reset_session() + state.set_split_layout(True) + try: + kb_config = vkb.VirtualKeyboardConfig() + layout = config.YamlFile("keyboard-layout.yaml") + layout.read() + layout.add_to_config("keys", kb_config) + kb = kb_config.construct() + + # Split mode runs the window at full display width (the halves are + # sized from the plain keyboard width, so keys keep their size and the + # display's extra width becomes the transparent middle gap). + screen.width, screen.height = 2560, 369 + kb.update_dimensions() + + layouts = list(kb.gen_key_layouts()) + assert layouts, "split layout must still produce keys" + # Every key stays inside the window. + for lay in layouts: + assert lay.x >= 0 and lay.x + lay.w <= screen.width + + # Each row splits into exactly two halves with a real middle gap. + gap = kb.split_gap_px() + assert gap > 0 + # All rows share ONE gap x-position (the fixed center band), so the + # transparent middle is a clean vertical strip the renderer can clear. + band = kb.split_gap_band() + assert band is not None + band_left, band_right = band + for i_row in range(kb.key_rows): + row_lays = [l for l in layouts if l.row == i_row] + if len(row_lays) < 2: + continue + split_idx = kb._split_index(i_row) + left = [l for l in row_lays if l.col < split_idx] + right = [l for l in row_lays if l.col >= split_idx] + assert left and right, "split layout must leave keys on both sides" + # Left half hugs the left edge; right half hugs the right edge. + assert left[0].x <= screen.width // 2 + assert right[-1].x + right[-1].w >= screen.width // 2 + # No left key crosses into the right half and vice versa (the + # halves may each reach past center when a wide key like Space + # dominates its side — the gap between them is the invariant). + left_max = max(l.x + l.w for l in left) + right_min = min(l.x for l in right) + assert left_max < right_min + # Every row's gap sits on the SAME band (rounding-tolerant: each + # key's width is rounded, so error grows with key count), which is + # what makes the middle a uniform transparent strip. + tol = len(row_lays) // 2 + 2 + assert abs(left_max - band_left) <= tol + assert abs(right_min - band_right) <= tol + # The halves of every row are separated by a real (px) gap. + for i_row in range(kb.key_rows): + row_lays = [l for l in layouts if l.row == i_row] + if len(row_lays) < 2: + continue + split_idx = kb._split_index(i_row) + left_max = max(l.x + l.w for l in row_lays if l.col < split_idx) + right_min = min(l.x for l in row_lays if l.col >= split_idx) + assert right_min - left_max >= gap - 4 # rounding-tolerant + + # Hit-testing: a click on the left half finds a LEFT-half key, a + # click on the right half finds a RIGHT-half key. + mid_y = kb.padding_outer + kb.key_height // 2 + left_hit = kb.find_key_rc(screen.width // 4, mid_y) + right_hit = kb.find_key_rc(3 * screen.width // 4, mid_y) + assert left_hit is not None and right_hit is not None + assert left_hit[1] < kb._split_index(left_hit[0]) + assert right_hit[1] >= kb._split_index(right_hit[0]) + finally: + state.set_split_layout(False) diff --git a/windows/tests/test_controller.py b/windows/tests/test_controller.py index c4baa7f..0af77b6 100755 --- a/windows/tests/test_controller.py +++ b/windows/tests/test_controller.py @@ -34,6 +34,22 @@ def test_adjust_raw_never_returns_outside_window(): assert 0 <= controller.adjust_raw_y(ry, 1 / 2) <= screen.height +def test_adjust_raw_x_span_maps_full_int16_range(): + """Split-layout pad span mapping: the touchpad raw X is the HID report's + int16 (±0x8000), so the pad's whole travel must land in its band. (The + old ±0x20000 scale squeezed every pad into the middle 25% of its span, + leaving the outer keys unreachable.)""" + screen.width, screen.height = 2560, 369 + assert controller.adjust_raw_x_span(-0x8000, 0, 730) == 0 + assert controller.adjust_raw_x_span(0, 0, 730) == 365 + assert controller.adjust_raw_x_span(0x7FFF, 0, 730) == 730 + assert controller.adjust_raw_x_span(-0x8000, 1830, 2560) == 1830 + assert controller.adjust_raw_x_span(0, 1830, 2560) == 2195 + assert controller.adjust_raw_x_span(0x7FFF, 1830, 2560) == 2560 + assert controller.adjust_raw_x_span(-0x10000, 0, 730) == 0 # clamped + assert controller.adjust_raw_x_span(0x10000, 1830, 2560) == 2560 + + """Headless tests for the Select key (iOS hold-space text selection).""" diff --git a/windows/tray/app.py b/windows/tray/app.py index 41f52cc..82276f5 100755 --- a/windows/tray/app.py +++ b/windows/tray/app.py @@ -93,6 +93,10 @@ def __init__(self): self.settings.get("sc_osk_trigger_actuation", "default") ) ) + # Split keyboard layout (left/right halves, one touchpad per side). + triton_state.set_split_layout( + bool(self.settings.get("osk_split_layout", False)) + ) # Trackpad press-force calibration (settings.json, hand-edited). triton_state.set_sc_pad_press( @@ -414,6 +418,25 @@ def is_click_button_checked(self, name): self.settings.get("sc_click_button", "L1/R1") == name ) + # Split keyboard layout: left/right halves, each touchpad covers its own + # half (no cross-body reach). Published to triton state immediately, so an + # already-open keyboard resizes its window + re-lays-out live (the main + # loop handles the flag change, exactly like a resize). The window WIDTH + # (full display width in split mode) is baked at Screen construction, so + # the cached Screen is rebuilt once the current run finishes / when closed. + def toggle_split_layout(self, icon, item): + self.settings["osk_split_layout"] = not item.checked + _save_settings(self.settings) + triton_state.set_split_layout(self.settings["osk_split_layout"]) + if self._kbd_open: + self._pending_size_change = True + else: + self._rebuild_cached_screen() + self._refresh_menu() + + def is_split_layout_checked(self, item): + return self.settings.get("osk_split_layout", False) + # --- Diacritic variants (Feature B: hold a letter to pick accents) ------- # Follows the existing toggle/radio pattern: save to settings.json AND # republish to the runtime state immediately, so a change applies without diff --git a/windows/tray/menu.py b/windows/tray/menu.py index 9786672..385df69 100755 --- a/windows/tray/menu.py +++ b/windows/tray/menu.py @@ -60,10 +60,16 @@ def build_menu(app): ) # Steam on-screen-keyboard skins (radio; applied on the next OSK open). - # The "Size" and "Transparent" submenus sit at the top, above the skin list. + # The "Size", "Transparent" and "Split Keyboard" items sit at the top, + # above the skin list. skin_submenu = pystray.Menu( pystray.MenuItem("Size", size_submenu), pystray.MenuItem("Transparent", transparent_submenu), + pystray.MenuItem( + "Split Keyboard", + app.toggle_split_layout, + checked=app.is_split_layout_checked, + ), pystray.Menu.SEPARATOR, *[ pystray.MenuItem( diff --git a/windows/triton/controller.py b/windows/triton/controller.py index 8928b4c..993fa35 100755 --- a/windows/triton/controller.py +++ b/windows/triton/controller.py @@ -100,6 +100,22 @@ def adjust_raw_x(raw_x, center_fraction, scalar=6 / 5): return utils.round_to_int(utils.clamp(x, 0, screen.width)) +def adjust_raw_x_span(raw_x, span_start, span_end): + """Map the touchpad's raw X across the fixed screen interval + [span_start, span_end]. Split layout uses this so each pad covers exactly + its own half's key span — the left pad [0, band_left], the right pad + [band_right, width] — instead of half the (much wider) display, most of + which is transparent middle gap where no key lives. The raw X is the HID + int16 (±0x8000) from the report struct — normalized against the TRUE full + scale so the pad's whole travel lands in its band (a ±0x20000 scale would + squeeze every pad into the middle 25% of its span, making the outer keys + unreachable).""" + abs_max = 0x8000 + frac = (raw_x + abs_max) / (2 * abs_max) + x = span_start + frac * (span_end - span_start) + return utils.round_to_int(utils.clamp(x, 0, screen.width)) + + def adjust_raw_y(raw_y, center_fraction, scalar=6 / 5): """Map the touchpad's raw Y to a screen Y, clamped into the window (see adjust_raw_x). Without the clamp the top of the pad maps to y=-258 and the @@ -804,14 +820,41 @@ def handle_input(self, sc, sc_input): self.sc_input_previous = pad_frame return - ptr_left_coords = CoordFraction.from_absolute( - adjust_raw_x(sc_input.lpad_x, 1 / 4), - adjust_raw_y(sc_input.lpad_y, 1 / 2), - ) - ptr_right_coords = CoordFraction.from_absolute( - adjust_raw_x(sc_input.rpad_x, 3 / 4), - adjust_raw_y(sc_input.rpad_y, 1 / 2), - ) + split = state.is_split_layout_enabled() + band = None + if split: + kb = state.get_virtual_kb() + if kb is not None: + band = kb.split_gap_band() + if band is not None: + band_left, band_right = band + ptr_left_coords = CoordFraction.from_absolute( + adjust_raw_x_span(sc_input.lpad_x, 0, band_left), + adjust_raw_y(sc_input.lpad_y, 1 / 2), + ) + ptr_right_coords = CoordFraction.from_absolute( + adjust_raw_x_span( + sc_input.rpad_x, band_right, screen.width + ), + adjust_raw_y(sc_input.rpad_y, 1 / 2), + ) + else: + ptr_left_coords = CoordFraction.from_absolute( + adjust_raw_x( + sc_input.lpad_x, + 1 / 4, + scalar=1 / 4 if split else 6 / 5, + ), + adjust_raw_y(sc_input.lpad_y, 1 / 2), + ) + ptr_right_coords = CoordFraction.from_absolute( + adjust_raw_x( + sc_input.rpad_x, + 3 / 4, + scalar=1 / 4 if split else 6 / 5, + ), + adjust_raw_y(sc_input.rpad_y, 1 / 2), + ) # Feature B (diacritic variants): while a pad-driven variant row is # open, that pad's press lock is bypassed (and its stored target diff --git a/windows/triton/screen.py b/windows/triton/screen.py index 5417ff9..ca8ef09 100755 --- a/windows/triton/screen.py +++ b/windows/triton/screen.py @@ -21,7 +21,7 @@ # from triton.screen (the class lives in triton.geometry). from triton.geometry import CoordFraction as CoordFraction from triton.geometry import set_dims -from triton.size import _BASE_HEIGHT, _compute_size +from triton.size import _BASE_HEIGHT, _compute_size, _compute_split_size width = 1286 height = 369 @@ -56,6 +56,20 @@ def get_osk_size(): return _active_osk_size +def resize_for_layout(): + """Recompute width/height for the CURRENT split-layout setting + size + submenu, updating the module dims (read by CoordFraction and vkb's key + layout). Called at Screen construction and when the tray toggles split + layout live (the main loop then resizes the SDL window). Returns (w, h).""" + global width, height + if state.is_split_layout_enabled(): + width, height = _compute_split_size(_active_osk_size) + else: + width, height = _compute_size(_active_osk_size) + set_dims(width, height) # keep geometry.py in sync (CoordFraction reads it) + return width, height + + class Screen: # Built-in default palette (kept when Steam's theme can't be resolved): # pure-black OLED backgrounds, cream labels, dark-grey cursor. @@ -121,10 +135,7 @@ def __init__(self): # the same ratio so "Small"/"Full Screen" stay proportional to the # original 1286x369 "Default" look instead of just changing the grid. global width, height - width, height = _compute_size(_active_osk_size) - set_dims( - width, height - ) # keep geometry.py in sync (CoordFraction reads it) + width, height = resize_for_layout() # Mark the layout dirty: this construction may have changed the module # dims from whatever the VirtualKeyboard was built against (e.g. the # tray pre-warm failed and main() built a fresh Screen at a non-default @@ -145,6 +156,10 @@ def __init__(self): # (e.g. a browser address bar / YouTube search field). "0" = do not # activate on show (the SDL3 successor to the old NO_ACTIVATION hint). S.SDL_SetHint(S.SDL_HINT_WINDOW_ACTIVATE_WHEN_SHOWN, b"0") + # Touchscreen support: let SDL synthesize mouse events from finger + # input (SDL3 defaults to touch-only events), so a touchscreen can + # point/type at the OSK exactly like a mouse. + S.SDL_SetHint(S.SDL_HINT_TOUCH_MOUSE_EVENTS, b"1") # Anchor the window to the bottom-center of the primary display's # usable area (i.e. above the taskbar). @@ -499,6 +514,36 @@ def clear(self): S.SDL_SetRenderDrawColor(self.renderer, c.r, c.g, c.b, 255) S.SDL_RenderClear(self.renderer) + def _render_split_background(self, virtual_kb): + """Split layout, opaque mode: erase everything to alpha 0 and repaint + ONLY the two keyboard halves — the middle gap stays fully transparent + so the desktop shows through the band (the "no black in between" split + look) instead of the window's opaque fill. Transparent mode already + erases everywhere, so this is a no-op there (the halves keep their + translucent key fills).""" + band = virtual_kb.split_gap_band() + if band is None: + return + left_x, right_x = band + c = self.bg_color + S.SDL_SetRenderDrawColor(self.renderer, 0, 0, 0, 0) + S.SDL_RenderClear(self.renderer) + S.SDL_SetRenderDrawColor(self.renderer, c.r, c.g, c.b, 255) + S.SDL_RenderFillRect( + self.renderer, + ctypes.byref( + S.SDL_FRect(0.0, 0.0, float(left_x), float(height)) + ), + ) + S.SDL_RenderFillRect( + self.renderer, + ctypes.byref( + S.SDL_FRect( + float(right_x), 0.0, float(width - right_x), float(height) + ) + ), + ) + # Glyph cache target size. Source PNGs are 128–240 px but get drawn at # ~30–50 px on screen — an 8× one-step GPU downscale aliases hard even # with linear filtering. Pre-resampling to ~2× the typical draw size @@ -1540,6 +1585,10 @@ def _render_diacritic_row(self): def render(self, virtual_kb, pointers): self.clear() + if not self._transparent: + # Split layout: repaint only the two halves so the middle gap stays + # transparent (opaque mode already filled the whole window). + self._render_split_background(virtual_kb) self.render_vkb(virtual_kb, pointers) self._render_diacritic_row() # Only show the finger circles while the trackpad is actually being @@ -1599,6 +1648,8 @@ def render_open_anim(self, virtual_kb, pointers, fade, cut_px): # 1) Keyboard -> offscreen texture, full opacity, normal appearance. S.SDL_SetRenderTarget(self.renderer, tex) self.clear() + if not self._transparent: + self._render_split_background(virtual_kb) # The animation runs without a content_changed this frame, so the caps # cache may be stale — force render_vkb to read it fresh. self._caps_on = None @@ -1650,6 +1701,8 @@ def render_close_anim(self, virtual_kb, pointers, fade, scale=1.0): return False S.SDL_SetRenderTarget(self.renderer, tex) self.clear() + if not self._transparent: + self._render_split_background(virtual_kb) # Same as render_open_anim: no content_changed this frame, so force a # fresh caps read inside render_vkb. self._caps_on = None diff --git a/windows/triton/size.py b/windows/triton/size.py index 589c787..1cb139a 100755 --- a/windows/triton/size.py +++ b/windows/triton/size.py @@ -54,3 +54,21 @@ def _compute_size(name): return bounds.w, int(round(_BASE_HEIGHT * scale)) return _BASE_WIDTH, _BASE_HEIGHT return (int(round(_BASE_WIDTH * scale)), int(round(_BASE_HEIGHT * scale))) + + +def _compute_split_size(name): + """Split-layout OSK size: the window spans the primary display's usable + width edge-to-edge so both halves sit at the screen corners (the middle + gap is rendered transparent, showing the desktop). The height follows the + size submenu ("small" scales it down; "full"/"medium" share the scaled + default height) — only the width changes in split mode.""" + scale = _display_scale() + if name == "small": + height = int(round(_BASE_HEIGHT * _SMALL_SCALE * scale)) + else: + height = int(round(_BASE_HEIGHT * scale)) + bounds = S.SDL_Rect() + disp = S.SDL_GetPrimaryDisplay() + if disp and S.SDL_GetDisplayUsableBounds(disp, ctypes.byref(bounds)): + return bounds.w, height + return int(round(_BASE_WIDTH * scale)), height diff --git a/windows/triton/state.py b/windows/triton/state.py index 95b50f7..7075d7e 100755 --- a/windows/triton/state.py +++ b/windows/triton/state.py @@ -945,6 +945,26 @@ def get_sc_osk_trigger_threshold(): return _sc_osk_trigger_threshold +# Split layout (settings.json "osk_split_layout", tray "Steam Controller -> +# Split Keyboard"): split the keyboard into left/right halves with a middle +# gap, each touchpad covering its own half. Published by the tray; read by +# vkb (geometry) and controller.py (pad X mapping). Session-independent +# config - deliberately NOT reset by reset_session(). +_split_layout = False +_split_layout_lock = Lock() + + +def set_split_layout(enabled): + global _split_layout + with _split_layout_lock: + _split_layout = bool(enabled) + + +def is_split_layout_enabled(): + with _split_layout_lock: + return _split_layout + + def key_sound_tick(): """Fire the registered key-press sound hook on every dispatched key. Gated by the key-sound enabled flag; NOT silenced while Steam runs (see diff --git a/windows/triton/triton.py b/windows/triton/triton.py index 5f0dc5b..6bdaaf0 100755 --- a/windows/triton/triton.py +++ b/windows/triton/triton.py @@ -79,12 +79,13 @@ _OPEN_ANIM_ZETA = 0.70 _OPEN_ANIM_OMEGA0 = 20.0 -# OSK CLOSE animation: a quick spring reverse (fade 1->0 + slight scale-down + -# a short downward slide), over _CLOSE_ANIM_SECS. Research: closes are faster -# and snappier than opens, and should NOT bounce (an overshooting exit reads as -# a glitch). The scale/fade follow a single eased curve; the slide is linear. +# OSK CLOSE animation: a quick spring reverse (fade 1->0 + slight scale-down), +# over _CLOSE_ANIM_SECS. Research: closes are faster and snappier than opens, +# and should NOT bounce (an overshooting exit reads as a glitch). The scale and +# fade follow a single eased curve. NO window movement during the close: moving +# the layered per-pixel-alpha window mid-fade makes DWM re-composite stale +# opaque content, which showed up as a flash on the bottom at the end. _CLOSE_ANIM_SECS = 0.16 -_CLOSE_ANIM_SLIDE_PX = 16 # Parking spot for the always-visible OSK window while "closed" (must match @@ -444,6 +445,10 @@ def main(cached_screen=None, on_close=None): # once at startup and never re-published mid-process, so it's static per # OSK session — cache it instead of a lock read every visible iteration. stick_nav_enabled = state.is_sc_kbd_stick_nav_enabled() + # Split layout (tray "Steam Controller -> Split Keyboard") is published + # live; the loop watches it and re-derives key geometry on change (see the + # update_dimensions gate below). + split_layout_was = state.is_split_layout_enabled() while not state.should_close(): now = time.monotonic() @@ -807,6 +812,22 @@ def main(cached_screen=None, on_close=None): # per-iteration call was rebuilding ~90 identical key widths. if scr._resize_dirty: virtual_kb.update_dimensions() + # Split layout toggled live from the tray: the window itself must + # change width (split = full display width, plain = the size + # submenu), so recompute the module dims, resize the SDL window, + # re-settle it at its position spot, and re-derive the key + # geometry. Same cache invalidation + render gate as a resize. + if state.is_split_layout_enabled() != split_layout_was: + split_layout_was = state.is_split_layout_enabled() + w, h = screen.resize_for_layout() + S.SDL_SetWindowSize(scr.window, w, h) + _apply_window_position(scr.window) + # The open/close animation renders to a cached offscreen + # texture sized for the old dimensions — drop it so the next + # anim target is built at the new size. + scr._anim_target = None + virtual_kb.update_dimensions() + scr._resize_dirty = True # --- Input-driven work runs EVERY loop iteration (NOT gated by the # render rate), so cursor steps and key presses drain at low latency # (the SC's frames go straight to the input thread). @@ -1014,27 +1035,15 @@ def main(cached_screen=None, on_close=None): # is throttled separately above. time.sleep(_LOOP_SLEEP) - # --- OSK CLOSE animation: quick spring reverse (fade + scale + slide) --- + # --- OSK CLOSE animation: quick spring reverse (fade + scale) --- # Played while the window is still visible, right before the hide/park in # the teardown. Fades the keyboard out over _CLOSE_ANIM_SECS with a gentle - # scale-down and a short downward slide, so closing feels as polished as - # opening. Falls back to an instant hide if the animation can't render. + # scale-down, so closing feels as polished as opening. Falls back to an + # instant hide if the animation can't render. The window is NOT moved + # during the close (see the _CLOSE_ANIM_SECS comment). if scr.render_close_anim( virtual_kb, controller_state.get_pointers(), 1.0, 1.0 ): - # The close animation must slide the window from WHERE IT ACTUALLY IS - # now — not from the resting spot it opened at. A Move (or a jump) - # repositions the window during the session, and SDL_GetWindowPosition - # gives us the true current spot; open_anim_rest would be stale (the - # bug: closing after a Move animated at the old spot and snapped - # back to it). - _close_xy = None - _px = ctypes.c_int() - _py = ctypes.c_int() - if S.SDL_GetWindowPosition( - scr.window, ctypes.byref(_px), ctypes.byref(_py) - ): - _close_xy = (int(_px.value), int(_py.value)) _close_anim_start = time.monotonic() _close_next = _close_anim_start while True: @@ -1042,15 +1051,10 @@ def main(cached_screen=None, on_close=None): _p = (_now - _close_anim_start) / _CLOSE_ANIM_SECS if _p >= 1.0: break - # Single eased curve drives fade + scale; the slide is linear. + # One eased curve drives fade + scale ("recede into the plate"). _e = _p * _p * (3.0 - 2.0 * _p) # smoothstep _fade = 1.0 - _e _scale = 1.0 - 0.08 * _e - _slide = _CLOSE_ANIM_SLIDE_PX * _p - if _close_xy is not None: - S.SDL_SetWindowPosition( - scr.window, _close_xy[0], int(round(_close_xy[1] + _slide)) - ) scr.render_close_anim( virtual_kb, controller_state.get_pointers(), _fade, _scale ) @@ -1060,10 +1064,12 @@ def main(cached_screen=None, on_close=None): _sleep = _close_next - time.monotonic() if _sleep > 0: time.sleep(_sleep) - # Snap back to the current spot so the hide/park below parks the - # window where the user last left it (Move-remembered position). - if _close_xy is not None: - S.SDL_SetWindowPosition(scr.window, _close_xy[0], _close_xy[1]) + # Commit one fully-transparent frame so the hide/park below moves an + # already alpha-0 window (a moved opaque-backed layered window would + # flash during DWM re-composite). + scr.render_close_anim( + virtual_kb, controller_state.get_pointers(), 0.0, 0.92 + ) try: # Fire the close callback BEFORE the teardown below: the tray's diff --git a/windows/triton/vkb.py b/windows/triton/vkb.py index 5bb51c4..059d2ae 100755 --- a/windows/triton/vkb.py +++ b/windows/triton/vkb.py @@ -4,6 +4,7 @@ from triton import config, diacritics, screen, state, utils from triton.color import Color +from triton.size import _compute_size kb = sui.Keyboard() @@ -94,6 +95,7 @@ def _uniform_key_width(self, row): unpadded_width = ( screen.width - self.padding_outer * 2 + - self.split_gap_px() - (len(self.keys[row]) * self.padding_inner * 2) ) weights_total = 0 @@ -102,6 +104,135 @@ def _uniform_key_width(self, row): return unpadded_width / weights_total + def split_gap_px(self): + """Middle-gap width (px) when split layout is on: a fraction of the + window so "full" displays get a proportionally larger gap.""" + if not state.is_split_layout_enabled(): + return 0 + return round(screen.width * self.SPLIT_GAP_FRACTION) + + def _split_index(self, i_row): + """Column where `i_row` splits into left/right halves in split layout: + the column whose width_weight sum leaves the two halves most evenly + balanced. The halves occupy equal-width fixed regions (see split_x), so + balancing the key WEIGHTS gives the most natural split. At least one + key lands on each side.""" + row = self.keys[i_row] + n = len(row) + if n < 2: + return n + total = sum(k.width_weight for k in row) + best_i, best_diff = 1, None + left = row[0].width_weight + for i in range(1, n): + diff = abs(left - (total - left)) + if best_diff is None or diff < best_diff: + best_i, best_diff = i, diff + left += row[i].width_weight + return best_i + + def _split_ref_width(self): + """Non-split keyboard width (px) for the CURRENT size submenu — the + reference each split half is sized against. Sizing the halves from the + plain-mode width (instead of from half the display) keeps split keys at + the same size the user picked; the display's extra width becomes + transparent middle gap.""" + return _compute_size(screen.get_osk_size())[0] + + def _split_region(self): + """Usable width (px) of each split half in split layout, 0 when off. + The halves are anchored to the window edges but sized from the + non-split keyboard width (see _split_ref_width), so keys keep their + normal size and the middle of a wide display stays transparent.""" + if not state.is_split_layout_enabled(): + return 0 + ref_w = self._split_ref_width() + gap = round(ref_w * self.SPLIT_GAP_FRACTION) + return (ref_w - gap) / 2 - self.padding_outer + + def split_gap_band(self): + """(left, right) x-bounds of the transparent middle band in split + layout, or None when off. Used by the renderer to clear the gap to + alpha 0 (the desktop shows through, "no black in between") while the + two halves stay opaque, and by the controller to map each pad onto its + own half's key span.""" + if not state.is_split_layout_enabled(): + return None + region = self._split_region() + return ( + self.padding_outer + region, + screen.width - self.padding_outer - region, + ) + + def _half_key_widths(self, i_row): + """(left, right) per-key base widths (px per width_weight unit) for + `i_row` in split layout: each half is sized independently against its + own fixed-width region (either side of the gap band), so rows with + different key counts still keep both halves symmetric around the ONE + middle band. Plain layout returns the shared row width twice.""" + if not state.is_split_layout_enabled(): + kw = self.key_width[i_row] + return kw, kw + split_idx = self._split_index(i_row) + row = self.keys[i_row] + left = row[:split_idx] + right = row[split_idx:] + region = self._split_region() + left_unpadded = region - len(left) * self.padding_inner * 2 + right_unpadded = region - len(right) * self.padding_inner * 2 + return ( + left_unpadded / sum(k.width_weight for k in left), + right_unpadded / sum(k.width_weight for k in right), + ) + + def _key_unit_width(self, i_row, col): + """Per-key base width (px per width_weight unit) at (i_row, col): the + shared row width in plain layout, the key's own HALF's width in split + layout — so find_key, find_key_rc and the render iteration all agree + with _row_key_positions.""" + if not state.is_split_layout_enabled(): + return self.key_width[i_row] + if col < self._split_index(i_row): + return self._left_key_width[i_row] + return self._right_key_width[i_row] + + def _row_key_positions(self, i_row): + """Yield (col, x_start) for every key of `i_row` in visual left-to-right + order. Split layout splits the row into two halves around the middle + gap: the left half runs from the left edge to split_x(), the right + half from split_x() + gap to the right edge (each half sizes its own + keys, so all rows share ONE gap x-position — see split_gap_band); the + plain layout is one contiguous run. Single source of truth for the + render iteration, find_key, and find_key_rc, so the two halves stay in + sync.""" + row = self.keys[i_row] + if state.is_split_layout_enabled(): + split_idx = self._split_index(i_row) + x = self.padding_outer + for i, key in enumerate(row[:split_idx]): + yield i, x + x += ( + key.width_weight * self._left_key_width[i_row] + + self.padding_inner * 2 + ) + band = self.split_gap_band() + assert band is not None # split mode is on (guard above) + x = band[1] + for i, key in enumerate(row[split_idx:], start=split_idx): + yield i, x + x += ( + key.width_weight * self._right_key_width[i_row] + + self.padding_inner * 2 + ) + else: + x = self.padding_outer + for i, key in enumerate(row): + yield i, x + x += ( + key.width_weight * self.key_width[i_row] + + self.padding_inner * 2 + ) + def _uniform_key_height(self): return ( screen.height @@ -112,8 +243,13 @@ def _uniform_key_height(self): def update_dimensions(self): self.key_height = self._uniform_key_height() self.key_width = [] + self._left_key_width = [] + self._right_key_width = [] for i in range(0, self.key_rows): self.key_width.append(self._uniform_key_width(i)) + lw, rw = self._half_key_widths(i) + self._left_key_width.append(lw) + self._right_key_width.append(rw) self._layouts_cache = None def find_key_row(self, y_coord): @@ -125,13 +261,15 @@ def find_key_row(self, y_coord): def find_key(self, x_coord, y_coord): i_row = self.find_key_row(y_coord) i_row = utils.clamp(i_row, 0, self.key_rows - 1) - - iterated_x = self.padding_outer - for key in self.keys[i_row]: - adjusted_key_width = key.width_weight * self.key_width[i_row] - iterated_x += adjusted_key_width + self.padding_inner * 2 - if x_coord < iterated_x: - return key + row = self.keys[i_row] + for i, x_start in self._row_key_positions(i_row): + x_end = ( + x_start + + row[i].width_weight * self._key_unit_width(i_row, i) + + self.padding_inner * 2 + ) + if x_coord < x_end: + return row[i] return None def find_key_rc(self, x_coord, y_coord): @@ -139,15 +277,21 @@ def find_key_rc(self, x_coord, y_coord): mouse handler to drive the same cursor/press path as the DPAD. Clamps to the nearest in-bounds cell so an edge click never misses.""" i_row = utils.clamp(self.find_key_row(y_coord), 0, self.key_rows - 1) - iterated_x = self.padding_outer - for i_key, key in enumerate(self.keys[i_row]): - iterated_x += ( - key.width_weight * self.key_width[i_row] + row = self.keys[i_row] + for i, x_start in self._row_key_positions(i_row): + x_end = ( + x_start + + row[i].width_weight * self._key_unit_width(i_row, i) + self.padding_inner * 2 ) - if x_coord < iterated_x: - return (i_row, i_key) - return (i_row, len(self.keys[i_row]) - 1) + if x_coord < x_end: + return (i_row, i) + return (i_row, len(row) - 1) + + # Split layout: fraction of the window width left empty between the left + # and right keyboard halves. Sized relative to the window so "full" gets a + # proportionally larger gap. + SPLIT_GAP_FRACTION = 0.06 # Hit-target expansion (px) added around every key when resolving a click # or hover position (see find_key_expanded). Keys sit ~6 px apart (the @@ -253,12 +397,12 @@ def _iter_key_layouts(self): iterated_y = self.padding_outer for i_row, row in enumerate(self.keys): - iterated_x = self.padding_outer - - for i_key, key in enumerate(row): - adj_x = iterated_x + self.padding_inner + for i_key, x_start in self._row_key_positions(i_row): + adj_x = x_start + self.padding_inner adj_y = iterated_y + self.padding_inner - adj_w = key.width_weight * self.key_width[i_row] + adj_w = row[i_key].width_weight * self._key_unit_width( + i_row, i_key + ) adj_h = self.key_height yield self.KeyLayout( @@ -269,8 +413,6 @@ def _iter_key_layouts(self): i_row, i_key, ) - - iterated_x += adj_w + self.padding_inner * 2 iterated_y += self.key_height + self.padding_inner * 2 diff --git a/windows/triton/windowpos.py b/windows/triton/windowpos.py index 366f9ca..ad5cfef 100755 --- a/windows/triton/windowpos.py +++ b/windows/triton/windowpos.py @@ -2,7 +2,7 @@ import sdl3w as S -from triton import screen +from triton import screen, state # Index into the 6-position window rotation, advanced by Shift+Move. # 0 starts at down-mid (the default open location). @@ -55,7 +55,13 @@ def _apply_window_position(sdl_window, index=None, move=True): def _cycle_window_position(sdl_window): - _position_index[0] = (_position_index[0] + 1) % 6 + if state.is_split_layout_enabled(): + # The split window spans the full display width, so the left/right/mid + # spots collapse to the same x — the Move key alternates between DOWN + # (indices 0/1/5) and UP (2/3/4) only. + _position_index[0] = 0 if _position_index[0] not in (0, 1, 5) else 3 + else: + _position_index[0] = (_position_index[0] + 1) % 6 _apply_window_position(sdl_window)