diff --git a/.github/workflows/build-ffmpeg.yml b/.github/workflows/build-ffmpeg.yml index aebd3de365..c8812e0a5e 100644 --- a/.github/workflows/build-ffmpeg.yml +++ b/.github/workflows/build-ffmpeg.yml @@ -47,6 +47,8 @@ jobs: - name: Combine run: osu.Framework.NativeLibs/scripts/ffmpeg/combine_dylibs.sh + env: + platform: macOS - name: Upload uses: actions/upload-artifact@v4 @@ -54,6 +56,71 @@ jobs: name: macOS-universal path: macOS-universal + build-iOS: + name: Build iOS + runs-on: macos-12 + strategy: + matrix: + arch: [arm64, simulator-arm64, simulator-x86_64] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: ilammy/setup-nasm@v1 + if: matrix.arch == 'simulator-x86_64' + + - name: Setup gas-preprocessor + run: | + git clone --depth=1 https://github.com/FFmpeg/gas-preprocessor + echo "GAS_PREPROCESSOR=$PWD/gas-preprocessor/gas-preprocessor.pl" >> $GITHUB_ENV + + - name: Build + run: osu.Framework.NativeLibs/scripts/ffmpeg/build-iOS.sh + env: + arch: ${{ matrix.arch }} + + - name: Upload + uses: actions/upload-artifact@v4 + with: + name: iOS-${{ matrix.arch }} + path: iOS-${{ matrix.arch }} + + combine-iOS: + name: Combine iOS libs + runs-on: macos-12 + needs: build-iOS + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: iOS-simulator-x86_64 + path: iOS-simulator-x86_64 + - uses: actions/download-artifact@v4 + with: + name: iOS-simulator-arm64 + path: iOS-simulator-arm64 + - uses: actions/download-artifact@v4 + with: + name: iOS-arm64 + path: iOS-arm64 + + - name: Combine dylibs + run: osu.Framework.NativeLibs/scripts/ffmpeg/combine_dylibs.sh + env: + platform: iOS-simulator + + - name: Create XCFrameworks + run: osu.Framework.NativeLibs/scripts/ffmpeg/create-xcframeworks.sh + + - name: Upload + uses: actions/upload-artifact@v4 + with: + name: iOS-xcframework + path: iOS-xcframework + build-win: name: Build Windows runs-on: ubuntu-22.04 @@ -112,7 +179,7 @@ jobs: - name: Install dependencies run: | sudo apt-get update - sudo apt-get install nasm libva-dev libvdpau-dev + sudo apt-get install nasm - name: Checkout uses: actions/checkout@v4 @@ -126,14 +193,42 @@ jobs: name: linux-x64 path: linux-x64 + build-android: + name: Build Android + runs-on: ubuntu-22.04 + strategy: + matrix: + arch: + - armeabi-v7a + - arm64-v8a + - x86 + - x86_64 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build + run: osu.Framework.NativeLibs/scripts/ffmpeg/build-android.sh + env: + arch: ${{ matrix.arch }} + + - name: Upload + uses: actions/upload-artifact@v4 + with: + name: android-${{ matrix.arch }} + path: android-${{ matrix.arch }} + make-pr: name: Create pull request runs-on: ubuntu-22.04 needs: - combine-macos + - combine-iOS - build-win - build-win-arm64 - build-linux + - build-android steps: - name: Checkout uses: actions/checkout@v4 @@ -142,6 +237,10 @@ jobs: with: name: macOS-universal path: osu.Framework.NativeLibs/runtimes/osx/native + - uses: actions/download-artifact@v4 + with: + name: iOS-xcframework + path: osu.Framework.iOS/runtimes/ios/native - uses: actions/download-artifact@v4 with: name: linux-x64 @@ -158,6 +257,22 @@ jobs: with: name: win-x86 path: osu.Framework.NativeLibs/runtimes/win-x86/native + - uses: actions/download-artifact@v4 + with: + name: android-armeabi-v7a + path: osu.Framework.Android/armeabi-v7a + - uses: actions/download-artifact@v4 + with: + name: android-arm64-v8a + path: osu.Framework.Android/arm64-v8a + - uses: actions/download-artifact@v4 + with: + name: android-x86 + path: osu.Framework.Android/x86 + - uses: actions/download-artifact@v4 + with: + name: android-x86_64 + path: osu.Framework.Android/x86_64 - uses: peter-evans/create-pull-request@v6 with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a9cb7205a..5905850e62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,8 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 + with: + go-version: 1.21.9 - name: Install httpbin run: go install github.com/mccutchen/go-httpbin/v2/cmd/go-httpbin@latest @@ -88,13 +90,13 @@ jobs: run: dotnet build -c ${{matrix.os.configuration}} -warnaserror osu-framework.Desktop.slnf - name: Test - run: dotnet test $pwd/**/*.Tests/bin/${{matrix.os.configuration}}/*/*.Tests.dll --no-build --settings $pwd/build/vstestconfig.runsettings --logger "trx;LogFileName=TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}-${{matrix.os.configuration}}.trx" + run: dotnet test $pwd/**/*.Tests/bin/${{matrix.os.configuration}}/*/*.Tests.dll --no-build --logger "trx;LogFileName=TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}-${{matrix.os.configuration}}.trx" -- NUnit.ConsoleOut=0 shell: pwsh # Attempt to upload results even if test fails. # https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#always - name: Upload Test Results - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: ${{ always() }} with: name: osu-framework-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}}-${{matrix.os.configuration}} @@ -139,9 +141,8 @@ jobs: dotnet-version: "8.0.x" - name: Restore .NET workloads - # `dotnet workload restore` is bugged in .NET 7.0.101+ when restoring iOS projects, - # see https://github.com/xamarin/xamarin-macios/issues/16400. - run: dotnet workload install ios + run: dotnet workload install ios --from-rollback-file workloads.json - name: Compile run: dotnet build -c Debug osu-framework.iOS.slnf + diff --git a/.github/workflows/deploy-pack.yml b/.github/workflows/deploy-pack.yml index 9a7921226a..88bc62e7ac 100644 --- a/.github/workflows/deploy-pack.yml +++ b/.github/workflows/deploy-pack.yml @@ -166,9 +166,7 @@ jobs: dotnet-version: "8.0.x" - name: Restore .NET Workloads - # `dotnet workload restore` is bugged in .NET 7.0.101+ when restoring iOS projects, - # see https://github.com/xamarin/xamarin-macios/issues/16400. - run: dotnet workload install ios + run: dotnet workload install ios --from-rollback-file workloads.json - name: Pack (iOS Framework) run: dotnet pack -c Release osu.Framework.iOS /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true -o ${{steps.artifactsPath.outputs.nuget_artifacts}} diff --git a/.github/workflows/report-nunit.yml b/.github/workflows/report-nunit.yml index a26723d84f..476000a223 100644 --- a/.github/workflows/report-nunit.yml +++ b/.github/workflows/report-nunit.yml @@ -5,30 +5,40 @@ name: Annotate CI run with test results on: workflow_run: - workflows: ["Continuous Integration"] + workflows: [ "Continuous Integration" ] types: - completed + +permissions: + contents: read + actions: read + checks: write + jobs: annotate: name: Annotate CI run with test results runs-on: ubuntu-latest if: ${{ github.event.workflow_run.conclusion != 'cancelled' }} - strategy: - fail-fast: false - matrix: - os: - - { prettyname: Windows, configuration: Debug } - - { prettyname: macOS, configuration: Debug } - - { prettyname: Linux, configuration: Debug } - - { prettyname: Linux, configuration: Release } - threadingMode: ['SingleThread', 'MultiThreaded'] timeout-minutes: 5 steps: + - name: Checkout + uses: actions/checkout@v4 + with: + repository: ${{ github.event.workflow_run.repository.full_name }} + ref: ${{ github.event.workflow_run.head_sha }} + + - name: Download results + uses: actions/download-artifact@v4 + with: + pattern: osu-framework-test-results-* + merge-multiple: true + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + - name: Annotate CI run with test results uses: dorny/test-reporter@v1.8.0 with: - artifact: osu-framework-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}}-${{matrix.os.configuration}} - name: Test Results (${{matrix.os.prettyname}}, ${{matrix.threadingMode}}, ${{matrix.os.configuration}}) + name: Results path: "*.trx" reporter: dotnet-trx list-suites: 'failed' diff --git a/.gitignore b/.gitignore index d665f2912f..841e492d7d 100644 --- a/.gitignore +++ b/.gitignore @@ -308,12 +308,15 @@ fabric.properties # Reference: https://github.com/JetBrains/resharper-rider-samples/blob/master/.gitignore # User specific -**/.idea/**/workspace.xml -**/.idea/**/tasks.xml -**/.idea/shelf/* -**/.idea/dictionaries **/.idea/httpRequests/ **/.idea/**/usage.statistics.xml +**/.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf +.idea/*/.idea/projectSettingsUpdater.xml +.idea/*/.idea/encodings.xml # Sensitive or high-churn files **/.idea/**/dataSources/ @@ -339,3 +342,8 @@ inspectcode .idea/.idea.osu-framework.Desktop/.idea/misc.xml .idea/.idea.osu-framework.Android/.idea/deploymentTargetDropDown.xml + +# NativeLibs build folders and tarballs +osu.Framework.NativeLibs/scripts/ffmpeg/*/ +osu.Framework.NativeLibs/scripts/ffmpeg/*.tar.gz + diff --git a/.idea/.idea.osu-framework.Android/.idea/projectSettingsUpdater.xml b/.idea/.idea.osu-framework.Android/.idea/projectSettingsUpdater.xml deleted file mode 100644 index 4bb9f4d2a0..0000000000 --- a/.idea/.idea.osu-framework.Android/.idea/projectSettingsUpdater.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/.idea.osu-framework.Desktop/.idea/encodings.xml b/.idea/.idea.osu-framework.Desktop/.idea/encodings.xml deleted file mode 100644 index 15a15b218a..0000000000 --- a/.idea/.idea.osu-framework.Desktop/.idea/encodings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/.idea.osu-framework.Desktop/.idea/projectSettingsUpdater.xml b/.idea/.idea.osu-framework.Desktop/.idea/projectSettingsUpdater.xml deleted file mode 100644 index 4bb9f4d2a0..0000000000 --- a/.idea/.idea.osu-framework.Desktop/.idea/projectSettingsUpdater.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/.idea.osu-framework.iOS/.idea/projectSettingsUpdater.xml b/.idea/.idea.osu-framework.iOS/.idea/projectSettingsUpdater.xml deleted file mode 100644 index 4bb9f4d2a0..0000000000 --- a/.idea/.idea.osu-framework.iOS/.idea/projectSettingsUpdater.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/.idea.osu-framework/.idea/projectSettingsUpdater.xml b/.idea/.idea.osu-framework/.idea/projectSettingsUpdater.xml deleted file mode 100644 index 4bb9f4d2a0..0000000000 --- a/.idea/.idea.osu-framework/.idea/projectSettingsUpdater.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 1220a16e83..dc9e9161c4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -58,7 +58,7 @@ "${workspaceRoot}/SampleGame.Desktop/bin/Debug/net8.0/SampleGame.Desktop.dll", ], "cwd": "${workspaceRoot}", - "preLaunchTask": "Build (Debug)", + "preLaunchTask": "Build SampleGame (Debug)", "linux": { "env": { "LD_LIBRARY_PATH": "${workspaceRoot}/SampleGame.Desktop/bin/Debug/net8.0:${env:LD_LIBRARY_PATH}" @@ -75,7 +75,7 @@ "${workspaceRoot}/SampleGame.Desktop/bin/Release/net8.0/SampleGame.Desktop.dll", ], "cwd": "${workspaceRoot}", - "preLaunchTask": "Build (Release)", + "preLaunchTask": "Build SampleGame (Release)", "linux": { "env": { "LD_LIBRARY_PATH": "${workspaceRoot}/SampleGame.Desktop/bin/Release/net8.0:${env:LD_LIBRARY_PATH}" diff --git a/README.md b/README.md index f2d45c7a5d..b392ce6289 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,9 @@ This framework is intended to take steps beyond what you would normally expect f ## Requirements -- A desktop platform with the [.NET 6.0 SDK](https://dotnet.microsoft.com/download). +- A desktop platform with the [.NET 8.0 SDK](https://dotnet.microsoft.com/download). - When running on linux, please have a system-wide ffmpeg installation available to support video decoding. -- When running on Windows 7 or 8.1, *[additional prerequisites](https://docs.microsoft.com/en-us/dotnet/core/install/windows?tabs=net60&pivots=os-windows#dependencies)** may be required to correctly run .NET 6 applications if your operating system is not up-to-date with the latest service packs. +- When running on Windows 7 or 8.1, *[additional prerequisites](https://docs.microsoft.com/en-us/dotnet/core/install/windows?tabs=net60&pivots=os-windows#dependencies)** may be required to correctly run .NET 8 applications if your operating system is not up-to-date with the latest service packs. - When working with the codebase, we recommend using an IDE with intellisense and syntax highlighting, such as [Visual Studio 2019+](https://visualstudio.microsoft.com/vs/), [Jetbrains Rider](https://www.jetbrains.com/rider/), or [Visual Studio Code](https://code.visualstudio.com/) with the [EditorConfig](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) and [C#](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp) plugin installed. ### Building diff --git a/build/vstestconfig.runsettings b/build/vstestconfig.runsettings deleted file mode 100644 index f7ca0caa90..0000000000 --- a/build/vstestconfig.runsettings +++ /dev/null @@ -1,6 +0,0 @@ - - - - 300000 - - \ No newline at end of file diff --git a/osu-framework.iOS.slnf b/osu-framework.iOS.slnf index 30287a2443..4826b8adf2 100644 --- a/osu-framework.iOS.slnf +++ b/osu-framework.iOS.slnf @@ -16,4 +16,4 @@ "SampleGame\\SampleGame.csproj" ] } -} +} \ No newline at end of file diff --git a/osu-framework.sln b/osu-framework.sln index 3a3d02ae48..155884055c 100644 --- a/osu-framework.sln +++ b/osu-framework.sln @@ -80,297 +80,113 @@ EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU - Debug|iPhone = Debug|iPhone - Debug|iPhoneSimulator = Debug|iPhoneSimulator Release|Any CPU = Release|Any CPU - Release|iPhone = Release|iPhone - Release|iPhoneSimulator = Release|iPhoneSimulator EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {C76BF5B3-985E-4D39-95FE-97C9C879B83A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C76BF5B3-985E-4D39-95FE-97C9C879B83A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C76BF5B3-985E-4D39-95FE-97C9C879B83A}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {C76BF5B3-985E-4D39-95FE-97C9C879B83A}.Debug|iPhone.Build.0 = Debug|Any CPU - {C76BF5B3-985E-4D39-95FE-97C9C879B83A}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {C76BF5B3-985E-4D39-95FE-97C9C879B83A}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {C76BF5B3-985E-4D39-95FE-97C9C879B83A}.Release|Any CPU.ActiveCfg = Release|Any CPU {C76BF5B3-985E-4D39-95FE-97C9C879B83A}.Release|Any CPU.Build.0 = Release|Any CPU - {C76BF5B3-985E-4D39-95FE-97C9C879B83A}.Release|iPhone.ActiveCfg = Release|Any CPU - {C76BF5B3-985E-4D39-95FE-97C9C879B83A}.Release|iPhone.Build.0 = Release|Any CPU - {C76BF5B3-985E-4D39-95FE-97C9C879B83A}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {C76BF5B3-985E-4D39-95FE-97C9C879B83A}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|iPhone.Build.0 = Debug|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|Any CPU.ActiveCfg = Release|Any CPU {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|Any CPU.Build.0 = Release|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|iPhone.ActiveCfg = Release|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|iPhone.Build.0 = Release|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {79803407-6F50-484F-93F5-641911EABD8A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {79803407-6F50-484F-93F5-641911EABD8A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {79803407-6F50-484F-93F5-641911EABD8A}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {79803407-6F50-484F-93F5-641911EABD8A}.Debug|iPhone.Build.0 = Debug|Any CPU - {79803407-6F50-484F-93F5-641911EABD8A}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {79803407-6F50-484F-93F5-641911EABD8A}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {79803407-6F50-484F-93F5-641911EABD8A}.Release|Any CPU.ActiveCfg = Release|Any CPU {79803407-6F50-484F-93F5-641911EABD8A}.Release|Any CPU.Build.0 = Release|Any CPU - {79803407-6F50-484F-93F5-641911EABD8A}.Release|iPhone.ActiveCfg = Release|Any CPU - {79803407-6F50-484F-93F5-641911EABD8A}.Release|iPhone.Build.0 = Release|Any CPU - {79803407-6F50-484F-93F5-641911EABD8A}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {79803407-6F50-484F-93F5-641911EABD8A}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {2AD6EA6F-CD5A-4348-86F1-5E228B11617D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2AD6EA6F-CD5A-4348-86F1-5E228B11617D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2AD6EA6F-CD5A-4348-86F1-5E228B11617D}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {2AD6EA6F-CD5A-4348-86F1-5E228B11617D}.Debug|iPhone.Build.0 = Debug|Any CPU - {2AD6EA6F-CD5A-4348-86F1-5E228B11617D}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {2AD6EA6F-CD5A-4348-86F1-5E228B11617D}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {2AD6EA6F-CD5A-4348-86F1-5E228B11617D}.Release|Any CPU.ActiveCfg = Release|Any CPU {2AD6EA6F-CD5A-4348-86F1-5E228B11617D}.Release|Any CPU.Build.0 = Release|Any CPU - {2AD6EA6F-CD5A-4348-86F1-5E228B11617D}.Release|iPhone.ActiveCfg = Release|Any CPU - {2AD6EA6F-CD5A-4348-86F1-5E228B11617D}.Release|iPhone.Build.0 = Release|Any CPU - {2AD6EA6F-CD5A-4348-86F1-5E228B11617D}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {2AD6EA6F-CD5A-4348-86F1-5E228B11617D}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {F853B4BB-CB83-4169-8FD2-72EEB4A88C32}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F853B4BB-CB83-4169-8FD2-72EEB4A88C32}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F853B4BB-CB83-4169-8FD2-72EEB4A88C32}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {F853B4BB-CB83-4169-8FD2-72EEB4A88C32}.Debug|iPhone.Build.0 = Debug|Any CPU - {F853B4BB-CB83-4169-8FD2-72EEB4A88C32}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {F853B4BB-CB83-4169-8FD2-72EEB4A88C32}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {F853B4BB-CB83-4169-8FD2-72EEB4A88C32}.Release|Any CPU.ActiveCfg = Release|Any CPU {F853B4BB-CB83-4169-8FD2-72EEB4A88C32}.Release|Any CPU.Build.0 = Release|Any CPU - {F853B4BB-CB83-4169-8FD2-72EEB4A88C32}.Release|iPhone.ActiveCfg = Release|Any CPU - {F853B4BB-CB83-4169-8FD2-72EEB4A88C32}.Release|iPhone.Build.0 = Release|Any CPU - {F853B4BB-CB83-4169-8FD2-72EEB4A88C32}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {F853B4BB-CB83-4169-8FD2-72EEB4A88C32}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {BBC0D18F-8595-43A6-AE61-5BF36A072CCE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BBC0D18F-8595-43A6-AE61-5BF36A072CCE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BBC0D18F-8595-43A6-AE61-5BF36A072CCE}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {BBC0D18F-8595-43A6-AE61-5BF36A072CCE}.Debug|iPhone.Build.0 = Debug|Any CPU - {BBC0D18F-8595-43A6-AE61-5BF36A072CCE}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {BBC0D18F-8595-43A6-AE61-5BF36A072CCE}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {BBC0D18F-8595-43A6-AE61-5BF36A072CCE}.Release|Any CPU.ActiveCfg = Release|Any CPU {BBC0D18F-8595-43A6-AE61-5BF36A072CCE}.Release|Any CPU.Build.0 = Release|Any CPU - {BBC0D18F-8595-43A6-AE61-5BF36A072CCE}.Release|iPhone.ActiveCfg = Release|Any CPU - {BBC0D18F-8595-43A6-AE61-5BF36A072CCE}.Release|iPhone.Build.0 = Release|Any CPU - {BBC0D18F-8595-43A6-AE61-5BF36A072CCE}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {BBC0D18F-8595-43A6-AE61-5BF36A072CCE}.Release|iPhoneSimulator.Build.0 = Release|Any CPU - {529D5E2E-774A-4831-9C9E-59E3E8DFF155}.Debug|Any CPU.ActiveCfg = Debug|iPhoneSimulator - {529D5E2E-774A-4831-9C9E-59E3E8DFF155}.Debug|iPhone.ActiveCfg = Debug|iPhone - {529D5E2E-774A-4831-9C9E-59E3E8DFF155}.Debug|iPhone.Build.0 = Debug|iPhone - {529D5E2E-774A-4831-9C9E-59E3E8DFF155}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator - {529D5E2E-774A-4831-9C9E-59E3E8DFF155}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator - {529D5E2E-774A-4831-9C9E-59E3E8DFF155}.Release|Any CPU.ActiveCfg = Release|iPhoneSimulator - {529D5E2E-774A-4831-9C9E-59E3E8DFF155}.Release|iPhone.ActiveCfg = Release|iPhone - {529D5E2E-774A-4831-9C9E-59E3E8DFF155}.Release|iPhone.Build.0 = Release|iPhone - {529D5E2E-774A-4831-9C9E-59E3E8DFF155}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator - {529D5E2E-774A-4831-9C9E-59E3E8DFF155}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator - {D972753E-45FC-4B82-B017-34BDE485F1BB}.Debug|Any CPU.ActiveCfg = Debug|iPhoneSimulator - {D972753E-45FC-4B82-B017-34BDE485F1BB}.Debug|iPhone.ActiveCfg = Debug|iPhone - {D972753E-45FC-4B82-B017-34BDE485F1BB}.Debug|iPhone.Build.0 = Debug|iPhone - {D972753E-45FC-4B82-B017-34BDE485F1BB}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator - {D972753E-45FC-4B82-B017-34BDE485F1BB}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator - {D972753E-45FC-4B82-B017-34BDE485F1BB}.Release|Any CPU.ActiveCfg = Release|iPhoneSimulator - {D972753E-45FC-4B82-B017-34BDE485F1BB}.Release|iPhone.ActiveCfg = Release|iPhone - {D972753E-45FC-4B82-B017-34BDE485F1BB}.Release|iPhone.Build.0 = Release|iPhone - {D972753E-45FC-4B82-B017-34BDE485F1BB}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator - {D972753E-45FC-4B82-B017-34BDE485F1BB}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator + {529D5E2E-774A-4831-9C9E-59E3E8DFF155}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {529D5E2E-774A-4831-9C9E-59E3E8DFF155}.Debug|Any CPU.Build.0 = Debug|Any CPU + {529D5E2E-774A-4831-9C9E-59E3E8DFF155}.Release|Any CPU.ActiveCfg = Release|Any CPU + {529D5E2E-774A-4831-9C9E-59E3E8DFF155}.Release|Any CPU.Build.0 = Release|Any CPU + {D972753E-45FC-4B82-B017-34BDE485F1BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D972753E-45FC-4B82-B017-34BDE485F1BB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D972753E-45FC-4B82-B017-34BDE485F1BB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D972753E-45FC-4B82-B017-34BDE485F1BB}.Release|Any CPU.Build.0 = Release|Any CPU {4D112E30-462B-4264-B44D-53B61ABB185E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4D112E30-462B-4264-B44D-53B61ABB185E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4D112E30-462B-4264-B44D-53B61ABB185E}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {4D112E30-462B-4264-B44D-53B61ABB185E}.Debug|iPhone.Build.0 = Debug|Any CPU - {4D112E30-462B-4264-B44D-53B61ABB185E}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {4D112E30-462B-4264-B44D-53B61ABB185E}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {4D112E30-462B-4264-B44D-53B61ABB185E}.Release|Any CPU.ActiveCfg = Release|Any CPU {4D112E30-462B-4264-B44D-53B61ABB185E}.Release|Any CPU.Build.0 = Release|Any CPU - {4D112E30-462B-4264-B44D-53B61ABB185E}.Release|iPhone.ActiveCfg = Release|Any CPU - {4D112E30-462B-4264-B44D-53B61ABB185E}.Release|iPhone.Build.0 = Release|Any CPU - {4D112E30-462B-4264-B44D-53B61ABB185E}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {4D112E30-462B-4264-B44D-53B61ABB185E}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {5A378BB7-11D6-4008-980E-A67507CD9969}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5A378BB7-11D6-4008-980E-A67507CD9969}.Debug|Any CPU.Build.0 = Debug|Any CPU {5A378BB7-11D6-4008-980E-A67507CD9969}.Debug|Any CPU.Deploy.0 = Debug|Any CPU - {5A378BB7-11D6-4008-980E-A67507CD9969}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {5A378BB7-11D6-4008-980E-A67507CD9969}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU {5A378BB7-11D6-4008-980E-A67507CD9969}.Release|Any CPU.ActiveCfg = Release|Any CPU {5A378BB7-11D6-4008-980E-A67507CD9969}.Release|Any CPU.Build.0 = Release|Any CPU {5A378BB7-11D6-4008-980E-A67507CD9969}.Release|Any CPU.Deploy.0 = Release|Any CPU - {5A378BB7-11D6-4008-980E-A67507CD9969}.Release|iPhone.ActiveCfg = Release|Any CPU - {5A378BB7-11D6-4008-980E-A67507CD9969}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU {320089C6-A141-4D3E-BD5F-C4A6CE9E567B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {320089C6-A141-4D3E-BD5F-C4A6CE9E567B}.Debug|Any CPU.Build.0 = Debug|Any CPU {320089C6-A141-4D3E-BD5F-C4A6CE9E567B}.Debug|Any CPU.Deploy.0 = Debug|Any CPU - {320089C6-A141-4D3E-BD5F-C4A6CE9E567B}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {320089C6-A141-4D3E-BD5F-C4A6CE9E567B}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU {320089C6-A141-4D3E-BD5F-C4A6CE9E567B}.Release|Any CPU.ActiveCfg = Release|Any CPU {320089C6-A141-4D3E-BD5F-C4A6CE9E567B}.Release|Any CPU.Build.0 = Release|Any CPU {320089C6-A141-4D3E-BD5F-C4A6CE9E567B}.Release|Any CPU.Deploy.0 = Release|Any CPU - {320089C6-A141-4D3E-BD5F-C4A6CE9E567B}.Release|iPhone.ActiveCfg = Release|Any CPU - {320089C6-A141-4D3E-BD5F-C4A6CE9E567B}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU {F294C804-8AE2-4020-841A-AF0D97FBE80C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F294C804-8AE2-4020-841A-AF0D97FBE80C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F294C804-8AE2-4020-841A-AF0D97FBE80C}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {F294C804-8AE2-4020-841A-AF0D97FBE80C}.Debug|iPhone.Build.0 = Debug|Any CPU - {F294C804-8AE2-4020-841A-AF0D97FBE80C}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {F294C804-8AE2-4020-841A-AF0D97FBE80C}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {F294C804-8AE2-4020-841A-AF0D97FBE80C}.Release|Any CPU.ActiveCfg = Release|Any CPU {F294C804-8AE2-4020-841A-AF0D97FBE80C}.Release|Any CPU.Build.0 = Release|Any CPU - {F294C804-8AE2-4020-841A-AF0D97FBE80C}.Release|iPhone.ActiveCfg = Release|Any CPU - {F294C804-8AE2-4020-841A-AF0D97FBE80C}.Release|iPhone.Build.0 = Release|Any CPU - {F294C804-8AE2-4020-841A-AF0D97FBE80C}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {F294C804-8AE2-4020-841A-AF0D97FBE80C}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {A95175BB-95D0-44B4-8B82-EABD166943DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A95175BB-95D0-44B4-8B82-EABD166943DA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A95175BB-95D0-44B4-8B82-EABD166943DA}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {A95175BB-95D0-44B4-8B82-EABD166943DA}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU {A95175BB-95D0-44B4-8B82-EABD166943DA}.Release|Any CPU.ActiveCfg = Release|Any CPU {A95175BB-95D0-44B4-8B82-EABD166943DA}.Release|Any CPU.Build.0 = Release|Any CPU - {A95175BB-95D0-44B4-8B82-EABD166943DA}.Release|iPhone.ActiveCfg = Release|Any CPU - {A95175BB-95D0-44B4-8B82-EABD166943DA}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {7AA1DB5D-78DB-4693-AE50-D6078F5A0CAB}.Debug|Any CPU.ActiveCfg = Debug|iPhoneSimulator - {7AA1DB5D-78DB-4693-AE50-D6078F5A0CAB}.Debug|iPhone.ActiveCfg = Debug|iPhone - {7AA1DB5D-78DB-4693-AE50-D6078F5A0CAB}.Debug|iPhone.Build.0 = Debug|iPhone - {7AA1DB5D-78DB-4693-AE50-D6078F5A0CAB}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator - {7AA1DB5D-78DB-4693-AE50-D6078F5A0CAB}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator - {7AA1DB5D-78DB-4693-AE50-D6078F5A0CAB}.Release|Any CPU.ActiveCfg = Release|iPhoneSimulator - {7AA1DB5D-78DB-4693-AE50-D6078F5A0CAB}.Release|iPhone.ActiveCfg = Release|iPhone - {7AA1DB5D-78DB-4693-AE50-D6078F5A0CAB}.Release|iPhone.Build.0 = Release|iPhone - {7AA1DB5D-78DB-4693-AE50-D6078F5A0CAB}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator - {7AA1DB5D-78DB-4693-AE50-D6078F5A0CAB}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator - {48783186-230D-4048-A97A-E4F1DF43BF5C}.Debug|Any CPU.ActiveCfg = Debug|iPhoneSimulator - {48783186-230D-4048-A97A-E4F1DF43BF5C}.Debug|iPhone.ActiveCfg = Debug|iPhone - {48783186-230D-4048-A97A-E4F1DF43BF5C}.Debug|iPhone.Build.0 = Debug|iPhone - {48783186-230D-4048-A97A-E4F1DF43BF5C}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator - {48783186-230D-4048-A97A-E4F1DF43BF5C}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator - {48783186-230D-4048-A97A-E4F1DF43BF5C}.Release|Any CPU.ActiveCfg = Release|iPhoneSimulator - {48783186-230D-4048-A97A-E4F1DF43BF5C}.Release|iPhone.ActiveCfg = Release|iPhone - {48783186-230D-4048-A97A-E4F1DF43BF5C}.Release|iPhone.Build.0 = Release|iPhone - {48783186-230D-4048-A97A-E4F1DF43BF5C}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator - {48783186-230D-4048-A97A-E4F1DF43BF5C}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator + {7AA1DB5D-78DB-4693-AE50-D6078F5A0CAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7AA1DB5D-78DB-4693-AE50-D6078F5A0CAB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7AA1DB5D-78DB-4693-AE50-D6078F5A0CAB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7AA1DB5D-78DB-4693-AE50-D6078F5A0CAB}.Release|Any CPU.Build.0 = Release|Any CPU + {48783186-230D-4048-A97A-E4F1DF43BF5C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {48783186-230D-4048-A97A-E4F1DF43BF5C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {48783186-230D-4048-A97A-E4F1DF43BF5C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {48783186-230D-4048-A97A-E4F1DF43BF5C}.Release|Any CPU.Build.0 = Release|Any CPU {6BEB95A6-0673-4AF5-892E-9146FF8B0948}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6BEB95A6-0673-4AF5-892E-9146FF8B0948}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6BEB95A6-0673-4AF5-892E-9146FF8B0948}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {6BEB95A6-0673-4AF5-892E-9146FF8B0948}.Debug|iPhone.Build.0 = Debug|Any CPU - {6BEB95A6-0673-4AF5-892E-9146FF8B0948}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {6BEB95A6-0673-4AF5-892E-9146FF8B0948}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {6BEB95A6-0673-4AF5-892E-9146FF8B0948}.Release|Any CPU.ActiveCfg = Release|Any CPU {6BEB95A6-0673-4AF5-892E-9146FF8B0948}.Release|Any CPU.Build.0 = Release|Any CPU - {6BEB95A6-0673-4AF5-892E-9146FF8B0948}.Release|iPhone.ActiveCfg = Release|Any CPU - {6BEB95A6-0673-4AF5-892E-9146FF8B0948}.Release|iPhone.Build.0 = Release|Any CPU - {6BEB95A6-0673-4AF5-892E-9146FF8B0948}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {6BEB95A6-0673-4AF5-892E-9146FF8B0948}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {6E3EBF71-8664-49D7-BD0D-2B21B3EEC540}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6E3EBF71-8664-49D7-BD0D-2B21B3EEC540}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6E3EBF71-8664-49D7-BD0D-2B21B3EEC540}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {6E3EBF71-8664-49D7-BD0D-2B21B3EEC540}.Debug|iPhone.Build.0 = Debug|Any CPU - {6E3EBF71-8664-49D7-BD0D-2B21B3EEC540}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {6E3EBF71-8664-49D7-BD0D-2B21B3EEC540}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {6E3EBF71-8664-49D7-BD0D-2B21B3EEC540}.Release|Any CPU.ActiveCfg = Release|Any CPU {6E3EBF71-8664-49D7-BD0D-2B21B3EEC540}.Release|Any CPU.Build.0 = Release|Any CPU - {6E3EBF71-8664-49D7-BD0D-2B21B3EEC540}.Release|iPhone.ActiveCfg = Release|Any CPU - {6E3EBF71-8664-49D7-BD0D-2B21B3EEC540}.Release|iPhone.Build.0 = Release|Any CPU - {6E3EBF71-8664-49D7-BD0D-2B21B3EEC540}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {6E3EBF71-8664-49D7-BD0D-2B21B3EEC540}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {55AB973D-ECA0-422B-B367-24BC47DA081B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {55AB973D-ECA0-422B-B367-24BC47DA081B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {55AB973D-ECA0-422B-B367-24BC47DA081B}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {55AB973D-ECA0-422B-B367-24BC47DA081B}.Debug|iPhone.Build.0 = Debug|Any CPU - {55AB973D-ECA0-422B-B367-24BC47DA081B}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {55AB973D-ECA0-422B-B367-24BC47DA081B}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {55AB973D-ECA0-422B-B367-24BC47DA081B}.Release|Any CPU.ActiveCfg = Release|Any CPU {55AB973D-ECA0-422B-B367-24BC47DA081B}.Release|Any CPU.Build.0 = Release|Any CPU - {55AB973D-ECA0-422B-B367-24BC47DA081B}.Release|iPhone.ActiveCfg = Release|Any CPU - {55AB973D-ECA0-422B-B367-24BC47DA081B}.Release|iPhone.Build.0 = Release|Any CPU - {55AB973D-ECA0-422B-B367-24BC47DA081B}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {55AB973D-ECA0-422B-B367-24BC47DA081B}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {FC0A9040-7BAF-4524-B398-8C7A1C7DDEE9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FC0A9040-7BAF-4524-B398-8C7A1C7DDEE9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FC0A9040-7BAF-4524-B398-8C7A1C7DDEE9}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {FC0A9040-7BAF-4524-B398-8C7A1C7DDEE9}.Debug|iPhone.Build.0 = Debug|Any CPU - {FC0A9040-7BAF-4524-B398-8C7A1C7DDEE9}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {FC0A9040-7BAF-4524-B398-8C7A1C7DDEE9}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {FC0A9040-7BAF-4524-B398-8C7A1C7DDEE9}.Release|Any CPU.ActiveCfg = Release|Any CPU {FC0A9040-7BAF-4524-B398-8C7A1C7DDEE9}.Release|Any CPU.Build.0 = Release|Any CPU - {FC0A9040-7BAF-4524-B398-8C7A1C7DDEE9}.Release|iPhone.ActiveCfg = Release|Any CPU - {FC0A9040-7BAF-4524-B398-8C7A1C7DDEE9}.Release|iPhone.Build.0 = Release|Any CPU - {FC0A9040-7BAF-4524-B398-8C7A1C7DDEE9}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {FC0A9040-7BAF-4524-B398-8C7A1C7DDEE9}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {7809CB42-8FED-4BB7-8C68-7638357B94A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7809CB42-8FED-4BB7-8C68-7638357B94A6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7809CB42-8FED-4BB7-8C68-7638357B94A6}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {7809CB42-8FED-4BB7-8C68-7638357B94A6}.Debug|iPhone.Build.0 = Debug|Any CPU - {7809CB42-8FED-4BB7-8C68-7638357B94A6}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {7809CB42-8FED-4BB7-8C68-7638357B94A6}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {7809CB42-8FED-4BB7-8C68-7638357B94A6}.Release|Any CPU.ActiveCfg = Release|Any CPU {7809CB42-8FED-4BB7-8C68-7638357B94A6}.Release|Any CPU.Build.0 = Release|Any CPU - {7809CB42-8FED-4BB7-8C68-7638357B94A6}.Release|iPhone.ActiveCfg = Release|Any CPU - {7809CB42-8FED-4BB7-8C68-7638357B94A6}.Release|iPhone.Build.0 = Release|Any CPU - {7809CB42-8FED-4BB7-8C68-7638357B94A6}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {7809CB42-8FED-4BB7-8C68-7638357B94A6}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {4C728441-4C3D-4AE4-9C0F-EA91E2C70965}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4C728441-4C3D-4AE4-9C0F-EA91E2C70965}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4C728441-4C3D-4AE4-9C0F-EA91E2C70965}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {4C728441-4C3D-4AE4-9C0F-EA91E2C70965}.Debug|iPhone.Build.0 = Debug|Any CPU - {4C728441-4C3D-4AE4-9C0F-EA91E2C70965}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {4C728441-4C3D-4AE4-9C0F-EA91E2C70965}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {4C728441-4C3D-4AE4-9C0F-EA91E2C70965}.Release|Any CPU.ActiveCfg = Release|Any CPU {4C728441-4C3D-4AE4-9C0F-EA91E2C70965}.Release|Any CPU.Build.0 = Release|Any CPU - {4C728441-4C3D-4AE4-9C0F-EA91E2C70965}.Release|iPhone.ActiveCfg = Release|Any CPU - {4C728441-4C3D-4AE4-9C0F-EA91E2C70965}.Release|iPhone.Build.0 = Release|Any CPU - {4C728441-4C3D-4AE4-9C0F-EA91E2C70965}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {4C728441-4C3D-4AE4-9C0F-EA91E2C70965}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {0309CF11-621A-4F23-8FBA-A583303A8531}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0309CF11-621A-4F23-8FBA-A583303A8531}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0309CF11-621A-4F23-8FBA-A583303A8531}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {0309CF11-621A-4F23-8FBA-A583303A8531}.Debug|iPhone.Build.0 = Debug|Any CPU - {0309CF11-621A-4F23-8FBA-A583303A8531}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {0309CF11-621A-4F23-8FBA-A583303A8531}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {0309CF11-621A-4F23-8FBA-A583303A8531}.Release|Any CPU.ActiveCfg = Release|Any CPU {0309CF11-621A-4F23-8FBA-A583303A8531}.Release|Any CPU.Build.0 = Release|Any CPU - {0309CF11-621A-4F23-8FBA-A583303A8531}.Release|iPhone.ActiveCfg = Release|Any CPU - {0309CF11-621A-4F23-8FBA-A583303A8531}.Release|iPhone.Build.0 = Release|Any CPU - {0309CF11-621A-4F23-8FBA-A583303A8531}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {0309CF11-621A-4F23-8FBA-A583303A8531}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {9E4B69EE-34E6-47CF-8346-2A66D1714FCD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9E4B69EE-34E6-47CF-8346-2A66D1714FCD}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9E4B69EE-34E6-47CF-8346-2A66D1714FCD}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {9E4B69EE-34E6-47CF-8346-2A66D1714FCD}.Debug|iPhone.Build.0 = Debug|Any CPU - {9E4B69EE-34E6-47CF-8346-2A66D1714FCD}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {9E4B69EE-34E6-47CF-8346-2A66D1714FCD}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {9E4B69EE-34E6-47CF-8346-2A66D1714FCD}.Release|Any CPU.ActiveCfg = Release|Any CPU {9E4B69EE-34E6-47CF-8346-2A66D1714FCD}.Release|Any CPU.Build.0 = Release|Any CPU - {9E4B69EE-34E6-47CF-8346-2A66D1714FCD}.Release|iPhone.ActiveCfg = Release|Any CPU - {9E4B69EE-34E6-47CF-8346-2A66D1714FCD}.Release|iPhone.Build.0 = Release|Any CPU - {9E4B69EE-34E6-47CF-8346-2A66D1714FCD}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {9E4B69EE-34E6-47CF-8346-2A66D1714FCD}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {BCFABDF9-AC1B-41B6-959E-04676F0C20F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BCFABDF9-AC1B-41B6-959E-04676F0C20F8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BCFABDF9-AC1B-41B6-959E-04676F0C20F8}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {BCFABDF9-AC1B-41B6-959E-04676F0C20F8}.Debug|iPhone.Build.0 = Debug|Any CPU - {BCFABDF9-AC1B-41B6-959E-04676F0C20F8}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {BCFABDF9-AC1B-41B6-959E-04676F0C20F8}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {BCFABDF9-AC1B-41B6-959E-04676F0C20F8}.Release|Any CPU.ActiveCfg = Release|Any CPU {BCFABDF9-AC1B-41B6-959E-04676F0C20F8}.Release|Any CPU.Build.0 = Release|Any CPU - {BCFABDF9-AC1B-41B6-959E-04676F0C20F8}.Release|iPhone.ActiveCfg = Release|Any CPU - {BCFABDF9-AC1B-41B6-959E-04676F0C20F8}.Release|iPhone.Build.0 = Release|Any CPU - {BCFABDF9-AC1B-41B6-959E-04676F0C20F8}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {BCFABDF9-AC1B-41B6-959E-04676F0C20F8}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {A0DDCF0A-A352-4CC6-8E6E-0E16CAA50CDD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A0DDCF0A-A352-4CC6-8E6E-0E16CAA50CDD}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A0DDCF0A-A352-4CC6-8E6E-0E16CAA50CDD}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {A0DDCF0A-A352-4CC6-8E6E-0E16CAA50CDD}.Debug|iPhone.Build.0 = Debug|Any CPU - {A0DDCF0A-A352-4CC6-8E6E-0E16CAA50CDD}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {A0DDCF0A-A352-4CC6-8E6E-0E16CAA50CDD}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {A0DDCF0A-A352-4CC6-8E6E-0E16CAA50CDD}.Release|Any CPU.ActiveCfg = Release|Any CPU {A0DDCF0A-A352-4CC6-8E6E-0E16CAA50CDD}.Release|Any CPU.Build.0 = Release|Any CPU - {A0DDCF0A-A352-4CC6-8E6E-0E16CAA50CDD}.Release|iPhone.ActiveCfg = Release|Any CPU - {A0DDCF0A-A352-4CC6-8E6E-0E16CAA50CDD}.Release|iPhone.Build.0 = Release|Any CPU - {A0DDCF0A-A352-4CC6-8E6E-0E16CAA50CDD}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {A0DDCF0A-A352-4CC6-8E6E-0E16CAA50CDD}.Release|iPhoneSimulator.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/osu-framework.sln.DotSettings b/osu-framework.sln.DotSettings index 49383b498e..3f68824f12 100644 --- a/osu-framework.sln.DotSettings +++ b/osu-framework.sln.DotSettings @@ -20,6 +20,7 @@ WARNING WARNING True + DO_NOT_SHOW WARNING WARNING HINT @@ -848,6 +849,7 @@ See the LICENCE file in the repository root for full licence text. True True True + True True True True @@ -999,4 +1001,5 @@ private void load() True True True - True + True + True diff --git a/osu.Framework.Android/AndroidClipboard.cs b/osu.Framework.Android/AndroidClipboard.cs deleted file mode 100644 index c6f3151340..0000000000 --- a/osu.Framework.Android/AndroidClipboard.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using Android.Content; -using osu.Framework.Platform; -using SixLabors.ImageSharp; - -namespace osu.Framework.Android -{ - public class AndroidClipboard : Clipboard - { - private readonly ClipboardManager? clipboardManager; - - public AndroidClipboard(AndroidGameView view) - { - clipboardManager = view.Activity.GetSystemService(Context.ClipboardService) as ClipboardManager; - } - - public override string? GetText() => clipboardManager?.PrimaryClip?.GetItemAt(0)?.Text; - - public override void SetText(string text) - { - if (clipboardManager != null) - clipboardManager.PrimaryClip = ClipData.NewPlainText(null, text); - } - - public override Image? GetImage() => null; - - public override bool SetImage(Image image) => false; - } -} diff --git a/osu.Framework.Android/AndroidGameActivity.cs b/osu.Framework.Android/AndroidGameActivity.cs index 57130be1e4..41c69e69b4 100644 --- a/osu.Framework.Android/AndroidGameActivity.cs +++ b/osu.Framework.Android/AndroidGameActivity.cs @@ -1,15 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using Android.App; using Android.Content; using Android.Content.PM; using Android.OS; -using Android.Runtime; -using Android.Views; using ManagedBass; -using osu.Framework.Bindables; +using Org.Libsdl.App; using osu.Framework.Extensions.ObjectExtensions; using Debug = System.Diagnostics.Debug; @@ -17,7 +14,7 @@ namespace osu.Framework.Android { // since `ActivityAttribute` can't be inherited, the below is only provided as an illustrative example of how to setup an activity for best compatibility. [Activity(ConfigurationChanges = DEFAULT_CONFIG_CHANGES, Exported = true, LaunchMode = DEFAULT_LAUNCH_MODE, MainLauncher = true)] - public abstract class AndroidGameActivity : Activity + public abstract class AndroidGameActivity : SDLActivity { protected const ConfigChanges DEFAULT_CONFIG_CHANGES = ConfigChanges.Keyboard | ConfigChanges.KeyboardHidden @@ -31,36 +28,18 @@ public abstract class AndroidGameActivity : Activity protected const LaunchMode DEFAULT_LAUNCH_MODE = LaunchMode.SingleInstance; - protected abstract Game CreateGame(); - - /// - /// Whether this is active (in the foreground). - /// - public BindableBool IsActive { get; } = new BindableBool(); + internal static AndroidGameSurface Surface => (AndroidGameSurface)MSurface!; - /// - /// The visibility flags for the system UI (status and navigation bars) - /// - public SystemUiFlags UIVisibilityFlags - { -#pragma warning disable 618 // SystemUiVisibility is deprecated - get => (SystemUiFlags)Window.AsNonNull().DecorView.SystemUiVisibility; - set - { - systemUiFlags = value; - Window.AsNonNull().DecorView.SystemUiVisibility = (StatusBarVisibility)value; -#pragma warning restore 618 - } - } + protected abstract Game CreateGame(); - private SystemUiFlags systemUiFlags; + protected override string[] GetLibraries() => new string[] { "SDL3" }; - private AndroidGameView gameView = null!; + protected override SDLSurface CreateSDLSurface(Context? context) => new AndroidGameSurface(this, context); - public override void OnTrimMemory([GeneratedEnum] TrimMemory level) + protected override void Main() { - base.OnTrimMemory(level); - gameView.Host?.Collect(); + var host = new AndroidGameHost(this); + host.Run(CreateGame()); } protected override void OnCreate(Bundle? savedInstanceState) @@ -74,83 +53,18 @@ protected override void OnCreate(Bundle? savedInstanceState) System.Environment.CurrentDirectory = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile); base.OnCreate(savedInstanceState); - - SetContentView(gameView = new AndroidGameView(this, CreateGame())); - - UIVisibilityFlags = SystemUiFlags.LayoutFlags | SystemUiFlags.ImmersiveSticky | SystemUiFlags.HideNavigation | SystemUiFlags.Fullscreen; - - // Firing up the on-screen keyboard (eg: interacting with textboxes) may cause the UI visibility flags to be altered thus showing the navigation bar and potentially the status bar - // This sets back the UI flags to hidden once the interaction with the on-screen keyboard has finished. - Window.AsNonNull().DecorView.SystemUiVisibilityChange += (_, e) => - { - if ((SystemUiFlags)e.Visibility != systemUiFlags) - { - UIVisibilityFlags = systemUiFlags; - } - }; - - if (OperatingSystem.IsAndroidVersionAtLeast(28)) - { - Window.AsNonNull().Attributes.AsNonNull().LayoutInDisplayCutoutMode = LayoutInDisplayCutoutMode.ShortEdges; - } - - gameView.HostStarted += host => - { - host.AllowScreenSuspension.Result.BindValueChanged(allow => - { - RunOnUiThread(() => - { - if (!allow.NewValue) - Window?.AddFlags(WindowManagerFlags.KeepScreenOn); - else - Window?.ClearFlags(WindowManagerFlags.KeepScreenOn); - }); - }, true); - }; } protected override void OnStop() { base.OnStop(); - gameView.Host?.Suspend(); Bass.Pause(); } protected override void OnRestart() { base.OnRestart(); - gameView.Host?.Resume(); Bass.Start(); } - - public override void OnWindowFocusChanged(bool hasFocus) - { - base.OnWindowFocusChanged(hasFocus); - IsActive.Value = hasFocus; - } - - public override void OnBackPressed() - { - // Avoid the default implementation that does close the app. - // This only happens when the back button could not be captured from OnKeyDown. - } - - // On some devices and keyboard combinations the OnKeyDown event does not propagate the key event to the view. - // Here it is done manually to ensure that the keys actually land in the view. - - public override bool OnKeyDown([GeneratedEnum] Keycode keyCode, KeyEvent? e) - { - return gameView.OnKeyDown(keyCode, e); - } - - public override bool OnKeyUp([GeneratedEnum] Keycode keyCode, KeyEvent? e) - { - return gameView.OnKeyUp(keyCode, e); - } - - public override bool OnKeyLongPress([GeneratedEnum] Keycode keyCode, KeyEvent? e) - { - return gameView.OnKeyLongPress(keyCode, e); - } } } diff --git a/osu.Framework.Android/AndroidGameHost.cs b/osu.Framework.Android/AndroidGameHost.cs index 256b02f0f8..9754a275e0 100644 --- a/osu.Framework.Android/AndroidGameHost.cs +++ b/osu.Framework.Android/AndroidGameHost.cs @@ -8,16 +8,12 @@ using Android.Content; using osu.Framework.Android.Graphics.Textures; using osu.Framework.Android.Graphics.Video; -using osu.Framework.Android.Input; using osu.Framework.Configuration; using osu.Framework.Extensions; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics.Textures; using osu.Framework.Graphics.Video; -using osu.Framework.Input; -using osu.Framework.Input.Handlers; -using osu.Framework.Input.Handlers.Midi; using osu.Framework.IO.Stores; using osu.Framework.Logging; using osu.Framework.Platform; @@ -25,13 +21,14 @@ namespace osu.Framework.Android { - public class AndroidGameHost : OsuTKGameHost + public class AndroidGameHost : SDLGameHost { - private readonly AndroidGameView gameView; + private readonly AndroidGameActivity activity; - public AndroidGameHost(AndroidGameView gameView) + public AndroidGameHost(AndroidGameActivity activity) + : base(string.Empty) { - this.gameView = gameView; + this.activity = activity; } protected override void SetupConfig(IDictionary defaultOverrides) @@ -42,9 +39,13 @@ protected override void SetupConfig(IDictionary defaul base.SetupConfig(defaultOverrides); } - protected override IWindow CreateWindow(GraphicsSurfaceType preferredSurface) => new AndroidGameWindow(gameView); + protected override IWindow CreateWindow(GraphicsSurfaceType preferredSurface) => new AndroidGameWindow(preferredSurface, Options.FriendlyGameName); - protected override Clipboard CreateClipboard() => new AndroidClipboard(gameView); + protected override void DrawFrame() + { + if (AndroidGameActivity.Surface.IsSurfaceReady) + base.DrawFrame(); + } public override bool CanExit => false; @@ -52,18 +53,6 @@ protected override void SetupConfig(IDictionary defaul public override bool OnScreenKeyboardOverlapsGameWindow => true; - protected override TextInputSource CreateTextInput() => new AndroidTextInput(gameView); - - protected override IEnumerable CreateAvailableInputHandlers() => - new InputHandler[] - { - new AndroidMouseHandler(gameView), - new AndroidKeyboardHandler(gameView), - new AndroidTouchHandler(gameView), - new AndroidJoystickHandler(gameView), - new MidiHandler() - }; - public override string InitialFileSelectorPath => @"/sdcard"; public override Storage GetStorage(string path) => new AndroidStorage(path, this); @@ -87,7 +76,7 @@ public override void OpenUrlExternally(string url) { // Recommended way to open URLs on Android 11+ // https://developer.android.com/training/package-visibility/use-cases#open-urls-browser-or-other-app - gameView.Activity.StartActivity(intent); + activity.StartActivity(intent); } } catch (Exception ex) @@ -104,7 +93,7 @@ public override VideoDecoder CreateVideoDecoder(Stream stream) public override bool SuspendToBackground() { - return gameView.Activity.MoveTaskToBack(true); + return activity.MoveTaskToBack(true); } } } diff --git a/osu.Framework.Android/AndroidGameSurface.cs b/osu.Framework.Android/AndroidGameSurface.cs new file mode 100644 index 0000000000..8f913d1b74 --- /dev/null +++ b/osu.Framework.Android/AndroidGameSurface.cs @@ -0,0 +1,121 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using Android.Content; +using Android.Runtime; +using Org.Libsdl.App; +using osu.Framework.Graphics; +using osu.Framework.Platform; +using osu.Framework.Bindables; +using Android.Views; +using AndroidX.Core.View; +using AndroidX.Window.Layout; + +namespace osu.Framework.Android +{ + internal class AndroidGameSurface : SDLSurface + { + private AndroidGameActivity activity { get; } = null!; + + public BindableSafeArea SafeAreaPadding { get; } = new BindableSafeArea(); + + public AndroidGameSurface(AndroidGameActivity activity, Context? context) + : base(context) + { + init(); + this.activity = activity; + } + + protected AndroidGameSurface(IntPtr javaReference, JniHandleOwnership transfer) + : base(javaReference, transfer) + { + init(); + } + + private void init() + { + if (OperatingSystem.IsAndroidVersionAtLeast(26)) + { + // disable ugly green border when view is focused via hardware keyboard/mouse. + DefaultFocusHighlightEnabled = false; + } + } + + private volatile bool isSurfaceReady; + + public bool IsSurfaceReady => isSurfaceReady; + + public override void HandlePause() + { + base.HandlePause(); + isSurfaceReady = false; + } + + public override void HandleResume() + { + base.HandleResume(); + isSurfaceReady = true; + } + + public override WindowInsets? OnApplyWindowInsets(View? view, WindowInsets? insets) + { + updateSafeArea(insets); + return base.OnApplyWindowInsets(view, insets); + } + + /// + /// Updates the , taking into account screen insets that may be obstructing this . + /// + private void updateSafeArea(WindowInsets? windowInsets) + { + var metrics = WindowMetricsCalculator.Companion.OrCreate.ComputeCurrentWindowMetrics(activity); + var windowArea = metrics.Bounds.ToRectangleI(); + var usableWindowArea = windowArea; + + if (OperatingSystem.IsAndroidVersionAtLeast(28)) + { + var cutout = windowInsets?.DisplayCutout; + + if (cutout != null) + usableWindowArea = usableWindowArea.Shrink(cutout.SafeInsetLeft, cutout.SafeInsetRight, cutout.SafeInsetTop, cutout.SafeInsetBottom); + } + + if (OperatingSystem.IsAndroidVersionAtLeast(31) && windowInsets != null) + { + var topLeftCorner = windowInsets.GetRoundedCorner((int)RoundedCornerPosition.TopLeft); + var topRightCorner = windowInsets.GetRoundedCorner((int)RoundedCornerPosition.TopRight); + var bottomLeftCorner = windowInsets.GetRoundedCorner((int)RoundedCornerPosition.BottomLeft); + var bottomRightCorner = windowInsets.GetRoundedCorner((int)RoundedCornerPosition.BottomRight); + + int cornerInsetLeft = Math.Max(topLeftCorner?.Radius ?? 0, bottomLeftCorner?.Radius ?? 0); + int cornerInsetRight = Math.Max(topRightCorner?.Radius ?? 0, bottomRightCorner?.Radius ?? 0); + int cornerInsetTop = Math.Max(topLeftCorner?.Radius ?? 0, topRightCorner?.Radius ?? 0); + int cornerInsetBottom = Math.Max(bottomLeftCorner?.Radius ?? 0, bottomRightCorner?.Radius ?? 0); + + var radiusInsetArea = windowArea.Width >= windowArea.Height + ? windowArea.Shrink(cornerInsetLeft, cornerInsetRight, 0, 0) + : windowArea.Shrink(0, 0, cornerInsetTop, cornerInsetBottom); + + usableWindowArea = usableWindowArea.Intersect(radiusInsetArea); + } + + if (OperatingSystem.IsAndroidVersionAtLeast(24) && activity.IsInMultiWindowMode && windowInsets != null) + { + // if we are in multi-window mode, the status bar is always visible (even if we request to hide it) and could be obstructing our view. + // if multi-window mode is not active, we can assume the status bar is hidden so we shouldn't consider it for safe area calculations. + var insetsCompat = WindowInsetsCompat.ToWindowInsetsCompat(windowInsets, this); + int statusBarHeight = insetsCompat.GetInsets(WindowInsetsCompat.Type.StatusBars()).Top; + usableWindowArea = usableWindowArea.Intersect(windowArea.Shrink(0, 0, statusBarHeight, 0)); + } + + SafeAreaPadding.Value = new MarginPadding + { + Left = usableWindowArea.Left - windowArea.Left, + Top = usableWindowArea.Top - windowArea.Top, + Right = windowArea.Right - usableWindowArea.Right, + Bottom = windowArea.Bottom - usableWindowArea.Bottom, + }; + } + } +} diff --git a/osu.Framework.Android/AndroidGameView.cs b/osu.Framework.Android/AndroidGameView.cs deleted file mode 100644 index a66f70a153..0000000000 --- a/osu.Framework.Android/AndroidGameView.cs +++ /dev/null @@ -1,359 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Runtime.Versioning; -using Android.Content; -using Android.OS; -using Android.Runtime; -using Android.Text; -using Android.Util; -using Android.Views; -using Android.Views.InputMethods; -using AndroidX.Core.View; -using AndroidX.Window.Layout; -using osu.Framework.Android.Input; -using osu.Framework.Logging; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Platform; -using osuTK.Graphics; - -namespace osu.Framework.Android -{ - public class AndroidGameView : osuTK.Android.AndroidGameView - { - public AndroidGameHost? Host { get; private set; } - - public AndroidGameActivity Activity { get; } = null!; - - public BindableSafeArea SafeAreaPadding { get; } = new BindableSafeArea(); - - /// - /// Represents whether the mouse pointer is captured, as reported by Android through . - /// - private bool pointerCaptured; - - /// - /// Set Android's pointer capture. - /// - /// - /// Only available in Android 8.0 Oreo () and up. - /// - public bool PointerCapture - { - get => pointerCaptured; - set - { - if (!OperatingSystem.IsAndroidVersionAtLeast(26)) - { - Logger.Log($"Tried to set {nameof(PointerCapture)} on an unsupported Android version.", level: LogLevel.Important); - return; - } - - if (pointerCaptured == value) return; - - if (value) - RequestPointerCapture(); - else - ReleasePointerCapture(); - } - } - - private readonly Game game = null!; - - private InputMethodManager? inputMethodManager; - - /// - /// Whether is active. - /// - private bool textInputActive; - - public AndroidGameView(AndroidGameActivity activity, Game game) - : base(activity) - { - Activity = activity; - this.game = game; - - init(); - } - - public AndroidGameView(Context context, IAttributeSet attrs) - : base(context, attrs) - { - init(); - } - - public AndroidGameView(IntPtr handle, JniHandleOwnership transfer) - : base(handle, transfer) - { - init(); - } - - private void init() - { - AutoSetContextOnRenderFrame = true; - ContextRenderingApi = GLVersion.ES3; - - // enable soft and hardware keyboard - // this needs to happen in the constructor - Focusable = true; - FocusableInTouchMode = true; - - if (OperatingSystem.IsAndroidVersionAtLeast(26)) - { - // disable ugly green border when view is focused via hardware keyboard/mouse. - DefaultFocusHighlightEnabled = false; - } - - inputMethodManager = Activity.GetSystemService(Context.InputMethodService) as InputMethodManager; - } - - protected override void CreateFrameBuffer() - { - try - { - base.CreateFrameBuffer(); - Log.Verbose("AndroidGameView", "Successfully created the framebuffer"); - } - catch (Exception e) - { - Log.Verbose("AndroidGameView", "{0}", e); - throw new InvalidOperationException("Can't load egl, aborting", e); - } - } - - public bool OnCommitText(string text) - { - CommitText?.Invoke(text); - return false; - } - - public override bool OnKeyDown([GeneratedEnum] Keycode keyCode, KeyEvent? e) - { - if (e == null) return base.OnKeyDown(keyCode, e); - - switch (keyCode) - { - // Do not consume Volume keys, so the system can handle them - case Keycode.VolumeDown: - case Keycode.VolumeUp: - case Keycode.VolumeMute: - return false; - - default: - KeyDown?.Invoke(keyCode, e); - - // Releasing backspace on a physical keyboard when text input is active will not send a key up event. - // Manually send one to prevent the key from getting stuck. - // This does mean that key repeat is handled by the OS, instead of by the usual `InputManager` handling. - if (keyCode == Keycode.Del && e.IsFromSource(InputSourceType.Keyboard) && textInputActive) - KeyUp?.Invoke(Keycode.Del, new KeyEvent(e.DownTime, e.EventTime, KeyEventActions.Up, Keycode.Del, 0, e.MetaState, e.DeviceId, e.ScanCode, e.Flags, e.Source)); - - return true; - } - } - - public override bool OnKeyLongPress([GeneratedEnum] Keycode keyCode, KeyEvent? e) - { - if (e == null) return base.OnKeyLongPress(keyCode, e); - - KeyLongPress?.Invoke(keyCode, e); - return true; - } - - public override bool OnKeyUp([GeneratedEnum] Keycode keyCode, KeyEvent? e) - { - if (e == null) return base.OnKeyUp(keyCode, e); - - KeyUp?.Invoke(keyCode, e); - return true; - } - - [SupportedOSPlatform("android26.0")] - public override void OnPointerCaptureChange(bool hasCapture) - { - base.OnPointerCaptureChange(hasCapture); - pointerCaptured = hasCapture; - } - - protected override void OnLoad(EventArgs e) - { - base.OnLoad(e); - - // osuTK calls `OnLoad()` every time the application surface is created, which will also happen upon a resume, - // at which point the host is already present and running, so there is no reason to create another one. - if (Host == null) - RenderGame(); - } - - public override WindowInsets? OnApplyWindowInsets(WindowInsets? insets) - { - updateSafeArea(insets); - return base.OnApplyWindowInsets(insets); - } - - [STAThread] - public void RenderGame() - { - // request focus so that joystick input can immediately work. - RequestFocus(); - - Host = new AndroidGameHost(this); - Host.ExceptionThrown += handleException; - Host.Run(game); - HostStarted?.Invoke(Host); - } - - private bool handleException(Exception ex) - { - // suppress exceptions related to MobileAuthenticatedStream disposal - // (see: https://github.com/ppy/osu/issues/6264 and linked related mono/xamarin issues) - // to be removed when upstream fixes come in - return ex is AggregateException ae - && ae.InnerException is ObjectDisposedException ode - && ode.ObjectName == "MobileAuthenticatedStream"; - } - - /// - /// Updates the , taking into account screen insets that may be obstructing this . - /// - private void updateSafeArea(WindowInsets? windowInsets) - { - var metrics = WindowMetricsCalculator.Companion.OrCreate.ComputeCurrentWindowMetrics(Activity); - var windowArea = metrics.Bounds.ToRectangleI(); - var usableWindowArea = windowArea; - - if (OperatingSystem.IsAndroidVersionAtLeast(28)) - { - var cutout = windowInsets?.DisplayCutout; - - if (cutout != null) - usableWindowArea = usableWindowArea.Shrink(cutout.SafeInsetLeft, cutout.SafeInsetRight, cutout.SafeInsetTop, cutout.SafeInsetBottom); - } - - if (OperatingSystem.IsAndroidVersionAtLeast(31) && windowInsets != null) - { - var topLeftCorner = windowInsets.GetRoundedCorner((int)RoundedCornerPosition.TopLeft); - var topRightCorner = windowInsets.GetRoundedCorner((int)RoundedCornerPosition.TopRight); - var bottomLeftCorner = windowInsets.GetRoundedCorner((int)RoundedCornerPosition.BottomLeft); - var bottomRightCorner = windowInsets.GetRoundedCorner((int)RoundedCornerPosition.BottomRight); - - int cornerInsetLeft = Math.Max(topLeftCorner?.Radius ?? 0, bottomLeftCorner?.Radius ?? 0); - int cornerInsetRight = Math.Max(topRightCorner?.Radius ?? 0, bottomRightCorner?.Radius ?? 0); - int cornerInsetTop = Math.Max(topLeftCorner?.Radius ?? 0, topRightCorner?.Radius ?? 0); - int cornerInsetBottom = Math.Max(bottomLeftCorner?.Radius ?? 0, bottomRightCorner?.Radius ?? 0); - - var radiusInsetArea = windowArea.Width >= windowArea.Height - ? windowArea.Shrink(cornerInsetLeft, cornerInsetRight, 0, 0) - : windowArea.Shrink(0, 0, cornerInsetTop, cornerInsetBottom); - - usableWindowArea = usableWindowArea.Intersect(radiusInsetArea); - } - - if (OperatingSystem.IsAndroidVersionAtLeast(24) && Activity.IsInMultiWindowMode && windowInsets != null) - { - // if we are in multi-window mode, the status bar is always visible (even if we request to hide it) and could be obstructing our view. - // if multi-window mode is not active, we can assume the status bar is hidden so we shouldn't consider it for safe area calculations. - var insetsCompat = WindowInsetsCompat.ToWindowInsetsCompat(windowInsets, this); - int statusBarHeight = insetsCompat.GetInsets(WindowInsetsCompat.Type.StatusBars()).Top; - usableWindowArea = usableWindowArea.Intersect(windowArea.Shrink(0, 0, statusBarHeight, 0)); - } - - SafeAreaPadding.Value = new MarginPadding - { - Left = usableWindowArea.Left - windowArea.Left, - Top = usableWindowArea.Top - windowArea.Top, - Right = windowArea.Right - usableWindowArea.Right, - Bottom = windowArea.Bottom - usableWindowArea.Bottom, - }; - } - - public override bool OnCheckIsTextEditor() => textInputActive; - - /// null to disable input methods - public override IInputConnection? OnCreateInputConnection(EditorInfo? outAttrs) - { - ArgumentNullException.ThrowIfNull(outAttrs); - - // Properly disable native input methods so that the software keyboard doesn't unexpectedly open. - // Eg. when pressing keys on a hardware keyboard. - if (!textInputActive) - return null; - - outAttrs.ImeOptions = ImeFlags.NoExtractUi | ImeFlags.NoFullscreen; - outAttrs.InputType = InputTypes.TextVariationVisiblePassword | InputTypes.TextFlagNoSuggestions; - return new AndroidInputConnection(this, true); - } - - internal void StartTextInput() - { - textInputActive = true; - Activity.RunOnUiThread(() => - { - inputMethodManager?.RestartInput(this); // this syncs the Android input method state with `OnCreateInputConnection()`. - RequestFocus(); - inputMethodManager?.ShowSoftInput(this, 0); - }); - } - - internal void StopTextInput() - { - textInputActive = false; - Activity.RunOnUiThread(() => - { - inputMethodManager?.RestartInput(this); - inputMethodManager?.HideSoftInputFromWindow(WindowToken, HideSoftInputFlags.None); - }); - } - - public override void SwapBuffers() - { - try - { - base.SwapBuffers(); - } - catch (GraphicsContextException ex) - { - // sometimes buffers will spontaneously fail to swap with BAD_SURFACE - // just before the activity is suspended to background or just after it has been resumed, - // but will continue operating correctly after that transitionary period. - // despite some testing it is unclear which view callback can be used to tell whether it is safe to swap buffers, - // so for now just catch and suppress these errors. - if (ex.Message.Contains("BAD_SURFACE", StringComparison.Ordinal)) - Logger.Log($"BAD_SURFACE failure in {nameof(SwapBuffers)} suppressed"); - else - throw; - } - } - - #region Events - - /// - /// Invoked on a key down event. - /// - public new event Action? KeyDown; - - /// - /// Invoked on a key up event. - /// - public new event Action? KeyUp; - - /// - /// Invoked on a key long press event. - /// - public event Action? KeyLongPress; - - /// - /// Invoked when text is committed by an . - /// - public event Action? CommitText; - - /// - /// Invoked when the has been started on the . - /// - public event Action? HostStarted; - - #endregion - } -} diff --git a/osu.Framework.Android/AndroidGameWindow.cs b/osu.Framework.Android/AndroidGameWindow.cs index 2feee17adf..457d5e1ebe 100644 --- a/osu.Framework.Android/AndroidGameWindow.cs +++ b/osu.Framework.Android/AndroidGameWindow.cs @@ -2,71 +2,30 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using osu.Framework.Bindables; -using osu.Framework.Configuration; using osu.Framework.Platform; -using osuTK; -using osuTK.Graphics; +using osu.Framework.Platform.SDL3; namespace osu.Framework.Android { - internal class AndroidGameWindow : OsuTKWindow + internal class AndroidGameWindow : SDL3MobileWindow { - private readonly AndroidGameView view; + public override IntPtr SurfaceHandle => AndroidGameActivity.Surface.NativeSurface?.Handle ?? IntPtr.Zero; - public override IGraphicsContext Context => view.GraphicsContext; - - public override bool Focused => IsActive.Value; - - public override IBindable IsActive { get; } - - public override Platform.WindowState WindowState - { - get => Platform.WindowState.Normal; - set { } - } - - public event Action? CursorStateChanged; - - public override CursorState CursorState - { - get => base.CursorState; - set - { - // cursor should always be confined on mobile platforms, to have UserInputManager confine the cursor to window bounds - base.CursorState = value | CursorState.Confined; - CursorStateChanged?.Invoke(); - } - } - - public AndroidGameWindow(AndroidGameView view) - : base(view) - { - this.view = view; - IsActive = view.Activity.IsActive.GetBoundCopy(); - } - - public override void SetupWindow(FrameworkConfigManager config) + public AndroidGameWindow(GraphicsSurfaceType surfaceType, string appName) + : base(surfaceType, appName) { - CursorState |= CursorState.Confined; - SafeAreaPadding.BindTo(view.SafeAreaPadding); } - public override IEnumerable SupportedWindowModes => new[] + public override void Create() { - Configuration.WindowMode.Fullscreen, - }; + base.Create(); - public override void Run() - { - view.Run(); - } + SafeAreaPadding.BindTo(AndroidGameActivity.Surface.SafeAreaPadding); - protected override DisplayDevice CurrentDisplayDevice - { - get => DisplayDevice.Default; - set => throw new InvalidOperationException(); + // Android SDL doesn't receive these events at start, so it never receives focus until it comes back from background + ((BindableBool)CursorInWindow).Value = true; + Focused = true; } } } diff --git a/osu.Framework.Android/Input/AndroidInputConnection.cs b/osu.Framework.Android/Input/AndroidInputConnection.cs deleted file mode 100644 index 1bcdee1fb9..0000000000 --- a/osu.Framework.Android/Input/AndroidInputConnection.cs +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using Android.Views; -using Android.Views.InputMethods; -using Java.Lang; - -namespace osu.Framework.Android.Input -{ - internal class AndroidInputConnection : BaseInputConnection - { - private readonly AndroidGameView targetView; - - public AndroidInputConnection(AndroidGameView targetView, bool fullEditor) - : base(targetView, fullEditor) - { - this.targetView = targetView; - } - - public override bool CommitText(ICharSequence? text, int newCursorPosition) - { - if (text?.Length() > 0) - { - targetView.OnCommitText(text.ToString()); - return true; - } - - return base.CommitText(text, newCursorPosition); - } - - public override bool SendKeyEvent(KeyEvent? e) - { - if (e == null) - return base.SendKeyEvent(e); - - switch (e.Action) - { - case KeyEventActions.Down: - targetView.OnKeyDown(e.KeyCode, e); - return true; - - case KeyEventActions.Up: - targetView.OnKeyUp(e.KeyCode, e); - return true; - - case KeyEventActions.Multiple: - targetView.OnKeyDown(e.KeyCode, e); - targetView.OnKeyUp(e.KeyCode, e); - return true; - } - - return base.SendKeyEvent(e); - } - - public override bool DeleteSurroundingText(int beforeLength, int afterLength) - { - for (int i = 0; i < beforeLength; i++) - { - KeyEvent ed = new KeyEvent(KeyEventActions.Multiple, Keycode.Del); - SendKeyEvent(ed); - } - - return true; - } - } -} diff --git a/osu.Framework.Android/Input/AndroidInputExtensions.cs b/osu.Framework.Android/Input/AndroidInputExtensions.cs deleted file mode 100644 index 0cb1e4a63a..0000000000 --- a/osu.Framework.Android/Input/AndroidInputExtensions.cs +++ /dev/null @@ -1,370 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using Android.Views; -using osu.Framework.Extensions.EnumExtensions; -using osu.Framework.Input; -using osu.Framework.Logging; -using osuTK; -using osuTK.Input; - -namespace osu.Framework.Android.Input -{ - public static class AndroidInputExtensions - { - /// - /// Denotes the current (last) event in a 's history. - /// - public const int HISTORY_CURRENT = -1; - - public delegate void MotionEventHandler(MotionEvent motionEvent, int historyPosition); - - /// - /// Handles all events in this 's history in a chronological fashion (up to and including ). - /// - /// The to handle the history of. - /// The to handle the events. - /// - /// Used in lieu of when the input infrastructure can only handle one pointer - /// and/or when the is expected to report only one pointer. - /// - public static void HandleHistorically(this MotionEvent motionEvent, MotionEventHandler handler) - { - if (motionEvent.PointerCount > 1) - { - Logger.Log($"{nameof(HandleHistorically)} was used when PointerCount ({motionEvent.PointerCount}) was greater than 1. Events for pointers other than the first have been dropped."); - Logger.Log($"MotionEvent: {motionEvent}"); - } - - for (int h = 0; h < motionEvent.HistorySize; h++) - { - handler(motionEvent, h); - } - - handler(motionEvent, HISTORY_CURRENT); - } - - public delegate void MotionEventPerPointerHandler(MotionEvent motionEvent, int historyPosition, int pointerIndex); - - /// - /// Handles all events in this 's history in a chronological fashion, sequentially calling for each pointer. - /// - /// The to handle the history of. - /// The to handle the events. - public static void HandleHistoricallyPerPointer(this MotionEvent motionEvent, MotionEventPerPointerHandler handler) - { - for (int h = 0; h < motionEvent.HistorySize; h++) - { - for (int p = 0; p < motionEvent.PointerCount; p++) - { - handler(motionEvent, h, p); - } - } - - for (int p = 0; p < motionEvent.PointerCount; p++) - { - handler(motionEvent, HISTORY_CURRENT, p); - } - } - - /// - /// Returns the value of the requested axis. - /// - /// The to get the value from. - /// The identifier for the axis value to retrieve. - /// Which historical value to return; must be in range [0, ), or the constant . - /// Raw index of pointer to retrieve; must be in range [0, ). - /// The value of the axis, or 0 if the axis is not available. - /// s different from are valid only for events. - public static float Get(this MotionEvent motionEvent, Axis axis, int historyPosition = HISTORY_CURRENT, int pointerIndex = 0) - => historyPosition == HISTORY_CURRENT - ? motionEvent.GetAxisValue(axis, pointerIndex) - : motionEvent.GetHistoricalAxisValue(axis, pointerIndex, historyPosition); - - /// - /// Gets the of the requested axis, returning true if it's valid. - /// - /// The to get the value from. - /// The identifier for the axis value to retrieve. - /// The value of the axis, or 0 if the axis is not available. - /// Which historical to return; must be in range [0, ), - /// or the constant . - /// Raw index of pointer to retrieve; must be in range [0, ). - /// Whether the returned is valid. - /// s different from are valid only for events. - public static bool TryGet(this MotionEvent motionEvent, Axis axis, out float value, int historyPosition = HISTORY_CURRENT, int pointerIndex = 0) - { - value = historyPosition == HISTORY_CURRENT - ? motionEvent.GetAxisValue(axis, pointerIndex) - : motionEvent.GetHistoricalAxisValue(axis, pointerIndex, historyPosition); - - return float.IsFinite(value); - } - - /// - /// Gets the and axes of the event, returning true if they're valid. - /// - /// The to get the axes from. - /// containing the and axes of the event. - /// Which historical to return; must be in range [0, ), - /// or the constant . - /// Raw index of pointer to retrieve; must be in range [0, ). - /// Whether the returned is valid. - /// s different from are valid only for events. - public static bool TryGetPosition(this MotionEvent motionEvent, out Vector2 position, int historyPosition = HISTORY_CURRENT, int pointerIndex = 0) - { - if (motionEvent.TryGet(Axis.X, out float x, historyPosition, pointerIndex) - && motionEvent.TryGet(Axis.Y, out float y, historyPosition, pointerIndex)) - { - position = new Vector2(x, y); - return true; - } - - position = Vector2.Zero; - return false; - } - - /// - /// Returns the corresponding s for a mouse button given as a . - /// - /// The given button state. Must not be a raw state or a non-mouse button. - /// The corresponding s. - public static IEnumerable ToMouseButtons(this MotionEventButtonState motionEventMouseButton) - { - if (motionEventMouseButton.HasFlagFast(MotionEventButtonState.Primary)) - yield return MouseButton.Left; - - if (motionEventMouseButton.HasFlagFast(MotionEventButtonState.Secondary)) - yield return MouseButton.Right; - - if (motionEventMouseButton.HasFlagFast(MotionEventButtonState.Tertiary)) - yield return MouseButton.Middle; - - if (motionEventMouseButton.HasFlagFast(MotionEventButtonState.Back)) - yield return MouseButton.Button1; - - if (motionEventMouseButton.HasFlagFast(MotionEventButtonState.Forward)) - yield return MouseButton.Button2; - } - - /// - /// Returns the corresponding for a mouse button given as a . - /// - /// The given keycode. Should be or . - /// The corresponding . - /// true if this is a valid . - public static bool TryGetMouseButton(this Keycode keycode, out MouseButton button) - { - switch (keycode) - { - case Keycode.Back: - button = MouseButton.Button1; - return true; - - case Keycode.Forward: - button = MouseButton.Button2; - return true; - } - - button = MouseButton.LastButton; - return false; - } - - public static bool IsKeyboard(this InputSourceType source) - { - // ReSharper disable once BitwiseOperatorOnEnumWithoutFlags - return source is InputSourceType.Keyboard or (InputSourceType.Keyboard | InputSourceType.Dpad); - } - - public static bool TryGetJoystickButton(this KeyEvent e, out JoystickButton button) - { - var keycode = e.KeyCode; - - if (keycode >= Keycode.Button1 && keycode <= Keycode.Button16) - { - // JoystickButtons 1-16 are used below. - button = JoystickButton.Button17 + (keycode - Keycode.Button1); - return true; - } - - switch (keycode) - { - // Dpad keycodes are _not_ joystick buttons, but are instead used for arrow keys on a keyboard. - // as evident from KeyEvent.isGamePadButton(): - // https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/view/KeyEvent.java;l=1899-1936;drc=11e61ab1fd1f868ee8ddd6fc86662f4f09df1a6a - case Keycode.DpadUp: - case Keycode.DpadDown: - case Keycode.DpadLeft: - case Keycode.DpadRight: - case Keycode.Back when e.Source.IsKeyboard(): - default: - button = JoystickButton.FirstButton; - return false; - - case Keycode.ButtonA: - button = JoystickButton.GamePadA; - return true; - - case Keycode.ButtonB: - button = JoystickButton.GamePadB; - return true; - - case Keycode.ButtonC: - button = JoystickButton.Button14; // generic button - return true; - - case Keycode.ButtonX: - button = JoystickButton.GamePadX; - return true; - - case Keycode.ButtonY: - button = JoystickButton.GamePadY; - return true; - - case Keycode.ButtonZ: - button = JoystickButton.Button15; // generic button - return true; - - case Keycode.ButtonL1: - button = JoystickButton.GamePadLeftShoulder; - return true; - - case Keycode.ButtonR1: - button = JoystickButton.GamePadRightShoulder; - return true; - - case Keycode.ButtonL2: - button = JoystickButton.GamePadLeftTrigger; - return true; - - case Keycode.ButtonR2: - button = JoystickButton.GamePadRightTrigger; - return true; - - case Keycode.ButtonThumbl: - button = JoystickButton.GamePadLeftStick; - return true; - - case Keycode.ButtonThumbr: - button = JoystickButton.GamePadRightStick; - return true; - - case Keycode.ButtonStart: - button = JoystickButton.GamePadStart; - return true; - - case Keycode.Back: - case Keycode.ButtonSelect: - button = JoystickButton.GamePadBack; - return true; - - case Keycode.ButtonMode: - button = JoystickButton.Button16; // generic button - return true; - } - } - - /// - /// All axes supported by . - /// - public static readonly IEnumerable ALL_AXES = new[] - { - Axis.X, - Axis.Y, - Axis.Ltrigger, - Axis.Z, - Axis.Rz, - Axis.Rtrigger, - Axis.Rx, - Axis.Ry, - Axis.Rudder, - Axis.Wheel, - }; - - /// - /// Returns the corresponding for an . - /// - /// true if provided maps to a . - /// - /// and are deliberately excluded as those axes are 1:1 mirrors of the and . - /// - public static bool TryGetJoystickAxisSource(this Axis axis, out JoystickAxisSource joystickAxis) - { - switch (axis) - { - case Axis.X: - joystickAxis = JoystickAxisSource.GamePadLeftStickX; - return true; - - case Axis.Y: - joystickAxis = JoystickAxisSource.GamePadLeftStickY; - return true; - - case Axis.Ltrigger: - joystickAxis = JoystickAxisSource.GamePadLeftTrigger; - return true; - - case Axis.Z: - joystickAxis = JoystickAxisSource.GamePadRightStickX; - return true; - - case Axis.Rz: - joystickAxis = JoystickAxisSource.GamePadRightStickY; - return true; - - case Axis.Rtrigger: - joystickAxis = JoystickAxisSource.GamePadRightTrigger; - return true; - - case Axis.Rx: - joystickAxis = JoystickAxisSource.Axis7; - return true; - - case Axis.Ry: - joystickAxis = JoystickAxisSource.Axis8; - return true; - - case Axis.Rudder: - joystickAxis = JoystickAxisSource.Axis9; - return true; - - case Axis.Wheel: - joystickAxis = JoystickAxisSource.Axis10; - return true; - } - - joystickAxis = JoystickAxisSource.AxisCount; - return false; - } - - /// - /// Whether this is a touch down action. - /// - /// The to check. - /// - /// true if this is a touch down action. - /// false if this is a touch up action. - /// - /// If this action is not a touch action. - public static bool IsTouchDownAction(this MotionEventActions action) - { - switch (action) - { - case MotionEventActions.Down: - case MotionEventActions.PointerDown: - case MotionEventActions.Move: - return true; - - case MotionEventActions.PointerUp: - case MotionEventActions.Up: - case MotionEventActions.Cancel: - return false; - - default: - throw new ArgumentOutOfRangeException(nameof(action), action, "Motion event action is not a touch action."); - } - } - } -} diff --git a/osu.Framework.Android/Input/AndroidInputHandler.cs b/osu.Framework.Android/Input/AndroidInputHandler.cs deleted file mode 100644 index 277fafa4ea..0000000000 --- a/osu.Framework.Android/Input/AndroidInputHandler.cs +++ /dev/null @@ -1,258 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Android.Views; -using osu.Framework.Extensions.EnumExtensions; -using osu.Framework.Extensions.TypeExtensions; -using osu.Framework.Input.Handlers; -using osu.Framework.Platform; - -namespace osu.Framework.Android.Input -{ - /// - /// Base input handler for handling events dispatched by . - /// Provides consistent and unified means for handling s only from specific s. - /// - public abstract class AndroidInputHandler : InputHandler - { - /// - protected const int HISTORY_CURRENT = AndroidInputExtensions.HISTORY_CURRENT; - - /// - /// The s that this will handle. - /// - protected abstract IEnumerable HandledEventSources { get; } - - /// - /// The view that this is handling events from. - /// - protected readonly AndroidGameView View; - - /// - /// Bitmask of all . - /// - private InputSourceType eventSourceBitmask; - - protected AndroidInputHandler(AndroidGameView view) - { - View = view; - } - - public override bool Initialize(GameHost host) - { - if (!base.Initialize(host)) - return false; - - // compute the bitmask for later use. - foreach (var eventSource in HandledEventSources) - { - // ReSharper disable once BitwiseOperatorOnEnumWithoutFlags - // (InputSourceType is a flags enum, but is not properly marked as such) - eventSourceBitmask |= eventSource; - } - - return true; - } - -#pragma warning disable 1574 // unresolved cref attribute - - /// - /// Invoked on every event that matches an from . - /// - /// - /// Subscribe to . to receive events here. - /// Whether the event was handled. Unhandled events are logged. - /// - protected virtual bool OnCapturedPointer(MotionEvent capturedPointerEvent) - { - throw new NotSupportedException($"{nameof(HandleCapturedPointer)} subscribed to {nameof(View.CapturedPointer)} but the relevant method was not overriden."); - } - - /// - /// Invoked on every event that matches an from . - /// - /// - /// Subscribe to . to receive events here. - /// Whether the event was handled. Unhandled events are logged. - /// - protected virtual bool OnGenericMotion(MotionEvent genericMotionEvent) - { - throw new NotSupportedException($"{nameof(HandleGenericMotion)} subscribed to {nameof(View.GenericMotion)} but the relevant method was not overriden."); - } - - /// - /// Invoked on every event that matches an from . - /// - /// - /// Subscribe to . to receive events here. - /// Whether the event was handled. Unhandled events are logged. - /// - protected virtual bool OnHover(MotionEvent hoverEvent) - { - throw new NotSupportedException($"{nameof(HandleHover)} subscribed to {nameof(View.Hover)} but the relevant method was not overriden."); - } - - /// - /// Invoked on every event that matches an from . - /// - /// - /// Subscribe to . to receive events here. - /// Whether the event was handled. Unhandled events are logged. - /// - protected virtual ReturnCode OnKeyDown(Keycode keycode, KeyEvent e) - { - throw new NotSupportedException($"{nameof(HandleKeyDown)} subscribed to {nameof(View.KeyDown)} but the relevant method was not overriden."); - } - - /// - /// Invoked on every event that matches an from . - /// - /// - /// Subscribe to . to receive events here. - /// Whether the event was handled. Unhandled events are logged. - /// - protected virtual ReturnCode OnKeyUp(Keycode keycode, KeyEvent e) - { - throw new NotSupportedException($"{nameof(HandleKeyUp)} subscribed to {nameof(View.KeyUp)} but the relevant method was not overriden."); - } - - /// - /// Invoked on every event that matches an from . - /// - /// - /// Subscribe to . to receive events here. - /// Whether the event was handled. Unhandled events are logged. - /// - protected virtual bool OnTouch(MotionEvent touchEvent) - { - throw new NotSupportedException($"{nameof(HandleTouch)} subscribed to {nameof(View.Touch)} but the relevant method was not overriden."); - } - - #region Event handlers - - /// - /// Checks whether the should be handled by this . - /// - /// The to check. - /// true if the 's matches . - /// Should be checked before handling events. - protected virtual bool ShouldHandleEvent([NotNullWhen(true)] InputEvent? inputEvent) - { - return inputEvent != null && eventSourceBitmask.HasFlagFast(inputEvent.Source); - } - - /// - /// Handler for events. - /// - protected void HandleCapturedPointer(object sender, View.CapturedPointerEventArgs e) - { - if (ShouldHandleEvent(e.Event)) - { - if (OnCapturedPointer(e.Event)) - e.Handled = true; - else - logUnhandledEvent(nameof(OnCapturedPointer), e.Event); - } - } - - /// - /// Handler for events. - /// - protected void HandleGenericMotion(object sender, View.GenericMotionEventArgs e) - { - if (ShouldHandleEvent(e.Event)) - { - if (OnGenericMotion(e.Event)) - e.Handled = true; - else - logUnhandledEvent(nameof(OnGenericMotion), e.Event); - } - } - - /// - /// Handler for events. - /// - protected void HandleHover(object sender, View.HoverEventArgs e) - { - if (ShouldHandleEvent(e.Event)) - { - if (OnHover(e.Event)) - e.Handled = true; - else - logUnhandledEvent(nameof(OnHover), e.Event); - } - } - - /// - /// Handler for events. - /// - protected void HandleKeyDown(Keycode keycode, KeyEvent e) - { - if (ShouldHandleEvent(e)) - { - if (OnKeyDown(keycode, e) == ReturnCode.Unhandled) - logUnhandledEvent(nameof(OnKeyDown), e); - } - } - - /// - /// Handler for events. - /// - protected void HandleKeyUp(Keycode keycode, KeyEvent e) - { - if (ShouldHandleEvent(e)) - { - if (OnKeyUp(keycode, e) == ReturnCode.Unhandled) - logUnhandledEvent(nameof(OnKeyUp), e); - } - } - - /// - /// Handler for events. - /// - protected void HandleTouch(object sender, View.TouchEventArgs e) - { - if (ShouldHandleEvent(e.Event)) - { - if (OnTouch(e.Event)) - e.Handled = true; - else - logUnhandledEvent(nameof(OnTouch), e.Event); - } - } - - #endregion - - private void logUnhandledEvent(string methodName, InputEvent inputEvent) - { - Log($"Unknown {GetType().ReadableName()}.{methodName} event: {inputEvent}"); - } - - protected enum ReturnCode - { - /// - /// Denotes an event that was handled by this handler. - /// - Handled, - - /// - /// Denotes an event that this handler did not handle. - /// - /// - /// Since all events are first put through the filter, an unhandled event is considered a bug and is logged. - /// - Unhandled, - - /// - /// Same as , but will not be logged. - /// - /// - /// Used when an event might also be handled by another handler, but that cannot be determined purely on . - /// - UnhandledSuppressLogging, - } - } -} diff --git a/osu.Framework.Android/Input/AndroidJoystickHandler.cs b/osu.Framework.Android/Input/AndroidJoystickHandler.cs deleted file mode 100644 index af7b63ddb4..0000000000 --- a/osu.Framework.Android/Input/AndroidJoystickHandler.cs +++ /dev/null @@ -1,231 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using System.Linq; -using Android.Views; -using osu.Framework.Bindables; -using osu.Framework.Input; -using osu.Framework.Input.Handlers.Joystick; -using osu.Framework.Input.StateChanges; -using osu.Framework.Platform; -using osu.Framework.Statistics; - -namespace osu.Framework.Android.Input -{ - public class AndroidJoystickHandler : AndroidInputHandler - { - private static readonly GlobalStatistic statistic_total_events = GlobalStatistics.Get(StatisticGroupFor(), "Total events"); - - public BindableFloat DeadzoneThreshold { get; } = new BindableFloat(0.1f) - { - MinValue = 0, - MaxValue = 0.95f, - Precision = 0.005f, - }; - - public override string Description => "Joystick / Gamepad"; - - public override bool IsActive => true; - - protected override IEnumerable HandledEventSources => new[] - { - InputSourceType.Dpad, - InputSourceType.Gamepad, - InputSourceType.Joystick, - // joysticks sometimes present themselves as a keyboard in OnKey{Up,Down} events. - InputSourceType.Keyboard - }; - - public AndroidJoystickHandler(AndroidGameView view) - : base(view) - { - } - - public override bool Initialize(GameHost host) - { - if (!base.Initialize(host)) - return false; - - Enabled.BindValueChanged(enabled => - { -#nullable disable // Events misses nullable mark in .NET Android SDK (6.0.402) - if (enabled.NewValue) - { - View.GenericMotion += HandleGenericMotion; - View.KeyDown += HandleKeyDown; - View.KeyUp += HandleKeyUp; - } - else - { - View.GenericMotion -= HandleGenericMotion; - View.KeyDown -= HandleKeyDown; - View.KeyUp -= HandleKeyUp; - } -#nullable restore - }, true); - - return true; - } - - private ReturnCode returnCodeForSource(InputSourceType source) - { - // keyboard only events are handled in AndroidKeyboardHandler - return source.IsKeyboard() - ? ReturnCode.UnhandledSuppressLogging - : ReturnCode.Unhandled; - } - - protected override ReturnCode OnKeyDown(Keycode keycode, KeyEvent e) - { - if (e.TryGetJoystickButton(out var button)) - { - enqueueButtonDown(button); - return ReturnCode.Handled; - } - - return returnCodeForSource(e.Source); - } - - protected override ReturnCode OnKeyUp(Keycode keycode, KeyEvent e) - { - if (e.TryGetJoystickButton(out var button)) - { - enqueueButtonUp(button); - return ReturnCode.Handled; - } - - return returnCodeForSource(e.Source); - } - - /// - /// The for which the are valid. - /// null iff the current device could not be determined, in that case, fall back to . - /// - private string? lastDeviceDescriptor; - - /// - /// The axes that are reported as supported by the current .. - /// if the current device doesn't report axes information. - /// - private IEnumerable availableAxes = AndroidInputExtensions.ALL_AXES; - - /// - /// Updates to be appropriate for the current . - /// - private void updateAvailableAxesForDevice(InputDevice? device) - { - if (device?.Descriptor == null) - { - if (lastDeviceDescriptor == null) - return; - - // use the default if this device is unknown. - lastDeviceDescriptor = null; - availableAxes = AndroidInputExtensions.ALL_AXES; - return; - } - - if (device.Descriptor == lastDeviceDescriptor) - return; - - lastDeviceDescriptor = device.Descriptor; - - var motionRanges = device.MotionRanges; - - availableAxes = motionRanges != null && motionRanges.Count > 0 - ? motionRanges.Select(m => m.Axis).Where(isValid).Distinct().ToList() - : AndroidInputExtensions.ALL_AXES; - - bool isValid(Axis axis) - { - switch (axis) - { - // D-pad axes are handled separately in `applyDpadInput` - case Axis.HatX: - case Axis.HatY: - // Brake and Gas axes mirror the left and right trigger and are therefore ignored - case Axis.Gas: - case Axis.Brake: - return false; - } - - if (axis.TryGetJoystickAxisSource(out _)) - return true; - - Log($"Unknown joystick axis: {axis}"); - return false; - } - } - - protected override bool OnGenericMotion(MotionEvent genericMotionEvent) - { - switch (genericMotionEvent.Action) - { - case MotionEventActions.Move: - updateAvailableAxesForDevice(genericMotionEvent.Device); - genericMotionEvent.HandleHistorically(apply); - return true; - - default: - return false; - } - } - - private void apply(MotionEvent motionEvent, int historyPosition) - { - foreach (var axis in availableAxes) - applyAxisInput(motionEvent, historyPosition, axis); - - applyDpadInput(motionEvent, historyPosition); - } - - private void applyAxisInput(MotionEvent motionEvent, int historyPosition, Axis axis) - { - if (axis.TryGetJoystickAxisSource(out var joystickAxisSource) - && motionEvent.TryGet(axis, out float value, historyPosition)) - { - value = JoystickHandler.RescaleByDeadzone(value, DeadzoneThreshold.Value); - enqueueInput(new JoystickAxisInput(new JoystickAxis(joystickAxisSource, value))); - } - } - - private float lastDpadX; - private float lastDpadY; - - private void applyDpadInput(MotionEvent motionEvent, int historyPosition) - { - float x = motionEvent.Get(Axis.HatX, historyPosition); - - if (x != lastDpadX) - { - if (x == 0) enqueueButtonUp(lastDpadX > 0 ? JoystickButton.GamePadDPadRight : JoystickButton.GamePadDPadLeft); - if (x > 0) enqueueButtonDown(JoystickButton.GamePadDPadRight); - if (x < 0) enqueueButtonDown(JoystickButton.GamePadDPadLeft); - - lastDpadX = x; - } - - float y = motionEvent.Get(Axis.HatY, historyPosition); - - if (y != lastDpadY) - { - if (y == 0) enqueueButtonUp(lastDpadY > 0 ? JoystickButton.GamePadDPadDown : JoystickButton.GamePadDPadUp); - if (y > 0) enqueueButtonDown(JoystickButton.GamePadDPadDown); - if (y < 0) enqueueButtonDown(JoystickButton.GamePadDPadUp); - - lastDpadY = y; - } - } - - private void enqueueButtonDown(JoystickButton button) => enqueueInput(new JoystickButtonInput(button, true)); - private void enqueueButtonUp(JoystickButton button) => enqueueInput(new JoystickButtonInput(button, false)); - - private void enqueueInput(IInput input) - { - PendingInputs.Enqueue(input); - FrameStatistics.Increment(StatisticsCounterType.JoystickEvents); - statistic_total_events.Value++; - } - } -} diff --git a/osu.Framework.Android/Input/AndroidKeyboardHandler.cs b/osu.Framework.Android/Input/AndroidKeyboardHandler.cs deleted file mode 100644 index 9eebefdb24..0000000000 --- a/osu.Framework.Android/Input/AndroidKeyboardHandler.cs +++ /dev/null @@ -1,217 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using Android.Views; -using osu.Framework.Input.StateChanges; -using osu.Framework.Platform; -using osu.Framework.Statistics; -using osuTK.Input; - -namespace osu.Framework.Android.Input -{ - public class AndroidKeyboardHandler : AndroidInputHandler - { - private static readonly GlobalStatistic statistic_total_events = GlobalStatistics.Get(StatisticGroupFor(), "Total events"); - - protected override IEnumerable HandledEventSources => new[] - { - InputSourceType.Keyboard, - // Some physical keyboards report as (Keyboard | Dpad) - InputSourceType.Dpad, - }; - - public AndroidKeyboardHandler(AndroidGameView view) - : base(view) - { - } - - public override bool Initialize(GameHost host) - { - if (!base.Initialize(host)) - return false; - - Enabled.BindValueChanged(enabled => - { - if (enabled.NewValue) - { - View.KeyDown += HandleKeyDown; - View.KeyUp += HandleKeyUp; - } - else - { - View.KeyDown -= HandleKeyDown; - View.KeyUp -= HandleKeyUp; - } - }, true); - - return true; - } - - public override bool IsActive => true; - - private ReturnCode returnCodeForKeycode(Keycode keycode) - { - // gamepad buttons are handled in AndroidJoystickHandler - return KeyEvent.IsGamepadButton(keycode) - ? ReturnCode.UnhandledSuppressLogging - : ReturnCode.Unhandled; - } - - protected override ReturnCode OnKeyDown(Keycode keycode, KeyEvent e) - { - var key = GetKeyCodeAsKey(keycode); - - if (key != Key.Unknown) - { - enqueueInput(new KeyboardKeyInput(key, true)); - return ReturnCode.Handled; - } - - return returnCodeForKeycode(keycode); - } - - protected override ReturnCode OnKeyUp(Keycode keycode, KeyEvent e) - { - var key = GetKeyCodeAsKey(keycode); - - if (key != Key.Unknown) - { - enqueueInput(new KeyboardKeyInput(key, false)); - return ReturnCode.Handled; - } - - return returnCodeForKeycode(keycode); - } - - /// - /// This method maps the to from opentk. - /// - /// The to be converted into a . - /// The that was converted from . - public static Key GetKeyCodeAsKey(Keycode keyCode) - { - // number keys - const Keycode first_num_key = Keycode.Num0; - const Keycode last_num_key = Keycode.Num9; - if (keyCode >= first_num_key && keyCode <= last_num_key) - return Key.Number0 + (keyCode - first_num_key); - - // letters - const Keycode first_letter_key = Keycode.A; - const Keycode last_letter_key = Keycode.Z; - if (keyCode >= first_letter_key && keyCode <= last_letter_key) - return Key.A + (keyCode - first_letter_key); - - // function keys - const Keycode first_function_key = Keycode.F1; - const Keycode last_function_key = Keycode.F12; - if (keyCode >= first_function_key && keyCode <= last_function_key) - return Key.F1 + (keyCode - first_function_key); - - // keypad keys - const Keycode first_keypad_key = Keycode.Numpad0; - const Keycode last_key_pad_key = Keycode.NumpadDot; - if (keyCode >= first_keypad_key && keyCode <= last_key_pad_key) - return Key.Keypad0 + (keyCode - first_keypad_key); - - // direction keys - const Keycode first_direction_key = Keycode.DpadUp; - const Keycode last_direction_key = Keycode.DpadRight; - if (keyCode >= first_direction_key && keyCode <= last_direction_key) - return Key.Up + (keyCode - first_direction_key); - - // one to one mappings - switch (keyCode) - { - case Keycode.Back: - return Key.Escape; - - case Keycode.MediaPlayPause: - return Key.PlayPause; - - case Keycode.SoftLeft: - return Key.Left; - - case Keycode.SoftRight: - return Key.Right; - - case Keycode.Star: - return Key.KeypadMultiply; - - case Keycode.Backslash: - case Keycode.Pound: - return Key.BackSlash; // english keyboard layout - - case Keycode.Del: - return Key.BackSpace; - - case Keycode.ForwardDel: - return Key.Delete; - - case Keycode.Power: - return Key.Sleep; - - case Keycode.MoveHome: - return Key.Home; - - case Keycode.MoveEnd: - return Key.End; - - case Keycode.MediaPause: - return Key.Pause; - - case Keycode.MediaClose: - return Key.Stop; - - case Keycode.LeftBracket: - return Key.BracketLeft; - - case Keycode.RightBracket: - return Key.BracketRight; - - case Keycode.MediaPrevious: - return Key.TrackPrevious; - - case Keycode.MediaNext: - return Key.TrackNext; - - case Keycode.CtrlLeft: - return Key.ControlLeft; - - case Keycode.CtrlRight: - return Key.ControlRight; - - case Keycode.MetaLeft: - return Key.WinLeft; - - case Keycode.MetaRight: - return Key.WinRight; - - case Keycode.Equals: - return Key.Plus; - - case Keycode.At: - case Keycode.Apostrophe: - return Key.Quote; - - case Keycode.NumpadEnter: - return Key.KeypadEnter; - } - - if (Enum.TryParse(keyCode.ToString(), out Key key)) - return key; - - // this is the worst case scenario. Please note that the osu-framework keyboard handling cannot cope with Key.Unknown. - return Key.Unknown; - } - - private void enqueueInput(IInput input) - { - PendingInputs.Enqueue(input); - FrameStatistics.Increment(StatisticsCounterType.KeyEvents); - statistic_total_events.Value++; - } - } -} diff --git a/osu.Framework.Android/Input/AndroidMouseHandler.cs b/osu.Framework.Android/Input/AndroidMouseHandler.cs deleted file mode 100644 index f2ebda89f5..0000000000 --- a/osu.Framework.Android/Input/AndroidMouseHandler.cs +++ /dev/null @@ -1,319 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using Android.OS; -using Android.Views; -using osu.Framework.Bindables; -using osu.Framework.Extensions.EnumExtensions; -using osu.Framework.Input.StateChanges; -using osu.Framework.Platform; -using osu.Framework.Statistics; -using osuTK; -using osuTK.Input; - -namespace osu.Framework.Android.Input -{ - /// - /// Input handler for Android mouse-type devices: the and . - /// - public class AndroidMouseHandler : AndroidInputHandler - { - private static readonly GlobalStatistic statistic_total_events = GlobalStatistics.Get(StatisticGroupFor(), "Total events"); - - /// - /// Whether relative mode should be preferred when the window has focus, the cursor is contained and the OS cursor is not visible. - /// - /// - /// Only available in Android 8.0 Oreo () and up. - /// - public BindableBool UseRelativeMode { get; } = new BindableBool(false) - { - Description = "Allows for sensitivity adjustment and tighter control of input", - }; - - public BindableDouble Sensitivity { get; } = new BindableDouble(1) - { - MinValue = 0.1, - MaxValue = 10, - Precision = 0.01, - }; - - public override string Description => "Mouse"; - - public override bool IsActive => true; - - protected override IEnumerable HandledEventSources => - OperatingSystem.IsAndroidVersionAtLeast(26) - ? new[] { InputSourceType.Mouse, InputSourceType.MouseRelative, InputSourceType.Touchpad } - : new[] { InputSourceType.Mouse, InputSourceType.Touchpad }; - - private AndroidGameWindow window = null!; - - /// - /// Whether a non-relative mouse event has ever been received. - /// This is used as a starting location for relative movement. - /// - private bool absolutePositionReceived; - - public AndroidMouseHandler(AndroidGameView view) - : base(view) - { - } - - public override bool Initialize(GameHost host) - { - if (!base.Initialize(host)) - return false; - - if (!(host.Window is AndroidGameWindow androidWindow)) - return false; - - window = androidWindow; - - window.CursorStateChanged += updatePointerCapture; - - // it's possible that Android forcefully released capture if we were unfocused. - // so we update here when we get focus again. - View.FocusChange += (_, args) => - { - if (args.HasFocus) - updatePointerCapture(); - }; - - UseRelativeMode.BindValueChanged(_ => updatePointerCapture()); - - Enabled.BindValueChanged(enabled => - { -#nullable disable // Events misses nullable mark in .NET Android SDK (6.0.402) - if (enabled.NewValue) - { - View.GenericMotion += HandleGenericMotion; - View.Hover += HandleHover; - View.KeyDown += HandleKeyDown; - View.KeyUp += HandleKeyUp; - View.Touch += HandleTouch; - - // Pointer capture is only available on Android 8.0 and up - if (OperatingSystem.IsAndroidVersionAtLeast(26)) - View.CapturedPointer += HandleCapturedPointer; - } - else - { - View.GenericMotion -= HandleGenericMotion; - View.Hover -= HandleHover; - View.KeyDown -= HandleKeyDown; - View.KeyUp -= HandleKeyUp; - View.Touch -= HandleTouch; - - // Pointer capture is only available on Android 8.0 and up - if (OperatingSystem.IsAndroidVersionAtLeast(26)) - View.CapturedPointer -= HandleCapturedPointer; - } -#nullable restore - - updatePointerCapture(); - }, true); - - return true; - } - - public override void Reset() - { - Sensitivity.SetDefault(); - base.Reset(); - } - - private void updatePointerCapture() - { - // Pointer capture is only available on Android 8.0 and up - if (!OperatingSystem.IsAndroidVersionAtLeast(26)) - return; - - bool shouldCapture = - // check whether this handler is actually enabled. - Enabled.Value - // check whether the consumer has requested to use relative mode when feasible. - && UseRelativeMode.Value - // relative mode requires at least one absolute input to arrive, to gain an additional position to work with. - && absolutePositionReceived - // relative mode shouldn't ever be enabled if the framework or a consumer has chosen not to hide the cursor. - && window.CursorState.HasFlagFast(CursorState.Hidden); - - View.PointerCapture = shouldCapture; - } - - protected override ReturnCode OnKeyDown(Keycode keycode, KeyEvent e) - { - // some implementations might send Mouse1 and Mouse2 as keyboard keycodes, so we handle those here. - if (keycode.TryGetMouseButton(out var button)) - { - handleMouseButton(button, true); - return ReturnCode.Handled; - } - - return ReturnCode.Unhandled; - } - - protected override ReturnCode OnKeyUp(Keycode keycode, KeyEvent e) - { - if (keycode.TryGetMouseButton(out var button)) - { - handleMouseButton(button, false); - return ReturnCode.Handled; - } - - return ReturnCode.Unhandled; - } - - protected override bool OnHover(MotionEvent hoverEvent) - { - switch (hoverEvent.Action) - { - case MotionEventActions.HoverMove: - handleMouseMoveEvent(hoverEvent); - return true; - - // related to the mouse entering/exiting the view, - // and the mouse "losing" hover state as the screen is touched (the mouse pointer disappears) - // no need to log, and no need to handle them in any way here. - case MotionEventActions.HoverEnter: - case MotionEventActions.HoverExit: - return true; - - default: - return false; - } - } - - protected override bool OnTouch(MotionEvent touchEvent) - { - switch (touchEvent.Action) - { - case MotionEventActions.Move: - handleMouseMoveEvent(touchEvent); - return true; - - default: - return tryHandleButtonEvent(touchEvent); - } - } - - protected override bool OnGenericMotion(MotionEvent genericMotionEvent) - { - switch (genericMotionEvent.Action) - { - case MotionEventActions.Scroll: - handleScrollEvent(genericMotionEvent); - return true; - - default: - return tryHandleButtonEvent(genericMotionEvent); - } - } - - protected override bool OnCapturedPointer(MotionEvent capturedPointerEvent) - { - switch (capturedPointerEvent.Action) - { - case MotionEventActions.Move: - handleMouseMoveRelativeEvent(capturedPointerEvent); - return true; - - case MotionEventActions.Scroll: - handleScrollEvent(capturedPointerEvent); - return true; - - default: - return tryHandleButtonEvent(capturedPointerEvent); - } - } - - /// - /// Handles an event that could potentially be a mouse button event. - /// - private bool tryHandleButtonEvent(MotionEvent motionEvent) - { - if (OperatingSystem.IsAndroidVersionAtLeast(23)) - { - switch (motionEvent.Action) - { - case MotionEventActions.ButtonPress: - case MotionEventActions.ButtonRelease: - bool pressed = motionEvent.Action == MotionEventActions.ButtonPress; - - foreach (var button in motionEvent.ActionButton.ToMouseButtons()) - handleMouseButton(button, pressed); - - return true; - - // fired when buttons are pressed, but these don't have reliable ActionButton information - case MotionEventActions.Up: - case MotionEventActions.Down: - return true; - } - } - else // on older android versions where button events are not supported - { - switch (motionEvent.Action) - { - case MotionEventActions.Up: - case MotionEventActions.Down: - bool pressed = motionEvent.Action == MotionEventActions.Down; - handleMouseButton(MouseButton.Left, pressed); - return true; - } - } - - return false; - } - - private void handleScrollEvent(MotionEvent scrollEvent) - { - if (scrollEvent.TryGet(Axis.Hscroll, out float h) - && scrollEvent.TryGet(Axis.Vscroll, out float v)) - { - // Android reports horizontal scroll opposite of what framework expects. - enqueueInput(new MouseScrollRelativeInput { Delta = new Vector2(-h, v) }); - } - } - - private void handleMouseMoveEvent(MotionEvent mouseMoveEvent) - { - mouseMoveEvent.HandleHistorically(apply); - - absolutePositionReceived = true; - - // we may lose pointer capture if we lose focus / the app goes to the background, - // so we use this opportunity to update capture if the user has requested it. - updatePointerCapture(); - - void apply(MotionEvent e, int historyPosition) - { - if (e.TryGetPosition(out var position, historyPosition)) - enqueueInput(new MousePositionAbsoluteInput { Position = position }); - } - } - - private void handleMouseMoveRelativeEvent(MotionEvent capturedPointerEvent) - { - capturedPointerEvent.HandleHistorically(apply); - - void apply(MotionEvent e, int historyPosition) - { - if (e.TryGetPosition(out var delta, historyPosition)) - enqueueInput(new MousePositionRelativeInput { Delta = delta * (float)Sensitivity.Value }); - } - } - - private void handleMouseButton(MouseButton button, bool pressed) => enqueueInput(new MouseButtonInput(button, pressed)); - - private void enqueueInput(IInput input) - { - PendingInputs.Enqueue(input); - FrameStatistics.Increment(StatisticsCounterType.MouseEvents); - statistic_total_events.Value++; - } - } -} diff --git a/osu.Framework.Android/Input/AndroidTextInput.cs b/osu.Framework.Android/Input/AndroidTextInput.cs deleted file mode 100644 index eab5a518d5..0000000000 --- a/osu.Framework.Android/Input/AndroidTextInput.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using Android.Views; -using osu.Framework.Input; - -namespace osu.Framework.Android.Input -{ - internal class AndroidTextInput : TextInputSource - { - private readonly AndroidGameView view; - - public AndroidTextInput(AndroidGameView view) - { - this.view = view; - } - - private void commitText(string text) - { - TriggerTextInput(text); - } - - private void keyDown(Keycode arg, KeyEvent e) - { - if (e.UnicodeChar != 0) - TriggerTextInput(((char)e.UnicodeChar).ToString()); - } - - protected override void ActivateTextInput(bool allowIme) - { - view.KeyDown += keyDown; - view.CommitText += commitText; - view.StartTextInput(); - } - - protected override void EnsureTextInputActivated(bool allowIme) - { - view.StartTextInput(); - } - - protected override void DeactivateTextInput() - { - view.KeyDown -= keyDown; - view.CommitText -= commitText; - view.StopTextInput(); - } - } -} diff --git a/osu.Framework.Android/Input/AndroidTouchHandler.cs b/osu.Framework.Android/Input/AndroidTouchHandler.cs deleted file mode 100644 index d3e01d2405..0000000000 --- a/osu.Framework.Android/Input/AndroidTouchHandler.cs +++ /dev/null @@ -1,156 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using Android.Views; -using osu.Framework.Input; -using osu.Framework.Input.StateChanges; -using osu.Framework.Platform; -using osu.Framework.Statistics; -using osuTK; -using osuTK.Input; - -namespace osu.Framework.Android.Input -{ - public class AndroidTouchHandler : AndroidInputHandler - { - private static readonly GlobalStatistic statistic_touch_events = GlobalStatistics.Get(StatisticGroupFor(), "Touch events"); - private static readonly GlobalStatistic statistic_hover_events = GlobalStatistics.Get(StatisticGroupFor(), "Hover events"); - - public override bool IsActive => true; - - protected override IEnumerable HandledEventSources => - OperatingSystem.IsAndroidVersionAtLeast(23) - ? new[] { InputSourceType.BluetoothStylus, InputSourceType.Stylus, InputSourceType.Touchscreen } - : new[] { InputSourceType.Stylus, InputSourceType.Touchscreen }; - - public AndroidTouchHandler(AndroidGameView view) - : base(view) - { - } - - public override bool Initialize(GameHost host) - { - if (!base.Initialize(host)) - return false; - - Enabled.BindValueChanged(enabled => - { -#nullable disable // Events misses nullable mark in .NET Android SDK (6.0.402) - if (enabled.NewValue) - { - View.Hover += HandleHover; - View.Touch += HandleTouch; - } - else - { - View.Hover -= HandleHover; - View.Touch -= HandleTouch; -#nullable restore - } - }, true); - - return true; - } - - protected override bool OnTouch(MotionEvent touchEvent) - { - switch (touchEvent.ActionMasked) - { - // MotionEventActions.Down arrives at the beginning of a touch event chain and implies the 0th pointer is pressed. - // ActionIndex is generally not valid here. - case MotionEventActions.Down: - applyTouchInput(touchEvent, HISTORY_CURRENT, 0); - return true; - - // events that apply only to the ActionIndex pointer (other pointers' states remain unchanged) - case MotionEventActions.PointerDown: - case MotionEventActions.PointerUp: - applyTouchInput(touchEvent, HISTORY_CURRENT, touchEvent.ActionIndex); - return true; - - // events that apply to every pointer (up to PointerCount). - case MotionEventActions.Move: - case MotionEventActions.Up: - case MotionEventActions.Cancel: - touchEvent.HandleHistoricallyPerPointer(applyTouchInput); - return true; - - default: - return false; - } - } - - protected override bool OnHover(MotionEvent hoverEvent) - { - hoverEvent.HandleHistorically(apply); - - if (OperatingSystem.IsAndroidVersionAtLeast(23)) - enqueueInput(new MouseButtonInput(MouseButton.Right, hoverEvent.IsButtonPressed(MotionEventButtonState.StylusPrimary))); - - // TODO: handle stylus events based on hoverEvent.Action - // stylus should probably have it's own handler. - return true; - - void apply(MotionEvent e, int historyPosition) - { - if (tryGetEventPosition(e, historyPosition, 0, out var position)) - { - enqueueInput(new MousePositionAbsoluteInput { Position = position }); - statistic_hover_events.Value++; - } - } - } - - private void applyTouchInput(MotionEvent touchEvent, int historyPosition, int pointerIndex) - { - if (tryGetEventTouch(touchEvent, historyPosition, pointerIndex, out var touch)) - { - enqueueInput(new TouchInput(touch, touchEvent.ActionMasked.IsTouchDownAction())); - statistic_touch_events.Value++; - } - } - - private bool tryGetEventTouch(MotionEvent motionEvent, int historyPosition, int pointerIndex, out Touch touch) - { - if (tryGetTouchSource(motionEvent.GetPointerId(pointerIndex), out var touchSource) - && tryGetEventPosition(motionEvent, historyPosition, pointerIndex, out var position)) - { - touch = new Touch(touchSource, position); - return true; - } - - touch = new Touch(); - return false; - - bool tryGetTouchSource(int pointerId, out TouchSource source) - { - source = (TouchSource)pointerId; - return source >= TouchSource.Touch1 && source <= TouchSource.Touch10; - } - } - - private bool tryGetEventPosition(MotionEvent motionEvent, int historyPosition, int pointerIndex, out Vector2 position) - { - if (motionEvent.TryGet(Axis.X, out float x, historyPosition, pointerIndex) - && motionEvent.TryGet(Axis.Y, out float y, historyPosition, pointerIndex)) - { - position = new Vector2(x * View.ScaleX, y * View.ScaleY); - return true; - } - - // in empirical testing, `MotionEvent.Get{X,Y}()` methods can return NaN positions early on in the android activity's lifetime. - // these nonsensical inputs then cause issues later down the line when they are converted into framework inputs. - // as there is really nothing to recover from such inputs, drop them entirely. - position = Vector2.Zero; - return false; - } - - private void enqueueInput(IInput input) - { - PendingInputs.Enqueue(input); - FrameStatistics.Increment(StatisticsCounterType.TouchEvents); - } - } -} diff --git a/osu.Framework.Android/arm64-v8a/libbass.so b/osu.Framework.Android/arm64-v8a/libbass.so index cdfa700d6d..e33e7b7a8e 100644 Binary files a/osu.Framework.Android/arm64-v8a/libbass.so and b/osu.Framework.Android/arm64-v8a/libbass.so differ diff --git a/osu.Framework.Android/arm64-v8a/libbassmix.so b/osu.Framework.Android/arm64-v8a/libbassmix.so index cd1d6b4d5a..6bb2a1d10f 100644 Binary files a/osu.Framework.Android/arm64-v8a/libbassmix.so and b/osu.Framework.Android/arm64-v8a/libbassmix.so differ diff --git a/osu.Framework.Android/armeabi-v7a/libbass.so b/osu.Framework.Android/armeabi-v7a/libbass.so index 0e6a6bc6a8..cd1734f12a 100644 Binary files a/osu.Framework.Android/armeabi-v7a/libbass.so and b/osu.Framework.Android/armeabi-v7a/libbass.so differ diff --git a/osu.Framework.Android/armeabi-v7a/libbassmix.so b/osu.Framework.Android/armeabi-v7a/libbassmix.so index f75b9e206f..28733a0c04 100644 Binary files a/osu.Framework.Android/armeabi-v7a/libbassmix.so and b/osu.Framework.Android/armeabi-v7a/libbassmix.so differ diff --git a/osu.Framework.Android/osu.Framework.Android.csproj b/osu.Framework.Android/osu.Framework.Android.csproj index 1969356ced..54e6baf66a 100644 --- a/osu.Framework.Android/osu.Framework.Android.csproj +++ b/osu.Framework.Android/osu.Framework.Android.csproj @@ -18,7 +18,7 @@ - - + + diff --git a/osu.Framework.Android/x86/libbass.so b/osu.Framework.Android/x86/libbass.so index a439bf1858..9a205f697c 100644 Binary files a/osu.Framework.Android/x86/libbass.so and b/osu.Framework.Android/x86/libbass.so differ diff --git a/osu.Framework.Android/x86/libbassmix.so b/osu.Framework.Android/x86/libbassmix.so index 645e1993ef..61ccd24375 100644 Binary files a/osu.Framework.Android/x86/libbassmix.so and b/osu.Framework.Android/x86/libbassmix.so differ diff --git a/osu.Framework.Benchmarks/BenchmarkSRGBColourMultiplication.cs b/osu.Framework.Benchmarks/BenchmarkSRGBColourMultiplication.cs new file mode 100644 index 0000000000..a3e9012a91 --- /dev/null +++ b/osu.Framework.Benchmarks/BenchmarkSRGBColourMultiplication.cs @@ -0,0 +1,62 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using BenchmarkDotNet.Attributes; +using osu.Framework.Graphics.Colour; +using osuTK.Graphics; + +namespace osu.Framework.Benchmarks +{ + public class BenchmarkSRGBColourMultiplication : BenchmarkTest + { + private static readonly SRGBColour white = new SRGBColour + { + SRGB = new Color4(1f, 1f, 1f, 1f) + }; + + private static readonly SRGBColour white_with_opacity = new SRGBColour + { + SRGB = new Color4(1f, 1f, 1f, 0.5f) + }; + + private static readonly SRGBColour gray = new SRGBColour + { + SRGB = Color4.Gray + }; + + private static readonly SRGBColour gray_light = new SRGBColour + { + SRGB = Color4.LightGray + }; + + [Benchmark] + public SRGBColour MultiplyNonWhite() + { + return gray * gray_light; + } + + [Benchmark] + public SRGBColour MultiplyWhite() + { + return gray * white; + } + + [Benchmark] + public SRGBColour MultiplyWhiteWithOpacity() + { + return gray * white_with_opacity; + } + + [Benchmark] + public SRGBColour MultiplyConstOne() + { + return gray * 1; + } + + [Benchmark] + public SRGBColour MultiplyConstNonOne() + { + return gray * 0.5f; + } + } +} diff --git a/osu.Framework.NativeLibs/runtimes/linux-x64/native/libbass.so b/osu.Framework.NativeLibs/runtimes/linux-x64/native/libbass.so index e49c7740ef..3317ed4c64 100644 Binary files a/osu.Framework.NativeLibs/runtimes/linux-x64/native/libbass.so and b/osu.Framework.NativeLibs/runtimes/linux-x64/native/libbass.so differ diff --git a/osu.Framework.NativeLibs/runtimes/linux-x64/native/libbassmix.so b/osu.Framework.NativeLibs/runtimes/linux-x64/native/libbassmix.so index 1026b4a922..b90214f49b 100644 Binary files a/osu.Framework.NativeLibs/runtimes/linux-x64/native/libbassmix.so and b/osu.Framework.NativeLibs/runtimes/linux-x64/native/libbassmix.so differ diff --git a/osu.Framework.NativeLibs/runtimes/linux-x86/native/libbass.so b/osu.Framework.NativeLibs/runtimes/linux-x86/native/libbass.so index 175369aa7a..74a204d7ab 100644 Binary files a/osu.Framework.NativeLibs/runtimes/linux-x86/native/libbass.so and b/osu.Framework.NativeLibs/runtimes/linux-x86/native/libbass.so differ diff --git a/osu.Framework.NativeLibs/runtimes/linux-x86/native/libbassmix.so b/osu.Framework.NativeLibs/runtimes/linux-x86/native/libbassmix.so index 9ece95cfbd..81c8ecbe04 100644 Binary files a/osu.Framework.NativeLibs/runtimes/linux-x86/native/libbassmix.so and b/osu.Framework.NativeLibs/runtimes/linux-x86/native/libbassmix.so differ diff --git a/osu.Framework.NativeLibs/runtimes/osx/native/libbass.dylib b/osu.Framework.NativeLibs/runtimes/osx/native/libbass.dylib index 67782b93b4..9bf2a506cc 100644 Binary files a/osu.Framework.NativeLibs/runtimes/osx/native/libbass.dylib and b/osu.Framework.NativeLibs/runtimes/osx/native/libbass.dylib differ diff --git a/osu.Framework.NativeLibs/runtimes/osx/native/libbass_fx.dylib b/osu.Framework.NativeLibs/runtimes/osx/native/libbass_fx.dylib index 44df49e58c..e4ecd80332 100644 Binary files a/osu.Framework.NativeLibs/runtimes/osx/native/libbass_fx.dylib and b/osu.Framework.NativeLibs/runtimes/osx/native/libbass_fx.dylib differ diff --git a/osu.Framework.NativeLibs/runtimes/osx/native/libbassmix.dylib b/osu.Framework.NativeLibs/runtimes/osx/native/libbassmix.dylib index 763ad0d68b..7bebf7daf5 100644 Binary files a/osu.Framework.NativeLibs/runtimes/osx/native/libbassmix.dylib and b/osu.Framework.NativeLibs/runtimes/osx/native/libbassmix.dylib differ diff --git a/osu.Framework.NativeLibs/runtimes/win-arm64/native/bass.dll b/osu.Framework.NativeLibs/runtimes/win-arm64/native/bass.dll index 08bba74756..a83988fccf 100644 Binary files a/osu.Framework.NativeLibs/runtimes/win-arm64/native/bass.dll and b/osu.Framework.NativeLibs/runtimes/win-arm64/native/bass.dll differ diff --git a/osu.Framework.NativeLibs/runtimes/win-arm64/native/bassmix.dll b/osu.Framework.NativeLibs/runtimes/win-arm64/native/bassmix.dll index b35f5f4c7b..6e440499ab 100644 Binary files a/osu.Framework.NativeLibs/runtimes/win-arm64/native/bassmix.dll and b/osu.Framework.NativeLibs/runtimes/win-arm64/native/bassmix.dll differ diff --git a/osu.Framework.NativeLibs/runtimes/win-x64/native/bass.dll b/osu.Framework.NativeLibs/runtimes/win-x64/native/bass.dll index 84298be2d4..334093c659 100644 Binary files a/osu.Framework.NativeLibs/runtimes/win-x64/native/bass.dll and b/osu.Framework.NativeLibs/runtimes/win-x64/native/bass.dll differ diff --git a/osu.Framework.NativeLibs/runtimes/win-x64/native/bass_fx.dll b/osu.Framework.NativeLibs/runtimes/win-x64/native/bass_fx.dll index 23e3a6e1e5..339856160f 100644 Binary files a/osu.Framework.NativeLibs/runtimes/win-x64/native/bass_fx.dll and b/osu.Framework.NativeLibs/runtimes/win-x64/native/bass_fx.dll differ diff --git a/osu.Framework.NativeLibs/runtimes/win-x64/native/bassmix.dll b/osu.Framework.NativeLibs/runtimes/win-x64/native/bassmix.dll index 6647157949..52a3b791df 100644 Binary files a/osu.Framework.NativeLibs/runtimes/win-x64/native/bassmix.dll and b/osu.Framework.NativeLibs/runtimes/win-x64/native/bassmix.dll differ diff --git a/osu.Framework.NativeLibs/runtimes/win-x86/native/bass.dll b/osu.Framework.NativeLibs/runtimes/win-x86/native/bass.dll index 437a0b63e6..789d141634 100644 Binary files a/osu.Framework.NativeLibs/runtimes/win-x86/native/bass.dll and b/osu.Framework.NativeLibs/runtimes/win-x86/native/bass.dll differ diff --git a/osu.Framework.NativeLibs/runtimes/win-x86/native/bass_fx.dll b/osu.Framework.NativeLibs/runtimes/win-x86/native/bass_fx.dll index a31f20ffdd..18649fe6b4 100644 Binary files a/osu.Framework.NativeLibs/runtimes/win-x86/native/bass_fx.dll and b/osu.Framework.NativeLibs/runtimes/win-x86/native/bass_fx.dll differ diff --git a/osu.Framework.NativeLibs/runtimes/win-x86/native/bassmix.dll b/osu.Framework.NativeLibs/runtimes/win-x86/native/bassmix.dll index 9207cb1bfa..e55f25596b 100644 Binary files a/osu.Framework.NativeLibs/runtimes/win-x86/native/bassmix.dll and b/osu.Framework.NativeLibs/runtimes/win-x86/native/bassmix.dll differ diff --git a/osu.Framework.NativeLibs/scripts/ffmpeg/build-android.sh b/osu.Framework.NativeLibs/scripts/ffmpeg/build-android.sh new file mode 100755 index 0000000000..73431886e6 --- /dev/null +++ b/osu.Framework.NativeLibs/scripts/ffmpeg/build-android.sh @@ -0,0 +1,96 @@ +#!/bin/bash +set -eu + +# Android ABI level to target. 21 is the minimum supported by the NDK +# See https://apilevels.com for info on what the API level means +API_LEVEL="21" + +if [ -z "${ANDROID_NDK_ROOT:-}" ]; then + echo "ANDROID_NDK_ROOT must be set" + exit 1 +fi + +pushd "$(dirname "$0")" > /dev/null +SCRIPT_PATH=$(pwd) +popd > /dev/null +source "$SCRIPT_PATH/common.sh" + +if [ -z "${arch-}" ]; then + PS3='Build for which arch? ' + select arch in "armeabi-v7a" "arm64-v8a" "x86" "x86_64"; do + if [ -z "$arch" ]; then + echo "invalid option" + else + break + fi + done +fi + +cpu='' +cross_arch='' +cc='' +cflags='' +asm_options='' + +case $arch in + armeabi-v7a) + cpu='armv7-a' + cross_arch='armv7-a' + cc="armv7a-linux-androideabi${API_LEVEL}-clang" + cflags='-mfpu=neon -mfloat-abi=softfp' + asm_options='--enable-neon --enable-asm --enable-inline-asm' + ;; + + arm64-v8a) + cpu='armv8-a' + cross_arch='aarch64' + cc="aarch64-linux-android${API_LEVEL}-clang" + asm_options='--enable-neon --enable-asm --enable-inline-asm' + ;; + + x86) + cpu='i686' + cross_arch='i686' + cc="i686-linux-android${API_LEVEL}-clang" + # ASM has text relocations + asm_options='--disable-asm' + ;; + + x86_64) + cpu='x86-64' + cross_arch='x86_64' + cc="x86_64-linux-android${API_LEVEL}-clang" + asm_options='--enable-asm --enable-inline-asm' + ;; +esac + +toolchain_path="$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64" +bin_path="$toolchain_path/bin" + +FFMPEG_FLAGS+=( + --enable-jni + + --enable-cross-compile + --target-os=android + --cpu=$cpu + --arch=$cross_arch + --sysroot="$toolchain_path/sysroot" + --cc="$bin_path/$cc" + --cxx="$bin_path/$cc++" + --ld="$bin_path/$cc" + --ar="$bin_path/llvm-ar" + --as="$bin_path/$cc" + --nm="$bin_path/llvm-nm" + --ranlib="$bin_path/llvm-ranlib" + --strip="$bin_path/llvm-strip" + --x86asmexe="$bin_path/yasm" + --extra-cflags="-fstrict-aliasing -fPIC -DANDROID -D__ANDROID__ $cflags" + + $asm_options +) + +pushd . > /dev/null +prep_ffmpeg "android-$arch" +build_ffmpeg +popd > /dev/null + diff --git a/osu.Framework.NativeLibs/scripts/ffmpeg/build-iOS.sh b/osu.Framework.NativeLibs/scripts/ffmpeg/build-iOS.sh new file mode 100755 index 0000000000..7b5daeb0ea --- /dev/null +++ b/osu.Framework.NativeLibs/scripts/ffmpeg/build-iOS.sh @@ -0,0 +1,94 @@ +#!/bin/bash +set -eu + +# Minimum iOS version. This should be the same as in osu.Framework.iOS.csproj +DEPLOYMENT_TARGET="13.4" + +pushd "$(dirname "$0")" > /dev/null +SCRIPT_PATH=$(pwd) +popd > /dev/null +source "$SCRIPT_PATH/common.sh" + +if [ -z "${GAS_PREPROCESSOR:-}" ]; then + echo "GAS_PREPROCESSOR must be set" + exit 1 +fi + +if [ -z "${arch-}" ]; then + PS3='Build for which arch? ' + select arch in "arm64" "simulator-arm64" "simulator-x86_64"; do + if [ -z "$arch" ]; then + echo "invalid option" + else + break + fi + done +fi + +cpu='' +cross_arch='' +cc='' +as='' +sysroot='' +cflags='' + +case $arch in + arm64) + cpu='armv8-a' + cross_arch='arm64' + cc='xcrun -sdk iphoneos clang' + as="$GAS_PREPROCESSOR -arch arm64 -- $cc" + sysroot=$(xcrun -sdk iphoneos --show-sdk-path) + cflags="-mios-version-min=$DEPLOYMENT_TARGET" + ;; + + simulator-arm64) + cpu='armv8-a' + cross_arch='arm64' + cc='xcrun -sdk iphonesimulator clang' + as="$GAS_PREPROCESSOR -arch arm64 -- $cc" + sysroot=$(xcrun -sdk iphonesimulator --show-sdk-path) + cflags="-mios-simulator-version-min=$DEPLOYMENT_TARGET" + ;; + + simulator-x86_64) + cpu='x86-64' + cross_arch='x86_64' + cc='xcrun -sdk iphonesimulator clang' + as="$GAS_PREPROCESSOR -- $cc" + sysroot=$(xcrun -sdk iphonesimulator --show-sdk-path) + cflags="-mios-simulator-version-min=$DEPLOYMENT_TARGET" + ;; +esac + +FFMPEG_FLAGS+=( + --enable-pic + --enable-videotoolbox + --enable-hwaccel=h264_videotoolbox + --enable-hwaccel=hevc_videotoolbox + --enable-hwaccel=vp9_videotoolbox + + --enable-cross-compile + --target-os=darwin + --cpu=$cpu + --arch=$cross_arch + --cc="$cc" + --as="$as" + --extra-cflags="-isysroot $sysroot -arch $cross_arch $cflags" + --extra-ldflags="-isysroot $sysroot -arch $cross_arch $cflags" + + --install-name-dir='@rpath' +) + +pushd . > /dev/null +prep_ffmpeg "iOS-$arch" +# Change the `-install_name` from +# "/libavcodec.dylib.61" to "/libavcodec.framework/libavcodec". +# This is required for framework bundles and xcframeworks to load correctly. +patch -p1 < "$SCRIPT_PATH/iOS-set-install-name-for-xcframework.patch" +build_ffmpeg +popd > /dev/null + +# Remove symlinks, keep only libraries with full version in their name +find "iOS-$arch" -type l -delete + diff --git a/osu.Framework.NativeLibs/scripts/ffmpeg/build-linux.sh b/osu.Framework.NativeLibs/scripts/ffmpeg/build-linux.sh index d3d52129b2..5044d4e061 100755 --- a/osu.Framework.NativeLibs/scripts/ffmpeg/build-linux.sh +++ b/osu.Framework.NativeLibs/scripts/ffmpeg/build-linux.sh @@ -7,22 +7,11 @@ popd > /dev/null source "$SCRIPT_PATH/common.sh" FFMPEG_FLAGS+=( - # --enable-vaapi - # --enable-vdpau - # --enable-hwaccel='h264_vaapi,h264_vdpau' - # --enable-hwaccel='hevc_vaapi,hevc_vdpau' - # --enable-hwaccel='vp8_vaapi,vp8_vdpau' - # --enable-hwaccel='vp9_vaapi,vp9_vdpau' - --target-os=linux ) pushd . > /dev/null prep_ffmpeg linux-x64 -# Apply patch from upstream to fix errors with new binutils versions: -# Ticket: https://fftrac-bg.ffmpeg.org/ticket/10405 -# This patch should be removed when FFmpeg is updated to >=6.1 -patch -p1 < "$SCRIPT_PATH/fix-binutils-2.41.patch" build_ffmpeg popd > /dev/null diff --git a/osu.Framework.NativeLibs/scripts/ffmpeg/build-win.sh b/osu.Framework.NativeLibs/scripts/ffmpeg/build-win.sh index ded8520dab..8dff16f670 100755 --- a/osu.Framework.NativeLibs/scripts/ffmpeg/build-win.sh +++ b/osu.Framework.NativeLibs/scripts/ffmpeg/build-win.sh @@ -54,15 +54,6 @@ FFMPEG_FLAGS+=( pushd . > /dev/null prep_ffmpeg "win-$arch" - -# FFmpeg doesn't do this correctly when building, so we do it instead. -# A newer FFmpeg release might make this unnecessary. -echo '-> Creating resource objects...' -make .version -for res in lib*/*res.rc; do - "${cross_prefix}windres" -I. "$res" "${res%.rc}.o" -done - build_ffmpeg popd > /dev/null diff --git a/osu.Framework.NativeLibs/scripts/ffmpeg/combine_dylibs.sh b/osu.Framework.NativeLibs/scripts/ffmpeg/combine_dylibs.sh index 9eb2ae0372..6965a54499 100755 --- a/osu.Framework.NativeLibs/scripts/ffmpeg/combine_dylibs.sh +++ b/osu.Framework.NativeLibs/scripts/ffmpeg/combine_dylibs.sh @@ -1,13 +1,24 @@ #!/bin/bash set -eu +if [ -z "${platform-}" ]; then + PS3='Combine binaries for which platform? ' + select platform in "macOS" "iOS"; do + if [ -z "$platform" ]; then + echo "invalid option" + else + break + fi + done +fi + pushd . > /dev/null -mkdir -p macOS-universal -cd macOS-arm64 +mkdir -p $platform-universal +cd $platform-arm64 for lib_arm in *.dylib; do - lib_x86="../macOS-x86_64/$lib_arm" + lib_x86="../$platform-x86_64/$lib_arm" echo "-> Creating universal $lib_arm..." - lipo -create "$lib_arm" "$lib_x86" -output "../macOS-universal/$lib_arm" + lipo -create "$lib_arm" "$lib_x86" -output "../$platform-universal/$lib_arm" done popd > /dev/null diff --git a/osu.Framework.NativeLibs/scripts/ffmpeg/common.sh b/osu.Framework.NativeLibs/scripts/ffmpeg/common.sh index 7753867dca..522b177112 100755 --- a/osu.Framework.NativeLibs/scripts/ffmpeg/common.sh +++ b/osu.Framework.NativeLibs/scripts/ffmpeg/common.sh @@ -1,12 +1,13 @@ #!/bin/bash set -eu -FFMPEG_VERSION=4.3.3 +FFMPEG_VERSION="7.0" FFMPEG_FILE="ffmpeg-$FFMPEG_VERSION.tar.gz" FFMPEG_FLAGS=( # General options --disable-static --enable-shared + --disable-debug --disable-all --disable-autodetect --enable-lto @@ -50,14 +51,14 @@ function prep_ffmpeg() { echo "-> $build_dir already exists, skipping unpacking." fi - echo "-> Configuring..." cd "$build_dir" - ./configure "${FFMPEG_FLAGS[@]}" } function build_ffmpeg() { - echo "-> Building using $CORES threads..." + echo "-> Configuring..." + ./configure "${FFMPEG_FLAGS[@]}" + echo "-> Building using $CORES threads..." make -j$CORES make install-libs } diff --git a/osu.Framework.NativeLibs/scripts/ffmpeg/create-xcframeworks.sh b/osu.Framework.NativeLibs/scripts/ffmpeg/create-xcframeworks.sh new file mode 100755 index 0000000000..68629e4463 --- /dev/null +++ b/osu.Framework.NativeLibs/scripts/ffmpeg/create-xcframeworks.sh @@ -0,0 +1,54 @@ +#!/bin/bash +set -eu + +# See build-iOS.sh +DEPLOYMENT_TARGET="13.4" + +for arch in "arm64" "simulator-universal"; do + pushd . > /dev/null + cd "iOS-$arch" + for f in *.*.*.*.dylib; do + [ -f "$f" ] || continue + + # [avcodec].58.10.72.dylib + lib_name="${f%.*.*.*.*}" + + # avcodec.[58.10.72].dylib + tmp=${f#*.} + version_string="${tmp%.*}" + + framework_dir="$lib_name.framework" + mkdir "$framework_dir" + + mv -v "$f" "$framework_dir/$lib_name" + + plist_file="$framework_dir/Info.plist" + + plutil -create xml1 "$plist_file" + plutil -insert CFBundleDevelopmentRegion -string en "$plist_file" + plutil -insert CFBundleExecutable -string "$lib_name" "$plist_file" + plutil -insert CFBundleIdentifier -string "sh.ppy.osu.Framework.iOS.$lib_name" "$plist_file" + plutil -insert CFBundleInfoDictionaryVersion -string '6.0' "$plist_file" + plutil -insert CFBundleName -string "$lib_name" "$plist_file" + plutil -insert CFBundlePackageType -string FMWK "$plist_file" + plutil -insert CFBundleShortVersionString -string "$version_string" "$plist_file" + plutil -insert CFBundleVersion -string "$version_string" "$plist_file" + plutil -insert MinimumOSVersion -string "$DEPLOYMENT_TARGET" "$plist_file" + plutil -insert CFBundleSupportedPlatforms -array "$plist_file" + plutil -insert CFBundleSupportedPlatforms -string iPhoneOS -append "$plist_file" + + done + popd > /dev/null +done + +pushd . > /dev/null +mkdir -p iOS-xcframework +cd iOS-arm64 +for framework_arm in *.framework; do + xcodebuild -create-xcframework \ + -framework "$framework_arm" \ + -framework "../iOS-simulator-universal/$framework_arm" \ + -output "../iOS-xcframework/${framework_arm%.framework}.xcframework" +done +popd > /dev/null + diff --git a/osu.Framework.NativeLibs/scripts/ffmpeg/fix-binutils-2.41.patch b/osu.Framework.NativeLibs/scripts/ffmpeg/fix-binutils-2.41.patch deleted file mode 100644 index 33fd3d484f..0000000000 --- a/osu.Framework.NativeLibs/scripts/ffmpeg/fix-binutils-2.41.patch +++ /dev/null @@ -1,76 +0,0 @@ -From effadce6c756247ea8bae32dc13bb3e6f464f0eb Mon Sep 17 00:00:00 2001 -From: =?utf8?q?R=C3=A9mi=20Denis-Courmont?= -Date: Sun, 16 Jul 2023 18:18:02 +0300 -Subject: [PATCH] avcodec/x86/mathops: clip constants used with shift - instructions within inline assembly - -Fixes assembling with binutil as >= 2.41 - -Signed-off-by: James Almer ---- - libavcodec/x86/mathops.h | 26 +++++++++++++++++++++++--- - 1 file changed, 23 insertions(+), 3 deletions(-) - -diff --git a/libavcodec/x86/mathops.h b/libavcodec/x86/mathops.h -index 6298f5ed19..ca7e2dffc1 100644 ---- a/libavcodec/x86/mathops.h -+++ b/libavcodec/x86/mathops.h -@@ -35,12 +35,20 @@ - static av_always_inline av_const int MULL(int a, int b, unsigned shift) - { - int rt, dummy; -+ if (__builtin_constant_p(shift)) - __asm__ ( - "imull %3 \n\t" - "shrdl %4, %%edx, %%eax \n\t" - :"=a"(rt), "=d"(dummy) -- :"a"(a), "rm"(b), "ci"((uint8_t)shift) -+ :"a"(a), "rm"(b), "i"(shift & 0x1F) - ); -+ else -+ __asm__ ( -+ "imull %3 \n\t" -+ "shrdl %4, %%edx, %%eax \n\t" -+ :"=a"(rt), "=d"(dummy) -+ :"a"(a), "rm"(b), "c"((uint8_t)shift) -+ ); - return rt; - } - -@@ -113,19 +121,31 @@ __asm__ volatile(\ - // avoid +32 for shift optimization (gcc should do that ...) - #define NEG_SSR32 NEG_SSR32 - static inline int32_t NEG_SSR32( int32_t a, int8_t s){ -+ if (__builtin_constant_p(s)) - __asm__ ("sarl %1, %0\n\t" - : "+r" (a) -- : "ic" ((uint8_t)(-s)) -+ : "i" (-s & 0x1F) - ); -+ else -+ __asm__ ("sarl %1, %0\n\t" -+ : "+r" (a) -+ : "c" ((uint8_t)(-s)) -+ ); - return a; - } - - #define NEG_USR32 NEG_USR32 - static inline uint32_t NEG_USR32(uint32_t a, int8_t s){ -+ if (__builtin_constant_p(s)) - __asm__ ("shrl %1, %0\n\t" - : "+r" (a) -- : "ic" ((uint8_t)(-s)) -+ : "i" (-s & 0x1F) - ); -+ else -+ __asm__ ("shrl %1, %0\n\t" -+ : "+r" (a) -+ : "c" ((uint8_t)(-s)) -+ ); - return a; - } - --- -2.30.2 - diff --git a/osu.Framework.NativeLibs/scripts/ffmpeg/iOS-set-install-name-for-xcframework.patch b/osu.Framework.NativeLibs/scripts/ffmpeg/iOS-set-install-name-for-xcframework.patch new file mode 100644 index 0000000000..9a34485807 --- /dev/null +++ b/osu.Framework.NativeLibs/scripts/ffmpeg/iOS-set-install-name-for-xcframework.patch @@ -0,0 +1,13 @@ +diff --git a/configure b/configure +index 4f5353f84b..dfddd13c9d 100755 +--- a/configure ++++ b/configure +@@ -5738,7 +5738,7 @@ case $target_os in + darwin) + enabled ppc && add_asflags -force_cpusubtype_ALL + install_name_dir_default='$(SHLIBDIR)' +- SHFLAGS='-dynamiclib -Wl,-single_module -Wl,-install_name,$(INSTALL_NAME_DIR)/$(SLIBNAME_WITH_MAJOR),-current_version,$(LIBVERSION),-compatibility_version,$(LIBMAJOR)' ++ SHFLAGS='-dynamiclib -Wl,-single_module -Wl,-install_name,$(INSTALL_NAME_DIR)/$(SLIBPREF)$(FULLNAME).framework/$(SLIBPREF)$(FULLNAME),-current_version,$(LIBVERSION),-compatibility_version,$(LIBMAJOR)' + enabled x86_32 && append SHFLAGS -Wl,-read_only_relocs,suppress + strip="${strip} -x" + add_ldflags -Wl,-dynamic,-search_paths_first diff --git a/osu.Framework.Templates/templates/template-empty/TemplateGame.iOS/TemplateGame.iOS.csproj b/osu.Framework.Templates/templates/template-empty/TemplateGame.iOS/TemplateGame.iOS.csproj index 2cca8636a2..dc86d4e4d3 100644 --- a/osu.Framework.Templates/templates/template-empty/TemplateGame.iOS/TemplateGame.iOS.csproj +++ b/osu.Framework.Templates/templates/template-empty/TemplateGame.iOS/TemplateGame.iOS.csproj @@ -10,12 +10,6 @@ so there's nothing to be worried about. --> MT7091 - - ios-arm64 - - - iossimulator-x64 - diff --git a/osu.Framework.Templates/templates/template-flappy/FlappyDon.iOS/FlappyDon.iOS.csproj b/osu.Framework.Templates/templates/template-flappy/FlappyDon.iOS/FlappyDon.iOS.csproj index a53d5b0248..ff0034760a 100644 --- a/osu.Framework.Templates/templates/template-flappy/FlappyDon.iOS/FlappyDon.iOS.csproj +++ b/osu.Framework.Templates/templates/template-flappy/FlappyDon.iOS/FlappyDon.iOS.csproj @@ -10,12 +10,6 @@ so there's nothing to be worried about. --> MT7091 - - ios-arm64 - - - iossimulator-x64 - diff --git a/osu.Framework.Tests/Audio/BassAudioMixerTest.cs b/osu.Framework.Tests/Audio/BassAudioMixerTest.cs index cc9a5fdbb2..23b5a10a75 100644 --- a/osu.Framework.Tests/Audio/BassAudioMixerTest.cs +++ b/osu.Framework.Tests/Audio/BassAudioMixerTest.cs @@ -6,7 +6,6 @@ using System; using System.Threading; using ManagedBass; -using ManagedBass.Fx; using ManagedBass.Mix; using NUnit.Framework; using osu.Framework.Audio.Mixing.Bass; @@ -226,95 +225,6 @@ static WeakReference runTest(SampleBass sample) } } - [Test] - public void TestAddEffect() - { - bass.Mixer.Effects.Add(new BQFParameters()); - assertEffectParameters(); - - bass.Mixer.Effects.AddRange(new[] - { - new BQFParameters(), - new BQFParameters(), - new BQFParameters() - }); - assertEffectParameters(); - } - - [Test] - public void TestRemoveEffect() - { - bass.Mixer.Effects.Add(new BQFParameters()); - assertEffectParameters(); - - bass.Mixer.Effects.RemoveAt(0); - assertEffectParameters(); - - bass.Mixer.Effects.AddRange(new[] - { - new BQFParameters(), - new BQFParameters(), - new BQFParameters() - }); - assertEffectParameters(); - - bass.Mixer.Effects.RemoveAt(1); - assertEffectParameters(); - - bass.Mixer.Effects.RemoveAt(1); - assertEffectParameters(); - } - - [Test] - public void TestMoveEffect() - { - bass.Mixer.Effects.AddRange(new[] - { - new BQFParameters(), - new BQFParameters(), - new BQFParameters() - }); - assertEffectParameters(); - - bass.Mixer.Effects.Move(0, 1); - assertEffectParameters(); - - bass.Mixer.Effects.Move(2, 0); - assertEffectParameters(); - } - - [Test] - public void TestReplaceEffect() - { - bass.Mixer.Effects.AddRange(new[] - { - new BQFParameters(), - new BQFParameters(), - new BQFParameters() - }); - assertEffectParameters(); - - bass.Mixer.Effects[1] = new BQFParameters(); - assertEffectParameters(); - } - - [Test] - public void TestInsertEffect() - { - bass.Mixer.Effects.AddRange(new[] - { - new BQFParameters(), - new BQFParameters() - }); - assertEffectParameters(); - - bass.Mixer.Effects.Insert(1, new BQFParameters()); - assertEffectParameters(); - - bass.Mixer.Effects.Insert(3, new BQFParameters()); - assertEffectParameters(); - } - [Test] public void TestChannelDoesNotPlayIfReachedEndAndSeekedBackwards() { @@ -356,22 +266,6 @@ public void TestChannelDoesNotPlayIfReachedEndAndMovedMixers() Assert.That(secondMixer.ChannelIsActive(track), Is.Not.EqualTo(PlaybackState.Playing)); } - private void assertEffectParameters() - { - bass.Update(); - - Assert.That(bass.Mixer.ActiveEffects.Count, Is.EqualTo(bass.Mixer.Effects.Count)); - - Assert.Multiple(() => - { - for (int i = 0; i < bass.Mixer.ActiveEffects.Count; i++) - { - Assert.That(bass.Mixer.ActiveEffects[i].Effect, Is.EqualTo(bass.Mixer.Effects[i])); - Assert.That(bass.Mixer.ActiveEffects[i].Priority, Is.EqualTo(-i)); - } - }); - } - private int getHandle() => ((IBassAudioChannel)track).Handle; } } diff --git a/osu.Framework.Tests/Clocks/DecouplingFramedClockTest.cs b/osu.Framework.Tests/Clocks/DecouplingFramedClockTest.cs index ba135aa60d..b5b645904e 100644 --- a/osu.Framework.Tests/Clocks/DecouplingFramedClockTest.cs +++ b/osu.Framework.Tests/Clocks/DecouplingFramedClockTest.cs @@ -502,6 +502,26 @@ public void TestForwardPlaybackOverLengthBoundary() Assert.That(source.IsRunning, Is.False); } + [Test] + public void TestPlayDifferentSourceAfterSeekFailure() + { + decouplingClock.AllowDecoupling = true; + + var firstSource = (TestClockWithRange)source; + firstSource.MaxTime = 100; + + decouplingClock.Seek(1000); + + Assert.That(firstSource.IsRunning, Is.False); + + var secondSource = new TestClockWithRange(); + + decouplingClock.ChangeSource(secondSource); + decouplingClock.Start(); + + Assert.That(secondSource.IsRunning, Is.True); + } + #endregion private class TestClockWithRange : TestClock diff --git a/osu.Framework.Tests/Dependencies/Reflection/CachedModelDependenciesTest.cs b/osu.Framework.Tests/Dependencies/Reflection/CachedModelDependenciesTest.cs index 8c27434c91..02297e5bde 100644 --- a/osu.Framework.Tests/Dependencies/Reflection/CachedModelDependenciesTest.cs +++ b/osu.Framework.Tests/Dependencies/Reflection/CachedModelDependenciesTest.cs @@ -1,9 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using System; using System.Diagnostics.CodeAnalysis; using NUnit.Framework; using osu.Framework.Allocation; @@ -15,24 +12,6 @@ namespace osu.Framework.Tests.Dependencies.Reflection [SuppressMessage("Performance", "OFSG001:Class contributes to dependency injection and should be partial")] public class CachedModelDependenciesTest { - [Test] - public void TestModelWithNonBindableFieldsFails() - { - IReadOnlyDependencyContainer unused; - - Assert.Throws(() => unused = new CachedModelDependencyContainer(null)); - Assert.Throws(() => unused = new CachedModelDependencyContainer(null)); - } - - [Test] - public void TestModelWithNonReadOnlyFieldsFails() - { - IReadOnlyDependencyContainer unused; - - Assert.Throws(() => unused = new CachedModelDependencyContainer(null)); - Assert.Throws(() => unused = new CachedModelDependencyContainer(null)); - } - [Test] public void TestSettingNoModelResolvesDefault() { @@ -195,7 +174,7 @@ public void TestSetModelToNullAfterResolved() var model = new FieldModel { Bindable = { Value = 2 } }; - var dependencies = new CachedModelDependencyContainer(null) + var dependencies = new CachedModelDependencyContainer(null) { Model = { Value = model } }; @@ -248,7 +227,7 @@ public void TestResolveIndividualProperties() BindableString = { Value = "3" } }; - var dependencies = new CachedModelDependencyContainer(null) + var dependencies = new CachedModelDependencyContainer(null) { Model = { Value = model1 } }; @@ -269,33 +248,6 @@ public void TestResolveIndividualProperties() Assert.AreEqual(null, resolver.BindableString.Value); } - private class NonBindablePublicFieldModel : IDependencyInjectionCandidate - { -#pragma warning disable 649 - public readonly int FailingField; -#pragma warning restore 649 - } - - private class NonBindablePrivateFieldModel : IDependencyInjectionCandidate - { -#pragma warning disable 169 - private readonly int failingField; -#pragma warning restore 169 - } - - private class NonReadOnlyFieldModel : IDependencyInjectionCandidate - { -#pragma warning disable 649 - public Bindable Bindable; -#pragma warning restore 649 - } - - private class PropertyModel : IDependencyInjectionCandidate - { - // ReSharper disable once UnusedMember.Local - public Bindable Bindable { get; private set; } - } - private class FieldModel : IDependencyInjectionCandidate { [Cached] @@ -311,22 +263,22 @@ private class DerivedFieldModel : FieldModel private class FieldModelResolver : IDependencyInjectionCandidate { [Resolved] - public FieldModel Model { get; private set; } + public FieldModel Model { get; private set; } = null!; } private class DerivedFieldModelResolver : IDependencyInjectionCandidate { [Resolved] - public DerivedFieldModel Model { get; private set; } + public DerivedFieldModel Model { get; private set; } = null!; } private class DerivedFieldModelPropertyResolver : IDependencyInjectionCandidate { [Resolved(typeof(DerivedFieldModel))] - public Bindable Bindable { get; private set; } + public Bindable Bindable { get; private set; } = null!; [Resolved(typeof(DerivedFieldModel))] - public Bindable BindableString { get; private set; } + public Bindable BindableString { get; private set; } = null!; } } } diff --git a/osu.Framework.Tests/Dependencies/SourceGeneration/CachedModelDependenciesTest.cs b/osu.Framework.Tests/Dependencies/SourceGeneration/CachedModelDependenciesTest.cs index 8555380a72..84529a4ae5 100644 --- a/osu.Framework.Tests/Dependencies/SourceGeneration/CachedModelDependenciesTest.cs +++ b/osu.Framework.Tests/Dependencies/SourceGeneration/CachedModelDependenciesTest.cs @@ -1,9 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using System; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -13,24 +10,6 @@ namespace osu.Framework.Tests.Dependencies.SourceGeneration [TestFixture] public partial class CachedModelDependenciesTest { - [Test] - public void TestModelWithNonBindableFieldsFails() - { - IReadOnlyDependencyContainer unused; - - Assert.Throws(() => unused = new CachedModelDependencyContainer(null)); - Assert.Throws(() => unused = new CachedModelDependencyContainer(null)); - } - - [Test] - public void TestModelWithNonReadOnlyFieldsFails() - { - IReadOnlyDependencyContainer unused; - - Assert.Throws(() => unused = new CachedModelDependencyContainer(null)); - Assert.Throws(() => unused = new CachedModelDependencyContainer(null)); - } - [Test] public void TestSettingNoModelResolvesDefault() { @@ -193,7 +172,7 @@ public void TestSetModelToNullAfterResolved() var model = new FieldModel { Bindable = { Value = 2 } }; - var dependencies = new CachedModelDependencyContainer(null) + var dependencies = new CachedModelDependencyContainer(null) { Model = { Value = model } }; @@ -246,7 +225,7 @@ public void TestResolveIndividualProperties() BindableString = { Value = "3" } }; - var dependencies = new CachedModelDependencyContainer(null) + var dependencies = new CachedModelDependencyContainer(null) { Model = { Value = model1 } }; @@ -267,33 +246,6 @@ public void TestResolveIndividualProperties() Assert.AreEqual(null, resolver.BindableString.Value); } - private partial class NonBindablePublicFieldModel : IDependencyInjectionCandidate - { -#pragma warning disable 649 - public readonly int FailingField; -#pragma warning restore 649 - } - - private partial class NonBindablePrivateFieldModel : IDependencyInjectionCandidate - { -#pragma warning disable 169 - private readonly int failingField; -#pragma warning restore 169 - } - - private partial class NonReadOnlyFieldModel : IDependencyInjectionCandidate - { -#pragma warning disable 649 - public Bindable Bindable; -#pragma warning restore 649 - } - - private partial class PropertyModel : IDependencyInjectionCandidate - { - // ReSharper disable once UnusedMember.Local - public Bindable Bindable { get; private set; } - } - private partial class FieldModel : IDependencyInjectionCandidate { [Cached] @@ -309,22 +261,22 @@ private partial class DerivedFieldModel : FieldModel private partial class FieldModelResolver : IDependencyInjectionCandidate { [Resolved] - public FieldModel Model { get; private set; } + public FieldModel Model { get; private set; } = null!; } private partial class DerivedFieldModelResolver : IDependencyInjectionCandidate { [Resolved] - public DerivedFieldModel Model { get; private set; } + public DerivedFieldModel Model { get; private set; } = null!; } private partial class DerivedFieldModelPropertyResolver : IDependencyInjectionCandidate { [Resolved(typeof(DerivedFieldModel))] - public Bindable Bindable { get; private set; } + public Bindable Bindable { get; private set; } = null!; [Resolved(typeof(DerivedFieldModel))] - public Bindable BindableString { get; private set; } + public Bindable BindableString { get; private set; } = null!; } } } diff --git a/osu.Framework.Tests/FlakyTestAttribute.cs b/osu.Framework.Tests/FlakyTestAttribute.cs index 62e8914901..e4eb0c685a 100644 --- a/osu.Framework.Tests/FlakyTestAttribute.cs +++ b/osu.Framework.Tests/FlakyTestAttribute.cs @@ -1,7 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using NUnit.Framework; +using NUnit.Framework.Interfaces; +using NUnit.Framework.Internal; +using NUnit.Framework.Internal.Commands; namespace osu.Framework.Tests { @@ -9,16 +13,63 @@ namespace osu.Framework.Tests /// An attribute to mark any flaky tests. /// Will add a retry count unless environment variable `FAIL_FLAKY_TESTS` is set to `1`. /// - public class FlakyTestAttribute : RetryAttribute + [AttributeUsage(AttributeTargets.Method, Inherited = false)] + public class FlakyTestAttribute : NUnitAttribute, IRepeatTest { + private readonly int tryCount; + public FlakyTestAttribute() : this(10) { } public FlakyTestAttribute(int tryCount) - : base(FrameworkEnvironment.FailFlakyTests ? 1 : tryCount) { + this.tryCount = tryCount; + } + + public TestCommand Wrap(TestCommand command) => new FlakyTestCommand(command, tryCount); + + // Adapted from https://github.com/nunit/nunit/blob/4eaab2eef3713907ca37bfb2f7f47e3fc2785214/src/NUnitFramework/framework/Attributes/RetryAttribute.cs + // Copyright (c) Charlie Poole, Rob Prouse and Contributors. MIT License - see LICENSE.txt + public class FlakyTestCommand : DelegatingTestCommand + { + private readonly int tryCount; + + public FlakyTestCommand(TestCommand innerCommand, int tryCount) + : base(innerCommand) + { + this.tryCount = tryCount; + } + + public override TestResult Execute(TestExecutionContext context) + { + int count = FrameworkEnvironment.FailFlakyTests ? 1 : tryCount; + + while (count-- > 0) + { + try + { + context.CurrentResult = innerCommand.Execute(context); + } + catch (Exception ex) + { + context.CurrentResult ??= context.CurrentTest.MakeTestResult(); + context.CurrentResult.RecordException(ex); + } + + if (context.CurrentResult.ResultState != ResultState.Failure) + break; + + if (count > 0) + { + context.CurrentResult = context.CurrentTest.MakeTestResult(); + context.CurrentRepeatCount++; + } + } + + return context.CurrentResult; + } } } } diff --git a/osu.Framework.Tests/Graphics/RendererTest.cs b/osu.Framework.Tests/Graphics/RendererTest.cs new file mode 100644 index 0000000000..fd84554e2c --- /dev/null +++ b/osu.Framework.Tests/Graphics/RendererTest.cs @@ -0,0 +1,53 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Graphics.Rendering.Dummy; +using osu.Framework.Graphics.Textures; + +namespace osu.Framework.Tests.Graphics +{ + public class RendererTest + { + [Test] + public void TestWhitePixelReuseUpdatesTextureWrapping() + { + DummyRenderer renderer = new DummyRenderer(); + + renderer.BindTexture(renderer.WhitePixel, 0, WrapMode.None, WrapMode.None); + Assert.That(renderer.CurrentWrapModeS, Is.EqualTo(WrapMode.None)); + Assert.That(renderer.CurrentWrapModeS, Is.EqualTo(WrapMode.None)); + + renderer.BindTexture(renderer.WhitePixel, 0, WrapMode.ClampToEdge, WrapMode.ClampToEdge); + Assert.That(renderer.CurrentWrapModeS, Is.EqualTo(WrapMode.ClampToEdge)); + Assert.That(renderer.CurrentWrapModeS, Is.EqualTo(WrapMode.ClampToEdge)); + + renderer.BindTexture(renderer.WhitePixel, 0, WrapMode.None, WrapMode.None); + Assert.That(renderer.CurrentWrapModeS, Is.EqualTo(WrapMode.None)); + Assert.That(renderer.CurrentWrapModeS, Is.EqualTo(WrapMode.None)); + } + + [Test] + public void TestTextureAtlasReuseUpdatesTextureWrapping() + { + DummyRenderer renderer = new DummyRenderer(); + + TextureAtlas atlas = new TextureAtlas(renderer, 1024, 1024); + + Texture textureWrapNone = atlas.Add(100, 100, WrapMode.None, WrapMode.None)!; + Texture textureWrapClamp = atlas.Add(100, 100, WrapMode.ClampToEdge, WrapMode.ClampToEdge)!; + + renderer.BindTexture(textureWrapNone, 0, null, null); + Assert.That(renderer.CurrentWrapModeS, Is.EqualTo(WrapMode.None)); + Assert.That(renderer.CurrentWrapModeT, Is.EqualTo(WrapMode.None)); + + renderer.BindTexture(textureWrapClamp, 0, null, null); + Assert.That(renderer.CurrentWrapModeS, Is.EqualTo(WrapMode.ClampToEdge)); + Assert.That(renderer.CurrentWrapModeT, Is.EqualTo(WrapMode.ClampToEdge)); + + renderer.BindTexture(textureWrapNone, 0, null, null); + Assert.That(renderer.CurrentWrapModeS, Is.EqualTo(WrapMode.None)); + Assert.That(renderer.CurrentWrapModeT, Is.EqualTo(WrapMode.None)); + } + } +} diff --git a/osu.Framework.Tests/Graphics/ShaderStorageBufferObjectStackTest.cs b/osu.Framework.Tests/Graphics/ShaderStorageBufferObjectStackTest.cs index a8561e3cc7..545dc7c8c2 100644 --- a/osu.Framework.Tests/Graphics/ShaderStorageBufferObjectStackTest.cs +++ b/osu.Framework.Tests/Graphics/ShaderStorageBufferObjectStackTest.cs @@ -2,9 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Runtime.InteropServices; using NUnit.Framework; using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Rendering.Dummy; +using osu.Framework.Graphics.Shaders.Types; namespace osu.Framework.Tests.Graphics { @@ -12,21 +14,21 @@ public class ShaderStorageBufferObjectStackTest { private const int size = 10; - private ShaderStorageBufferObjectStack stack = null!; + private ShaderStorageBufferObjectStack stack = null!; [SetUp] public void Setup() { - stack = new ShaderStorageBufferObjectStack(new DummyRenderer(), 2, size); + stack = new ShaderStorageBufferObjectStack(new DummyRenderer(), 2, size); } [Test] public void TestBufferMustBeAtLeast2Elements() { - Assert.Throws(() => _ = new ShaderStorageBufferObjectStack(new DummyRenderer(), 1, 100)); - Assert.Throws(() => _ = new ShaderStorageBufferObjectStack(new DummyRenderer(), 100, 1)); - Assert.DoesNotThrow(() => _ = new ShaderStorageBufferObjectStack(new DummyRenderer(), 2, 100)); - Assert.DoesNotThrow(() => _ = new ShaderStorageBufferObjectStack(new DummyRenderer(), 100, 2)); + Assert.Throws(() => _ = new ShaderStorageBufferObjectStack(new DummyRenderer(), 1, 100)); + Assert.Throws(() => _ = new ShaderStorageBufferObjectStack(new DummyRenderer(), 100, 1)); + Assert.DoesNotThrow(() => _ = new ShaderStorageBufferObjectStack(new DummyRenderer(), 2, 100)); + Assert.DoesNotThrow(() => _ = new ShaderStorageBufferObjectStack(new DummyRenderer(), 100, 2)); } [Test] @@ -34,7 +36,7 @@ public void TestInitialState() { Assert.That(stack.CurrentOffset, Is.Zero); Assert.That(stack.CurrentBuffer, Is.Not.Null); - Assert.That(stack.CurrentBuffer[stack.CurrentOffset], Is.EqualTo(0)); + Assert.That(stack.CurrentBuffer[stack.CurrentOffset].Int.Value, Is.EqualTo(0)); } [Test] @@ -48,11 +50,11 @@ public void TestAddInitialItem() { var firstBuffer = stack.CurrentBuffer; - stack.Push(1); + stack.Push(new TestUniformData { Int = 1 }); Assert.That(stack.CurrentOffset, Is.Zero); Assert.That(stack.CurrentBuffer, Is.EqualTo(firstBuffer)); - Assert.That(stack.CurrentBuffer[stack.CurrentOffset], Is.EqualTo(1)); + Assert.That(stack.CurrentBuffer[stack.CurrentOffset].Int.Value, Is.EqualTo(1)); } [Test] @@ -63,10 +65,10 @@ public void TestPushToFillOneBuffer() for (int i = 0; i < size; i++) { - stack.Push(i); + stack.Push(new TestUniformData { Int = i }); Assert.That(stack.CurrentOffset, Is.EqualTo(expectedIndex++)); Assert.That(stack.CurrentBuffer, Is.EqualTo(firstBuffer)); - Assert.That(stack.CurrentBuffer[stack.CurrentOffset], Is.EqualTo(i)); + Assert.That(stack.CurrentBuffer[stack.CurrentOffset].Int.Value, Is.EqualTo(i)); } } @@ -74,7 +76,7 @@ public void TestPushToFillOneBuffer() public void TestPopEntireBuffer() { for (int i = 0; i < size; i++) - stack.Push(i); + stack.Push(new TestUniformData { Int = i }); var firstBuffer = stack.CurrentBuffer; @@ -82,7 +84,7 @@ public void TestPopEntireBuffer() { Assert.That(stack.CurrentOffset, Is.EqualTo(i)); Assert.That(stack.CurrentBuffer, Is.EqualTo(firstBuffer)); - Assert.That(stack.CurrentBuffer[stack.CurrentOffset], Is.EqualTo(i)); + Assert.That(stack.CurrentBuffer[stack.CurrentOffset].Int.Value, Is.EqualTo(i)); stack.Pop(); } } @@ -91,47 +93,47 @@ public void TestPopEntireBuffer() public void TestTransitionToBufferOnPush() { for (int i = 0; i < size; i++) - stack.Push(i); + stack.Push(new TestUniformData { Int = i }); var firstBuffer = stack.CurrentBuffer; - int copiedItem = stack.CurrentBuffer[stack.CurrentOffset]; + int copiedItem = stack.CurrentBuffer[stack.CurrentOffset].Int.Value; // Transition to a new buffer... - stack.Push(size); + stack.Push(new TestUniformData { Int = size }); Assert.That(stack.CurrentBuffer, Is.Not.EqualTo(firstBuffer)); // ... where the "hack" employed by the queue means that after a transition, the new item is added at index 1... Assert.That(stack.CurrentOffset, Is.EqualTo(1)); - Assert.That(stack.CurrentBuffer[1], Is.EqualTo(size)); + Assert.That(stack.CurrentBuffer[1].Int.Value, Is.EqualTo(size)); // ... and the first item in the new buffer is a copy of the last referenced item before the push. - Assert.That(stack.CurrentBuffer[0], Is.EqualTo(copiedItem)); + Assert.That(stack.CurrentBuffer[0].Int.Value, Is.EqualTo(copiedItem)); } [Test] public void TestTransitionToBufferOnPop() { for (int i = 0; i < size; i++) - stack.Push(i); + stack.Push(new TestUniformData { Int = i }); var firstBuffer = stack.CurrentBuffer; - int copiedItem = stack.CurrentBuffer[stack.CurrentOffset]; + int copiedItem = stack.CurrentBuffer[stack.CurrentOffset].Int.Value; // Transition to the new buffer. - stack.Push(size); + stack.Push(new TestUniformData { Int = size }); // The "hack" employed means that on the first pop, the index moves to the 0th index in the new buffer. stack.Pop(); Assert.That(stack.CurrentBuffer, Is.Not.EqualTo(firstBuffer)); Assert.That(stack.CurrentOffset, Is.Zero); - Assert.That(stack.CurrentBuffer[stack.CurrentOffset], Is.EqualTo(copiedItem)); + Assert.That(stack.CurrentBuffer[stack.CurrentOffset].Int.Value, Is.EqualTo(copiedItem)); // After a subsequent pop, we transition to the previous buffer and move to the index prior to the copied item. // We've already seen the copied item in the new buffer with the above pop, so we should not see it again here. stack.Pop(); Assert.That(stack.CurrentBuffer, Is.EqualTo(firstBuffer)); Assert.That(stack.CurrentOffset, Is.EqualTo(copiedItem - 1)); - Assert.That(stack.CurrentBuffer[stack.CurrentOffset], Is.EqualTo(copiedItem - 1)); + Assert.That(stack.CurrentBuffer[stack.CurrentOffset].Int.Value, Is.EqualTo(copiedItem - 1)); // Popping once again should move the index further backwards. stack.Pop(); @@ -143,7 +145,7 @@ public void TestTransitionToBufferOnPop() public void TestTransitionToAndFromNewBufferFromMiddle() { for (int i = 0; i < size; i++) - stack.Push(i); + stack.Push(new TestUniformData { Int = i }); // Move to the middle of the current buffer (it can not take up any new items at this point). stack.Pop(); @@ -153,13 +155,13 @@ public void TestTransitionToAndFromNewBufferFromMiddle() int copiedItem = stack.CurrentOffset; // Transition to the new buffer... - stack.Push(size); + stack.Push(new TestUniformData { Int = size }); // ... and as above, we arrive at index 1 in the new buffer. Assert.That(stack.CurrentBuffer, Is.Not.EqualTo(firstBuffer)); Assert.That(stack.CurrentOffset, Is.EqualTo(1)); - Assert.That(stack.CurrentBuffer[1], Is.EqualTo(size)); - Assert.That(stack.CurrentBuffer[0], Is.EqualTo(copiedItem)); + Assert.That(stack.CurrentBuffer[1].Int.Value, Is.EqualTo(size)); + Assert.That(stack.CurrentBuffer[0].Int.Value, Is.EqualTo(copiedItem)); // Transition to the previous buffer... stack.Pop(); @@ -168,7 +170,7 @@ public void TestTransitionToAndFromNewBufferFromMiddle() // ... noting that this is the same as the above "normal" pop case, except that item arrived at is in the middle of the previous buffer. Assert.That(stack.CurrentBuffer, Is.EqualTo(firstBuffer)); Assert.That(stack.CurrentOffset, Is.EqualTo(copiedItem - 1)); - Assert.That(stack.CurrentBuffer[stack.CurrentOffset], Is.EqualTo(copiedItem - 1)); + Assert.That(stack.CurrentBuffer[stack.CurrentOffset].Int.Value, Is.EqualTo(copiedItem - 1)); // Popping once again from this state should move further backwards. stack.Pop(); @@ -180,19 +182,19 @@ public void TestTransitionToAndFromNewBufferFromMiddle() public void TestMoveToAndFromMiddleOfNewBuffer() { for (int i = 0; i < size; i++) - stack.Push(i); + stack.Push(new TestUniformData { Int = i }); var lastBuffer = stack.CurrentBuffer; - int copiedItem1 = stack.CurrentBuffer[stack.CurrentOffset]; + int copiedItem1 = stack.CurrentBuffer[stack.CurrentOffset].Int.Value; // Transition to the middle of the new buffer. - stack.Push(size); - stack.Push(size + 1); + stack.Push(new TestUniformData { Int = size }); + stack.Push(new TestUniformData { Int = size + 1 }); Assert.That(stack.CurrentBuffer, Is.Not.EqualTo(lastBuffer)); Assert.That(stack.CurrentOffset, Is.EqualTo(2)); - Assert.That(stack.CurrentBuffer[2], Is.EqualTo(size + 1)); - Assert.That(stack.CurrentBuffer[1], Is.EqualTo(size)); - Assert.That(stack.CurrentBuffer[0], Is.EqualTo(copiedItem1)); + Assert.That(stack.CurrentBuffer[2].Int.Value, Is.EqualTo(size + 1)); + Assert.That(stack.CurrentBuffer[1].Int.Value, Is.EqualTo(size)); + Assert.That(stack.CurrentBuffer[0].Int.Value, Is.EqualTo(copiedItem1)); // Transition to the previous buffer. stack.Pop(); @@ -201,23 +203,23 @@ public void TestMoveToAndFromMiddleOfNewBuffer() Assert.That(stack.CurrentBuffer, Is.EqualTo(lastBuffer)); // The item that will be copied into the new buffer. - int copiedItem2 = stack.CurrentBuffer[stack.CurrentOffset]; + int copiedItem2 = stack.CurrentBuffer[stack.CurrentOffset].Int.Value; // Transition to the new buffer... - stack.Push(size + 2); + stack.Push(new TestUniformData { Int = size + 2 }); Assert.That(stack.CurrentBuffer, Is.Not.EqualTo(lastBuffer)); // ... noting that this is the same as the normal case of transitioning to a new buffer, except arriving in the middle of it... Assert.That(stack.CurrentOffset, Is.EqualTo(4)); - Assert.That(stack.CurrentBuffer[4], Is.EqualTo(size + 2)); + Assert.That(stack.CurrentBuffer[4].Int.Value, Is.EqualTo(size + 2)); // ... where this is the copied item as a result of the immediate push... - Assert.That(stack.CurrentBuffer[3], Is.EqualTo(copiedItem2)); + Assert.That(stack.CurrentBuffer[3].Int.Value, Is.EqualTo(copiedItem2)); // ... and these are the same items from the first pushes above. - Assert.That(stack.CurrentBuffer[2], Is.EqualTo(size + 1)); - Assert.That(stack.CurrentBuffer[1], Is.EqualTo(size)); - Assert.That(stack.CurrentBuffer[0], Is.EqualTo(copiedItem1)); + Assert.That(stack.CurrentBuffer[2].Int.Value, Is.EqualTo(size + 1)); + Assert.That(stack.CurrentBuffer[1].Int.Value, Is.EqualTo(size)); + Assert.That(stack.CurrentBuffer[0].Int.Value, Is.EqualTo(copiedItem1)); // Transition to the previous buffer... stack.Pop(); @@ -230,7 +232,7 @@ public void TestMoveToAndFromMiddleOfNewBuffer() // 3. From index N-2 -> transition to new buffer. // 4. Transition to old buffer, arrive at index N-3 (N-2 was copied into the new buffer). Assert.That(stack.CurrentOffset, Is.EqualTo(size - 3)); - Assert.That(stack.CurrentBuffer[stack.CurrentOffset], Is.EqualTo(size - 3)); + Assert.That(stack.CurrentBuffer[stack.CurrentOffset].Int.Value, Is.EqualTo(size - 3)); } [Test] @@ -241,18 +243,25 @@ public void TestTransitionFromEmptyStack() var lastBuffer = stack.CurrentBuffer; // Push one item. - stack.Push(i); + stack.Push(new TestUniformData { Int = i }); // On a buffer transition, test that the item at the 0-th index of the first buffer was correct copied to the new buffer. if (stack.CurrentBuffer != lastBuffer) - Assert.That(stack.CurrentBuffer[stack.CurrentOffset - 1], Is.EqualTo(0)); + Assert.That(stack.CurrentBuffer[stack.CurrentOffset - 1].Int.Value, Is.EqualTo(0)); // Test that the item was correctly placed in the new buffer - Assert.That(stack.CurrentBuffer[stack.CurrentOffset], Is.EqualTo(i)); + Assert.That(stack.CurrentBuffer[stack.CurrentOffset].Int.Value, Is.EqualTo(i)); // Return to an empty stack. stack.Pop(); } } + + [StructLayout(LayoutKind.Sequential, Pack = 1)] + private record struct TestUniformData + { + public UniformInt Int; + private UniformPadding12 pad; + } } } diff --git a/osu.Framework.Tests/Graphics/TripleBufferTest.cs b/osu.Framework.Tests/Graphics/TripleBufferTest.cs index 3dce0e5eb0..d14e0faec1 100644 --- a/osu.Framework.Tests/Graphics/TripleBufferTest.cs +++ b/osu.Framework.Tests/Graphics/TripleBufferTest.cs @@ -40,196 +40,49 @@ public void TestSameBufferIsNotWrittenTwiceInRowNoContestation() { var tripleBuffer = createWithIDsMatchingIndices(); - using (var write = tripleBuffer.GetForWrite()) - Assert.That(write.Object?.ID, Is.EqualTo(0)); + int? lastWrite = null; - // buffer 0: waiting for read - // buffer 1: old - // buffer 2: old - - using (var buffer = tripleBuffer.GetForRead()) - Assert.That(buffer?.Object?.ID, Is.EqualTo(0)); - - // buffer 0: last read - // buffer 1: old - // buffer 2: old - - using (var write = tripleBuffer.GetForWrite()) - Assert.That(write.Object?.ID, Is.EqualTo(1)); - - // buffer 0: last read - // buffer 1: waiting for read - // buffer 2: old - - using (var write = tripleBuffer.GetForWrite()) - Assert.That(write.Object?.ID, Is.EqualTo(2)); - - // buffer 0: last read - // buffer 1: old - // buffer 2: waiting for read - - using (var write = tripleBuffer.GetForWrite()) - Assert.That(write.Object?.ID, Is.EqualTo(1)); - - // buffer 0: last read - // buffer 1: waiting for read - // buffer 2: old - - using (var buffer = tripleBuffer.GetForRead()) - Assert.That(buffer?.Object?.ID, Is.EqualTo(1)); - - // buffer 0: old - // buffer 1: last read - // buffer 2: old - } - - [Test] - public void TestSameBufferIsNotWrittenTwiceInRowContestation() - { - var tripleBuffer = createWithIDsMatchingIndices(); - - // Test with first write in use during second. - using (var write = tripleBuffer.GetForWrite()) - Assert.That(write.Object?.ID, Is.EqualTo(0)); - - // buffer 0: waiting for read - // buffer 1: old - // buffer 2: old - - using (var read = tripleBuffer.GetForRead()) - { - Assert.That(read?.Object?.ID, Is.EqualTo(0)); - - // buffer 0: reading - // buffer 1: old - // buffer 2: old - - using (var write = tripleBuffer.GetForWrite()) - Assert.That(write.Object?.ID, Is.EqualTo(1)); - - // buffer 0: reading - // buffer 1: waiting for read - // buffer 2: old - - using (var write = tripleBuffer.GetForWrite()) - Assert.That(write.Object?.ID, Is.EqualTo(2)); - - // buffer 0: reading - // buffer 1: old - // buffer 2: waiting for read - } - - using (var read = tripleBuffer.GetForRead()) + for (int i = 0; i < 3; i++) { - Assert.That(read?.Object?.ID, Is.EqualTo(2)); - - // buffer 0: old - // buffer 1: old - // buffer 2: reading - - using (var write = tripleBuffer.GetForWrite()) - Assert.That(write.Object?.ID, Is.EqualTo(0)); - - // buffer 0: waiting for read - // buffer 1: old - // buffer 2: reading - using (var write = tripleBuffer.GetForWrite()) - Assert.That(write.Object?.ID, Is.EqualTo(1)); - - // buffer 0: old - // buffer 1: waiting for read - // buffer 2: reading - - using (var write = tripleBuffer.GetForWrite()) - Assert.That(write.Object?.ID, Is.EqualTo(0)); - - // buffer 0: waiting for read - // buffer 1: old - // buffer 2: reading - } + { + Assert.That(write.Object!.ID, Is.Not.EqualTo(lastWrite)); + lastWrite = write.Object!.ID; + } - using (var read = tripleBuffer.GetForRead()) - { - Assert.That(read?.Object?.ID, Is.EqualTo(0)); - // buffer 0: reading - // buffer 1: old - // buffer 2: old + using (var buffer = tripleBuffer.GetForRead()) + Assert.That(buffer!.Object!.ID, Is.EqualTo(lastWrite)); } } [Test] - public void TestSameBufferIsNotWrittenTwiceInRowContestation2() + public void TestSameBufferIsNotWrittenTwiceInRowContestation() { var tripleBuffer = createWithIDsMatchingIndices(); - using (var write = tripleBuffer.GetForWrite()) - Assert.That(write.Object?.ID, Is.EqualTo(0)); - - // buffer 0: waiting for read - // buffer 1: old - // buffer 2: old - - using (var read = tripleBuffer.GetForRead()) - { - Assert.That(read?.Object?.ID, Is.EqualTo(0)); - - // buffer 0: reading - // buffer 1: old - // buffer 2: old - - using (var write = tripleBuffer.GetForWrite()) - { - Assert.That(write.Object?.ID, Is.EqualTo(1)); - - // buffer 0: reading - // buffer 1: writing - // buffer 2: old - } - } - - using (var read = tripleBuffer.GetForRead()) + // Test with first write in use during second. + using (tripleBuffer.GetForWrite()) { - Assert.That(read?.Object?.ID, Is.EqualTo(1)); - - // buffer 0: old - // buffer 1: reading - // buffer 2: old } - using (var write = tripleBuffer.GetForWrite()) - { - Assert.That(write.Object?.ID, Is.EqualTo(0)); - - // buffer 0: writing - // buffer 1: last read - // buffer 2: old - } + int? lastRead = null; + int? lastWrite = null; - using (var read = tripleBuffer.GetForRead()) + for (int i = 0; i < 3; i++) { - Assert.That(read?.Object?.ID, Is.EqualTo(0)); - - // buffer 0: reading - // buffer 1: old - // buffer 2: old - - using (var write = tripleBuffer.GetForWrite()) + using (var read = tripleBuffer.GetForRead()) { - Assert.That(write.Object?.ID, Is.EqualTo(1)); - - // buffer 0: reading - // buffer 1: writing - // buffer 2: old - } - - using (var write = tripleBuffer.GetForWrite()) - { - Assert.That(write.Object?.ID, Is.EqualTo(2)); - - // buffer 0: reading - // buffer 1: waiting for read - // buffer 2: writing + Assert.That(read!.Object!.ID, Is.Not.EqualTo(lastRead)); + + for (int j = 0; j < 3; j++) + { + using (var write = tripleBuffer.GetForWrite()) + { + Assert.That(write.Object!.ID, Is.Not.EqualTo(lastWrite)); + Assert.That(write.Object!.ID, Is.Not.EqualTo(read.Object?.ID)); + lastWrite = write.Object!.ID; + } + } } } } diff --git a/osu.Framework.Tests/IO/TestOnlineStore.cs b/osu.Framework.Tests/IO/TestOnlineStore.cs new file mode 100644 index 0000000000..f673909ba9 --- /dev/null +++ b/osu.Framework.Tests/IO/TestOnlineStore.cs @@ -0,0 +1,121 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Extensions; +using osu.Framework.IO.Stores; + +namespace osu.Framework.Tests.IO +{ + [TestFixture] + [Category("httpbin")] + public class TestOnlineStore + { + private const string default_protocol = "http"; + + private static readonly string host; + private static readonly IEnumerable protocols; + + private bool oldAllowInsecureRequests; + private OnlineStore store = null!; + + static TestOnlineStore() + { + bool localHttpBin = Environment.GetEnvironmentVariable("OSU_TESTS_LOCAL_HTTPBIN") == "1"; + + if (localHttpBin) + { + // httpbin very frequently falls over and causes random tests to fail + // Thus github actions builds rely on a local httpbin instance to run the tests + + host = "127.0.0.1:8080"; + protocols = new[] { default_protocol }; + } + else + { + host = "httpbin.org"; + protocols = new[] { default_protocol, "https" }; + } + } + + [OneTimeSetUp] + public void GlobalSetup() + { + oldAllowInsecureRequests = FrameworkEnvironment.AllowInsecureRequests; + FrameworkEnvironment.AllowInsecureRequests = true; + } + + [OneTimeTearDown] + public void GlobalTeardown() + { + FrameworkEnvironment.AllowInsecureRequests = oldAllowInsecureRequests; + } + + [SetUp] + public void Setup() + { + store = new OnlineStore(); + } + + [Test, Retry(5)] + public void TestValidUrlReturnsData([ValueSource(nameof(protocols))] string protocol, [Values(true, false)] bool async) + { + byte[]? result = async + ? store.GetAsync($"{protocol}://{host}/image/png").GetResultSafely() + : store.Get($"{protocol}://{host}/image/png"); + + Assert.That(result, Is.Not.Null); + Assert.That(result, Has.Length.GreaterThan(0)); + } + + [Test] + public void TestMissingSchemeReturnsNull([Values(true, false)] bool async) + { + byte[]? result = async + ? store.GetAsync($"{host}/image/png").GetResultSafely() + : store.Get($"{host}/image/png"); + + Assert.That(result, Is.Null); + } + + [Test] + public void TestInvalidUrlReturnsNull() + { + byte[]? result = store.Get("this is not a valid url"); + Assert.That(result, Is.Null); + } + + [Test] + public void TestNullUrlReturnsNull() + { + // Not sure if this store should accept a null URL, but let's test it anyway. + byte[]? result = store.Get(null); + Assert.That(result, Is.Null); + } + + [Test] + public void TestFileUrlFails([Values(true, false)] bool async) + { + // Known, guaranteed file path. + string path = new Uri(AppContext.BaseDirectory).AbsoluteUri; + + byte[]? result = async + ? store.GetAsync(path).GetResultSafely() + : store.Get(path); + + Assert.That(result, Is.Null); + } + + [Test] + public void TestBadWebRequest([ValueSource(nameof(protocols))] string protocol, [Values(true, false)] bool async) + { + byte[]? result = async + ? store.GetAsync($"{protocol}://{host}/status/500").GetResultSafely() + : store.Get($"{protocol}://{host}/status/500"); + + Assert.That(result, Is.Null); + } + } +} diff --git a/osu.Framework.Tests/IO/TestWebRequest.cs b/osu.Framework.Tests/IO/TestWebRequest.cs index 263fa788de..5dc898de18 100644 --- a/osu.Framework.Tests/IO/TestWebRequest.cs +++ b/osu.Framework.Tests/IO/TestWebRequest.cs @@ -32,6 +32,8 @@ public class TestWebRequest private static readonly string host; private static readonly IEnumerable protocols; + private bool oldAllowInsecureRequests; + static TestWebRequest() { bool localHttpBin = Environment.GetEnvironmentVariable("OSU_TESTS_LOCAL_HTTPBIN") == "1"; @@ -51,14 +53,26 @@ static TestWebRequest() } } + [SetUp] + public void Setup() + { + oldAllowInsecureRequests = FrameworkEnvironment.AllowInsecureRequests; + FrameworkEnvironment.AllowInsecureRequests = true; + } + + [TearDown] + public void Teardown() + { + FrameworkEnvironment.AllowInsecureRequests = oldAllowInsecureRequests; + } + [Test, Retry(5)] public void TestValidGet([ValueSource(nameof(protocols))] string protocol, [Values(true, false)] bool async) { string url = $"{protocol}://{host}/get"; var request = new JsonWebRequest(url) { - Method = HttpMethod.Get, - AllowInsecureRequests = true + Method = HttpMethod.Get }; testValidGetInternal(async, request, "osu-framework"); @@ -74,8 +88,7 @@ public void TestValidGetFromTask([ValueSource(nameof(protocols))] string protoco string url = $"{protocol}://{host}/get"; var request = new JsonWebRequest(url) { - Method = HttpMethod.Get, - AllowInsecureRequests = true + Method = HttpMethod.Get }; Task.Run(() => testValidGetInternal(false, request, "osu-framework")).WaitSafely(); @@ -87,8 +100,7 @@ public void TestCustomUserAgent([ValueSource(nameof(protocols))] string protocol string url = $"{protocol}://{host}/get"; var request = new CustomUserAgentWebRequest(url) { - Method = HttpMethod.Get, - AllowInsecureRequests = true + Method = HttpMethod.Get }; testValidGetInternal(async, request, "custom-ua"); @@ -143,7 +155,6 @@ public void TestConcurrency() var request = new DelayedWebRequest { Method = HttpMethod.Get, - AllowInsecureRequests = true, Delay = induced_delay }; @@ -186,8 +197,7 @@ public void TestInvalidGetExceptions([ValueSource(nameof(protocols))] string pro { var request = new WebRequest($"{protocol}://{invalid_get_url}") { - Method = HttpMethod.Get, - AllowInsecureRequests = true + Method = HttpMethod.Get }; Exception finishedException = null; @@ -208,10 +218,7 @@ public void TestInvalidGetExceptions([ValueSource(nameof(protocols))] string pro [Test, Retry(5)] public void TestBadStatusCode([Values(true, false)] bool async) { - var request = new WebRequest($"{default_protocol}://{host}/hidden-basic-auth/user/passwd") - { - AllowInsecureRequests = true, - }; + var request = new WebRequest($"{default_protocol}://{host}/hidden-basic-auth/user/passwd"); bool hasThrown = false; request.Failed += exception => hasThrown = exception != null; @@ -230,10 +237,7 @@ public void TestBadStatusCode([Values(true, false)] bool async) [Test, Retry(5)] public void TestJsonWebRequestThrowsCorrectlyOnMultipleErrors([Values(true, false)] bool async) { - var request = new JsonWebRequest("badrequest://www.google.com") - { - AllowInsecureRequests = true, - }; + var request = new JsonWebRequest("badrequest://www.google.com"); bool hasThrown = false; request.Failed += exception => hasThrown = exception != null; @@ -261,8 +265,7 @@ public void TestAbortReceive([Values(true, false)] bool async) { var request = new JsonWebRequest($"{default_protocol}://{host}/get") { - Method = HttpMethod.Get, - AllowInsecureRequests = true, + Method = HttpMethod.Get }; bool hasThrown = false; @@ -290,8 +293,7 @@ public void TestAbortRequest() { var request = new JsonWebRequest($"{default_protocol}://{host}/get") { - Method = HttpMethod.Get, - AllowInsecureRequests = true, + Method = HttpMethod.Get }; bool hasThrown = false; @@ -317,8 +319,7 @@ public void TestStartAfterAbort([Values(true, false)] bool async) { var request = new JsonWebRequest($"{default_protocol}://{host}/get") { - Method = HttpMethod.Get, - AllowInsecureRequests = true, + Method = HttpMethod.Get }; bool hasThrown = false; @@ -348,8 +349,7 @@ public void TestRestartAfterAbort([Values(true, false)] bool async) { var request = new JsonWebRequest($"{default_protocol}://{host}/delay/10") { - Method = HttpMethod.Get, - AllowInsecureRequests = true, + Method = HttpMethod.Get }; bool hasThrown = false; @@ -383,8 +383,7 @@ public void TestCancelReceive() var cancellationSource = new CancellationTokenSource(); var request = new JsonWebRequest($"{default_protocol}://{host}/get") { - Method = HttpMethod.Get, - AllowInsecureRequests = true, + Method = HttpMethod.Get }; bool hasThrown = false; @@ -409,8 +408,7 @@ public async Task TestCancelRequest() var cancellationSource = new CancellationTokenSource(); var request = new JsonWebRequest($"{default_protocol}://{host}/get") { - Method = HttpMethod.Get, - AllowInsecureRequests = true, + Method = HttpMethod.Get }; bool hasThrown = false; @@ -436,8 +434,7 @@ public void TestRestartAfterAbortViaCancellationToken() var cancellationSource = new CancellationTokenSource(); var request = new JsonWebRequest($"{default_protocol}://{host}/get") { - Method = HttpMethod.Get, - AllowInsecureRequests = true, + Method = HttpMethod.Get }; bool hasThrown = false; @@ -466,7 +463,6 @@ public void TestOneTimeout() var request = new DelayedWebRequest { Method = HttpMethod.Get, - AllowInsecureRequests = true, Timeout = 1000, Delay = 2 }; @@ -493,7 +489,6 @@ public void TestFailTimeout() var request = new WebRequest($"{default_protocol}://{host}/delay/4") { Method = HttpMethod.Get, - AllowInsecureRequests = true, Timeout = 1000 }; @@ -518,8 +513,7 @@ public void TestEventUnbindOnCompletion([Values(true, false)] bool async) { var request = new JsonWebRequest($"{default_protocol}://{host}/get") { - Method = HttpMethod.Get, - AllowInsecureRequests = true, + Method = HttpMethod.Get }; request.Started += () => { }; @@ -546,8 +540,7 @@ public void TestUnbindOnDispose([Values(true, false)] bool async) { var request = new JsonWebRequest($"{default_protocol}://{host}/get") { - Method = HttpMethod.Get, - AllowInsecureRequests = true, + Method = HttpMethod.Get }; using (request) @@ -580,8 +573,7 @@ public void TestGetWithQueryStringParameters() var request = new JsonWebRequest($@"{default_protocol}://{host}/get") { - Method = HttpMethod.Get, - AllowInsecureRequests = true + Method = HttpMethod.Get }; request.AddParameter(test_key_1, test_val_1); @@ -608,8 +600,7 @@ public void TestPostWithJsonResponse([Values(true, false)] bool async) { var request = new JsonWebRequest($"{default_protocol}://{host}/post") { - Method = HttpMethod.Post, - AllowInsecureRequests = true, + Method = HttpMethod.Post }; request.AddParameter("testkey1", "testval1"); @@ -645,7 +636,6 @@ public void TestPostWithJsonRequest([Values(true, false)] bool async) var request = new JsonWebRequest($"{default_protocol}://{host}/post") { Method = HttpMethod.Post, - AllowInsecureRequests = true, ContentType = "application/json" }; @@ -672,8 +662,7 @@ public void TestNoContentPost([Values(true, false)] bool async) { var request = new WebRequest($"{default_protocol}://{host}/post") { - Method = HttpMethod.Post, - AllowInsecureRequests = true, + Method = HttpMethod.Post }; if (async) @@ -702,8 +691,7 @@ public void TestPutWithQueryAndFormParams() var request = new JsonWebRequest($"{default_protocol}://{host}/put") { - Method = HttpMethod.Put, - AllowInsecureRequests = true, + Method = HttpMethod.Put }; request.AddParameter(test_key_1, test_val_1, RequestParameterType.Query); @@ -735,8 +723,7 @@ public void TestFormParamsNotSupportedForGet() { var request = new JsonWebRequest($"{default_protocol}://{host}/get") { - Method = HttpMethod.Get, - AllowInsecureRequests = true, + Method = HttpMethod.Get }; Assert.Throws(() => request.AddParameter("cannot", "work", RequestParameterType.Form)); @@ -777,7 +764,6 @@ Also note that the TPL thread pool generally gets much higher values than this ( var request = new DelayedWebRequest { Method = HttpMethod.Get, - AllowInsecureRequests = true, Timeout = 1000, Delay = 2 }; @@ -804,8 +790,7 @@ public void TestGetBinaryData([Values(true, false)] bool async, [Values(true, fa WebRequest request = new WebRequest($"{default_protocol}://{host}/{endpoint}/{bytes_count}") { - Method = HttpMethod.Get, - AllowInsecureRequests = true, + Method = HttpMethod.Get }; if (chunked) request.AddParameter("chunk_size", chunk_size.ToString()); @@ -821,6 +806,21 @@ public void TestGetBinaryData([Values(true, false)] bool async, [Values(true, fa Assert.AreEqual(bytes_count, request.ResponseStream.Length); } + [Test, Retry(5)] + public void TestAllowInsecureRequestsOverride([ValueSource(nameof(protocols))] string protocol, [Values(true, false)] bool async) + { + FrameworkEnvironment.AllowInsecureRequests = false; + + string url = $"{protocol}://{host}/get"; + var request = new JsonWebRequest(url) + { + Method = HttpMethod.Get, + AllowInsecureRequests = true + }; + + testValidGetInternal(async, request, "osu-framework"); + } + private static Dictionary convertDictionary(Dictionary dict) { var result = new Dictionary(); diff --git a/osu.Framework.Tests/Input/KeyCombinationTest.cs b/osu.Framework.Tests/Input/KeyCombinationTest.cs index 618c260ce3..37a6caff3d 100644 --- a/osu.Framework.Tests/Input/KeyCombinationTest.cs +++ b/osu.Framework.Tests/Input/KeyCombinationTest.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using NUnit.Framework; using osu.Framework.Input.Bindings; +using osu.Framework.Input.States; +using osuTK.Input; namespace osu.Framework.Tests.Input { @@ -14,37 +17,34 @@ public class KeyCombinationTest // test single combination matches. new object[] { new KeyCombination(InputKey.Shift), new KeyCombination(InputKey.LShift), KeyCombinationMatchingMode.Any, true }, new object[] { new KeyCombination(InputKey.Shift), new KeyCombination(InputKey.RShift), KeyCombinationMatchingMode.Any, true }, - new object[] { new KeyCombination(InputKey.Shift), new KeyCombination(InputKey.Shift), KeyCombinationMatchingMode.Any, true }, new object[] { new KeyCombination(InputKey.LShift), new KeyCombination(InputKey.RShift), KeyCombinationMatchingMode.Any, false }, new object[] { new KeyCombination(InputKey.RShift), new KeyCombination(InputKey.RShift), KeyCombinationMatchingMode.Any, true }, new object[] { new KeyCombination(InputKey.Shift), new KeyCombination(InputKey.LShift), KeyCombinationMatchingMode.Exact, true }, new object[] { new KeyCombination(InputKey.Shift), new KeyCombination(InputKey.RShift), KeyCombinationMatchingMode.Exact, true }, - new object[] { new KeyCombination(InputKey.Shift), new KeyCombination(InputKey.Shift), KeyCombinationMatchingMode.Exact, true }, new object[] { new KeyCombination(InputKey.LShift), new KeyCombination(InputKey.RShift), KeyCombinationMatchingMode.Exact, false }, new object[] { new KeyCombination(InputKey.RShift), new KeyCombination(InputKey.RShift), KeyCombinationMatchingMode.Exact, true }, new object[] { new KeyCombination(InputKey.Shift), new KeyCombination(InputKey.LShift), KeyCombinationMatchingMode.Modifiers, true }, new object[] { new KeyCombination(InputKey.Shift), new KeyCombination(InputKey.RShift), KeyCombinationMatchingMode.Modifiers, true }, - new object[] { new KeyCombination(InputKey.Shift), new KeyCombination(InputKey.Shift), KeyCombinationMatchingMode.Modifiers, true }, new object[] { new KeyCombination(InputKey.LShift), new KeyCombination(InputKey.RShift), KeyCombinationMatchingMode.Modifiers, false }, new object[] { new KeyCombination(InputKey.RShift), new KeyCombination(InputKey.RShift), KeyCombinationMatchingMode.Modifiers, true }, // test multiple combination matches. - new object[] { new KeyCombination(InputKey.Shift), new KeyCombination(InputKey.Shift, InputKey.LShift), KeyCombinationMatchingMode.Any, true }, - new object[] { new KeyCombination(InputKey.Shift), new KeyCombination(InputKey.Shift, InputKey.RShift), KeyCombinationMatchingMode.Any, true }, - new object[] { new KeyCombination(InputKey.LShift), new KeyCombination(InputKey.Shift, InputKey.RShift), KeyCombinationMatchingMode.Any, false }, - new object[] { new KeyCombination(InputKey.RShift), new KeyCombination(InputKey.Shift, InputKey.RShift), KeyCombinationMatchingMode.Any, true }, + new object[] { new KeyCombination(InputKey.Shift), new KeyCombination(InputKey.LShift, InputKey.RShift), KeyCombinationMatchingMode.Any, true }, + new object[] { new KeyCombination(InputKey.LShift), new KeyCombination(InputKey.LShift, InputKey.RShift), KeyCombinationMatchingMode.Any, true }, + new object[] { new KeyCombination(InputKey.RShift), new KeyCombination(InputKey.LShift, InputKey.RShift), KeyCombinationMatchingMode.Any, true }, + new object[] { new KeyCombination(InputKey.RShift), new KeyCombination(InputKey.RShift, InputKey.A), KeyCombinationMatchingMode.Any, true }, - new object[] { new KeyCombination(InputKey.Shift), new KeyCombination(InputKey.Shift, InputKey.LShift), KeyCombinationMatchingMode.Exact, true }, - new object[] { new KeyCombination(InputKey.Shift), new KeyCombination(InputKey.Shift, InputKey.RShift), KeyCombinationMatchingMode.Exact, true }, - new object[] { new KeyCombination(InputKey.LShift), new KeyCombination(InputKey.Shift, InputKey.RShift), KeyCombinationMatchingMode.Exact, false }, - new object[] { new KeyCombination(InputKey.RShift), new KeyCombination(InputKey.Shift, InputKey.RShift), KeyCombinationMatchingMode.Exact, true }, + new object[] { new KeyCombination(InputKey.Shift), new KeyCombination(InputKey.LShift, InputKey.RShift), KeyCombinationMatchingMode.Exact, true }, + new object[] { new KeyCombination(InputKey.LShift), new KeyCombination(InputKey.LShift, InputKey.RShift), KeyCombinationMatchingMode.Exact, false }, + new object[] { new KeyCombination(InputKey.RShift), new KeyCombination(InputKey.LShift, InputKey.RShift), KeyCombinationMatchingMode.Exact, false }, + new object[] { new KeyCombination(InputKey.RShift), new KeyCombination(InputKey.RShift, InputKey.A), KeyCombinationMatchingMode.Exact, false }, - new object[] { new KeyCombination(InputKey.Shift), new KeyCombination(InputKey.Shift, InputKey.LShift), KeyCombinationMatchingMode.Modifiers, true }, - new object[] { new KeyCombination(InputKey.Shift), new KeyCombination(InputKey.Shift, InputKey.RShift), KeyCombinationMatchingMode.Modifiers, true }, - new object[] { new KeyCombination(InputKey.LShift), new KeyCombination(InputKey.Shift, InputKey.RShift), KeyCombinationMatchingMode.Modifiers, false }, - new object[] { new KeyCombination(InputKey.RShift), new KeyCombination(InputKey.Shift, InputKey.RShift), KeyCombinationMatchingMode.Modifiers, true }, + new object[] { new KeyCombination(InputKey.Shift), new KeyCombination(InputKey.LShift, InputKey.RShift), KeyCombinationMatchingMode.Modifiers, true }, + new object[] { new KeyCombination(InputKey.LShift), new KeyCombination(InputKey.LShift, InputKey.RShift), KeyCombinationMatchingMode.Modifiers, false }, + new object[] { new KeyCombination(InputKey.RShift), new KeyCombination(InputKey.LShift, InputKey.RShift), KeyCombinationMatchingMode.Modifiers, false }, + new object[] { new KeyCombination(InputKey.RShift), new KeyCombination(InputKey.RShift, InputKey.A), KeyCombinationMatchingMode.Modifiers, true }, }; [TestCaseSource(nameof(key_combination_display_test_cases))] @@ -68,5 +68,16 @@ public void TestCreationWithDuplicates() Assert.That(keyCombination.Keys[0], Is.EqualTo(InputKey.Control)); Assert.That(keyCombination.Keys[1], Is.EqualTo(InputKey.A)); } + + [Test] + public void TestEmptyCombinationIsNeverPressed() + { + var keyCombination = new KeyCombination(Array.Empty()); + + var state = new InputState(); + state.Keyboard.Keys.SetPressed(Key.X, true); + + Assert.That(keyCombination.IsPressed(new KeyCombination(InputKey.X), state, KeyCombinationMatchingMode.Any), Is.False); + } } } diff --git a/osu.Framework.Tests/Layout/TestSceneContainerLayout.cs b/osu.Framework.Tests/Layout/TestSceneContainerLayout.cs index 754f84eb5e..81cd0577d0 100644 --- a/osu.Framework.Tests/Layout/TestSceneContainerLayout.cs +++ b/osu.Framework.Tests/Layout/TestSceneContainerLayout.cs @@ -7,6 +7,7 @@ using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Layout; using osu.Framework.Testing; @@ -498,6 +499,42 @@ public void TestNonAutoSizingParentDoesNotInvalidateSizeDependenciesFromChild() AddAssert("still valid", () => isValid); } + /// + /// Tests that changing Masking property will invalidate child masking bounds. + /// + [Test] + public void TestChildMaskingInvalidationOnMaskingChange() + { + Container parent = null; + Container child = null; + RectangleF childMaskingBounds = new RectangleF(); + RectangleF actualChildMaskingBounds = new RectangleF(); + + AddStep("createTest", () => + { + Child = parent = new Container + { + Size = new Vector2(100), + Child = child = new Container + { + RelativeSizeAxes = Axes.Both + } + }; + }); + + AddAssert("Parent's masking is off", () => parent.Masking == false); + AddStep("Save masking bounds", () => + { + childMaskingBounds = parent.ChildMaskingBounds; + actualChildMaskingBounds = child.ChildMaskingBounds; + }); + AddAssert("Parent and child have the same masking bounds", () => childMaskingBounds == actualChildMaskingBounds); + AddStep("Enable parent masking", () => parent.Masking = true); + AddAssert("Parent's ChildMaskingBounds has changed", () => childMaskingBounds != parent.ChildMaskingBounds); + AddAssert("Child's masking bounds has changed", () => actualChildMaskingBounds != child.ChildMaskingBounds); + AddAssert("Parent and child have the same masking bounds", () => parent.ChildMaskingBounds == child.ChildMaskingBounds); + } + private partial class TestBox1 : Box { public override bool RemoveWhenNotAlive => false; diff --git a/osu.Framework.Tests/Localisation/LocalisableStringTest.cs b/osu.Framework.Tests/Localisation/LocalisableStringTest.cs index a026a68c04..f6195c9548 100644 --- a/osu.Framework.Tests/Localisation/LocalisableStringTest.cs +++ b/osu.Framework.Tests/Localisation/LocalisableStringTest.cs @@ -99,6 +99,7 @@ public void TestNullEqualsNull() public void TestLocalisableStringDoesNotEqualNull() { testEquals(false, new LocalisableString(), new RomanisableString(makeStringA, makeStringB)); + testEquals(false, new RomanisableString(makeStringA, makeStringB), new LocalisableString()); } [Test] diff --git a/osu.Framework.Tests/Resources/Textures/sample-nine-slice-texture.png b/osu.Framework.Tests/Resources/Textures/sample-nine-slice-texture.png new file mode 100644 index 0000000000..cb881832df Binary files /dev/null and b/osu.Framework.Tests/Resources/Textures/sample-nine-slice-texture.png differ diff --git a/osu.Framework.Tests/Visual/Audio/TestSceneAudioMixer.cs b/osu.Framework.Tests/Visual/Audio/TestSceneAudioMixer.cs index 3234d0a852..0fa2481396 100644 --- a/osu.Framework.Tests/Visual/Audio/TestSceneAudioMixer.cs +++ b/osu.Framework.Tests/Visual/Audio/TestSceneAudioMixer.cs @@ -4,10 +4,8 @@ using System.Linq; using ManagedBass; using ManagedBass.Fx; -using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio.Sample; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Audio; using osu.Framework.Graphics.Containers; @@ -21,64 +19,88 @@ namespace osu.Framework.Tests.Visual.Audio { public partial class TestSceneAudioMixer : FrameworkTestScene { - [SetUp] - public void Setup() => Schedule(() => - { - ContainerWithEffect noEffectContainer; - FillFlowContainer effectContainers; + private readonly DragHandle dragHandle; + private readonly AudioPlayingDrawable audioDrawable; + private readonly ContainerWithEffect noEffectContainer; + private readonly FillFlowContainer effectContainers; - Child = noEffectContainer = new ContainerWithEffect("no effect", Color4.Black) + public TestSceneAudioMixer() + { + AddRange(new Drawable[] { - RelativeSizeAxes = Axes.Both, - Size = new Vector2(1), - Child = new Container + noEffectContainer = new ContainerWithEffect("no effect", Color4.Black, null) { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding(20), - Child = effectContainers = new FillFlowContainer + Size = new Vector2(1), + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, + effectContainers = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(20) + }, + audioDrawable = new AudioPlayingDrawable { Origin = Anchor.Centre } } + }, + dragHandle = new DragHandle + { + Origin = Anchor.Centre, + Position = new Vector2(50) } - }; + }); for (int i = 0; i < 50; i++) { float centre = 150 + 50 * i; - effectContainers.Add(new ContainerWithEffect($"<{centre}Hz", Color4.Blue) + effectContainers.Add(new ContainerWithEffect($"<{centre}Hz", Color4.Blue, new BQFParameters + { + lFilter = BQFType.LowPass, + fCenter = centre + }) { Size = new Vector2(100), - Effects = - { - new BQFParameters - { - lFilter = BQFType.LowPass, - fCenter = centre - } - } }); } + } - AudioBox audioBox; - noEffectContainer.Add(audioBox = new AudioBox(noEffectContainer, effectContainers)); + protected override void Update() + { + base.Update(); - Add(audioBox.CreateProxy()); - }); + Vector2 pos = dragHandle.ScreenSpaceDrawQuad.Centre; + Container container = effectContainers.SingleOrDefault(c => c.ScreenSpaceDrawQuad.Contains(pos)) ?? noEffectContainer; - private partial class AudioBox : CompositeDrawable - { - private readonly Container defaultParent; - private readonly Container effectContainers; + if (audioDrawable.Parent != container) + { + audioDrawable.Parent!.RemoveInternal(audioDrawable, false); + container.Add(audioDrawable); + } + } - public AudioBox(Container defaultParent, Container effectContainers) + private partial class AudioPlayingDrawable : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(ISampleStore samples) { - this.defaultParent = defaultParent; - this.effectContainers = effectContainers; + DrawableSample sample; + + AddInternal(new AudioContainer + { + Volume = { Value = 0.5f }, + Child = sample = new DrawableSample(samples.Get("long.mp3")) + }); - currentContainer = defaultParent; + var channel = sample.GetChannel(); + channel.Looping = true; + channel.Play(); + } + } - Origin = Anchor.Centre; + private partial class DragHandle : CompositeDrawable + { + public DragHandle() + { Size = new Vector2(50); InternalChild = new CircularContainer @@ -104,64 +126,24 @@ public AudioBox(Container defaultParent, Container true; - protected override void OnDrag(DragEvent e) - { - Position += e.Delta; - } - - private Container currentContainer; - - protected override void Update() - { - base.Update(); - - Vector2 centre = ScreenSpaceDrawQuad.Centre; - - Container targetContainer = effectContainers.FirstOrDefault(c => c.Contains(centre)) ?? defaultParent; - if (targetContainer == currentContainer) - return; - - currentContainer.Remove(this, false); - targetContainer.Add(this); - - Position = Parent!.ToLocalSpace(centre); - - currentContainer = targetContainer; - } + protected override void OnDrag(DragEvent e) => Position = e.MousePosition; } private partial class ContainerWithEffect : Container { protected override Container Content => content; - private readonly DrawableAudioMixer mixer; private readonly Container content; - private readonly Drawable background; - public ContainerWithEffect(string name, Color4 colour) + public ContainerWithEffect(string name, Color4 colour, IEffectParameter? effect) { Anchor = Anchor.Centre; Origin = Anchor.Centre; + DrawableAudioMixer mixer; InternalChild = mixer = new DrawableAudioMixer { RelativeSizeAxes = Axes.Both, @@ -186,14 +168,14 @@ public ContainerWithEffect(string name, Color4 colour) } } }; - } - public BindableList Effects => mixer.Effects; + if (effect != null) + mixer.AddEffect(effect); + } protected override void Update() { base.Update(); - background.Alpha = content.Count > 0 ? 1 : 0.2f; } } diff --git a/osu.Framework.Tests/Visual/Containers/TestSceneVirtualisedListContainer.cs b/osu.Framework.Tests/Visual/Containers/TestSceneVirtualisedListContainer.cs new file mode 100644 index 0000000000..b137b68886 --- /dev/null +++ b/osu.Framework.Tests/Visual/Containers/TestSceneVirtualisedListContainer.cs @@ -0,0 +1,173 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; + +namespace osu.Framework.Tests.Visual.Containers +{ + [TestFixture] + public partial class TestSceneVirtualisedListContainer : FrameworkTestScene + { + [Test] + public void TestNaiveList() + { + AddStep("create list", () => Child = new BasicScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + ChildrenEnumerable = Enumerable.Range(1, 10000).Select(i => new DrawableItem { Current = { Value = $"Item #{i}" } }) + } + }); + } + + [Test] + public void TestVirtualisedList() + { + ExampleVirtualisedList list = null!; + AddStep("create list", () => + { + Child = list = new ExampleVirtualisedList + { + RelativeSizeAxes = Axes.Both, + }; + list.RowData.AddRange(Enumerable.Range(1, 10000).Select(i => $"Item #{i}")); + }); + AddStep("replace items", () => + { + list.RowData.Clear(); + list.RowData.AddRange(Enumerable.Range(10001, 10000).Select(i => $"Item #{i}")); + }); + } + + [Test] + public void TestVirtualisedListDisposal() + { + ExampleVirtualisedList list = null!; + AddStep("create list nested in container", () => + { + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Child = list = new ExampleVirtualisedList + { + RelativeSizeAxes = Axes.Both, + } + } + }; + list.RowData.AddRange(Enumerable.Range(1, 10000).Select(i => $"Item #{i}")); + }); + AddStep("clear", Clear); + AddUntilStep("wait for async disposal", () => list.IsDisposed); + } + + [Test] + public void TestCollectionChangeHandling() + { + ExampleVirtualisedList list = null!; + + AddStep("create list", () => + { + Child = list = new ExampleVirtualisedList + { + RelativeSizeAxes = Axes.Both, + }; + list.RowData.AddRange(Enumerable.Range(1, 10).Select(i => $"Item #{i}")); + }); + + AddStep("insert at start", () => list.RowData.Insert(0, "first")); + AddStep("insert at end", () => list.RowData.Add("last")); + AddStep("remove from middle", () => list.RowData.RemoveAt(5)); + AddStep("move forward", () => list.RowData.Move(0, 4)); + AddStep("move back", () => list.RowData.Move(5, 2)); + AddStep("replace", () => list.RowData[3] = "replacing"); + AddStep("clear", () => list.RowData.Clear()); + } + + private partial class DrawableItem : PoolableDrawable, IHasCurrentValue + { + public const int HEIGHT = 25; + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + private Box background = null!; + private SpriteText text = null!; + + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + Height = HEIGHT; + InternalChildren = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = FrameworkColour.GreenDark, + }, + text = new SpriteText + { + RelativeSizeAxes = Axes.X, + Margin = new MarginPadding { Left = 10, }, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(_ => text.Text = Current.Value, true); + updateState(); + FinishTransforms(true); + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return true; + } + + private void updateState() => background.FadeTo(IsHovered ? 1 : 0, 300, Easing.OutQuint); + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + } + } + + private partial class ExampleVirtualisedList : VirtualisedListContainer + { + public ExampleVirtualisedList() + : base(DrawableItem.HEIGHT, 50) + { + } + + protected override ScrollContainer CreateScrollContainer() => new BasicScrollContainer(); + } + } +} diff --git a/osu.Framework.Tests/Visual/Drawables/TestSceneFastCircle.cs b/osu.Framework.Tests/Visual/Drawables/TestSceneFastCircle.cs new file mode 100644 index 0000000000..b0e464e289 --- /dev/null +++ b/osu.Framework.Tests/Visual/Drawables/TestSceneFastCircle.cs @@ -0,0 +1,188 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Framework.Testing; +using osuTK; +using osuTK.Input; + +namespace osu.Framework.Tests.Visual.Drawables +{ + public partial class TestSceneFastCircle : ManualInputManagerTestScene + { + private TestCircle fastCircle = null!; + private Circle circle = null!; + private CircularContainer fastCircleMask = null!; + private CircularContainer circleMask = null!; + + [SetUp] + public void Setup() => Schedule(() => + { + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.Absolute, 100), + new Dimension(), + }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Relative, 0.5f), + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + new SpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "FastCircle" + }, + new SpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Circle" + } + }, + new Drawable[] + { + fastCircleMask = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.TopRight, + Size = new Vector2(200), + Child = fastCircle = new TestCircle + { + Anchor = Anchor.TopRight, + Origin = Anchor.Centre, + Size = new Vector2(200), + Clicked = onClick + } + }, + circleMask = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.TopRight, + Size = new Vector2(200), + Child = circle = new Circle + { + Anchor = Anchor.TopRight, + Origin = Anchor.Centre, + Size = new Vector2(200) + } + }, + } + } + }; + }); + + [Test] + public void TestInput() + { + testInput(new Vector2(200, 100)); + testInput(new Vector2(100, 200)); + testInput(new Vector2(200, 200)); + } + + [Test] + public void TestSmoothness() + { + AddStep("Change smoothness to 0", () => fastCircle.EdgeSmoothness = circle.MaskingSmoothness = 0); + AddStep("Change smoothness to 1", () => fastCircle.EdgeSmoothness = circle.MaskingSmoothness = 1); + AddStep("Change smoothness to 5", () => fastCircle.EdgeSmoothness = circle.MaskingSmoothness = 5); + } + + [Test] + public void TestNestedMasking() + { + AddToggleStep("Toggle parent masking", m => fastCircleMask.Masking = circleMask.Masking = m); + } + + [Test] + public void TestRotation() + { + resize(new Vector2(200, 100)); + AddToggleStep("Toggle rotation", rotate => + { + fastCircle.ClearTransforms(); + circle.ClearTransforms(); + + if (rotate) + { + fastCircle.Spin(2000, RotationDirection.Clockwise); + circle.Spin(2000, RotationDirection.Clockwise); + } + }); + } + + [Test] + public void TestShear() + { + resize(new Vector2(200, 100)); + AddToggleStep("Toggle shear", shear => + { + fastCircle.Shear = circle.Shear = shear ? new Vector2(0.5f, 0) : Vector2.Zero; + }); + } + + [Test] + public void TestScale() + { + resize(new Vector2(200, 100)); + AddToggleStep("Toggle scale", scale => + { + fastCircle.Scale = circle.Scale = scale ? new Vector2(2f, 1f) : Vector2.One; + }); + } + + private void testInput(Vector2 size) + { + resize(size); + AddStep("Click outside the corner", () => clickNearCorner(-Vector2.One)); + AddAssert("input not received", () => clicked == false); + AddStep("Click inside the corner", () => clickNearCorner(Vector2.One)); + AddAssert("input received", () => clicked); + } + + private void resize(Vector2 size) + { + AddStep($"Resize to {size}", () => + { + fastCircle.Size = circle.Size = size; + }); + } + + private void clickNearCorner(Vector2 offset) + { + clicked = false; + InputManager.MoveMouseTo(fastCircle.ToScreenSpace(new Vector2(fastCircle.Radius * (1f - MathF.Sqrt(0.5f))) + offset)); + InputManager.Click(MouseButton.Left); + } + + private bool clicked; + + private void onClick() => clicked = true; + + private partial class TestCircle : FastCircle + { + public Action? Clicked; + + protected override bool OnClick(ClickEvent e) + { + base.OnClick(e); + Clicked?.Invoke(); + return true; + } + } + } +} diff --git a/osu.Framework.Tests/Visual/Drawables/TestSceneFocus.cs b/osu.Framework.Tests/Visual/Drawables/TestSceneFocus.cs index 64e5701afa..1da78d6cad 100644 --- a/osu.Framework.Tests/Visual/Drawables/TestSceneFocus.cs +++ b/osu.Framework.Tests/Visual/Drawables/TestSceneFocus.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Testing; @@ -135,7 +136,7 @@ public void RequestsFocusLosesFocusOnClickingFocused() } /// - /// Ensures that performing to a drawable with disabled returns . + /// Ensures that performing to a drawable with disabled returns . /// [Test] public void DisabledFocusDrawableCannotReceiveFocusViaChangeFocus() @@ -143,13 +144,13 @@ public void DisabledFocusDrawableCannotReceiveFocusViaChangeFocus() checkFocused(() => requestingFocus); AddStep("disable focus from top left", () => focusTopLeft.AllowAcceptingFocus = false); - AddAssert("cannot switch focus to top left", () => !InputManager.ChangeFocus(focusTopLeft)); + AddAssert("cannot switch focus to top left", () => !((IFocusManager)InputManager).ChangeFocus(focusTopLeft)); checkFocused(() => requestingFocus); } /// - /// Ensures that performing to a non-present drawable returns . + /// Ensures that performing to a non-present drawable returns . /// [Test] public void NotPresentDrawableCannotReceiveFocusViaChangeFocus() @@ -157,13 +158,13 @@ public void NotPresentDrawableCannotReceiveFocusViaChangeFocus() checkFocused(() => requestingFocus); AddStep("hide top left", () => focusTopLeft.Alpha = 0); - AddAssert("cannot switch focus to top left", () => !InputManager.ChangeFocus(focusTopLeft)); + AddAssert("cannot switch focus to top left", () => !((IFocusManager)InputManager).ChangeFocus(focusTopLeft)); checkFocused(() => requestingFocus); } /// - /// Ensures that performing to a drawable of a non-present parent returns . + /// Ensures that performing to a drawable of a non-present parent returns . /// [Test] public void DrawableOfNotPresentParentCannotReceiveFocusViaChangeFocus() @@ -183,7 +184,7 @@ public void DrawableOfNotPresentParentCannotReceiveFocusViaChangeFocus() Remove(focusTopLeft, false); container.Add(focusTopLeft); }); - AddAssert("cannot switch focus to top left", () => !InputManager.ChangeFocus(focusTopLeft)); + AddAssert("cannot switch focus to top left", () => !((IFocusManager)InputManager).ChangeFocus(focusTopLeft)); checkFocused(() => requestingFocus); } @@ -243,6 +244,91 @@ public void InputPropagation() focusBottomRight.JoystickPressCount == 1 && focusBottomRight.JoystickReleaseCount == 1); } + [Test] + public void TestDrawableWithNoFocusChangeOnClick() + { + FocusBox focusableBox = null!; + FocusBox noFocusChangeBox = null!; + + AddStep("setup", () => + { + Children = new Drawable[] + { + focusableBox = new FocusBox + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + noFocusChangeBox = new NoFocusChangeBox + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + AllowAcceptingFocus = false + } + }; + }); + + AddStep("click focusable box", () => + { + InputManager.MoveMouseTo(focusableBox); + InputManager.Click(MouseButton.Left); + }); + + checkFocused(() => focusableBox); + + AddStep("click no focus change box", () => + { + InputManager.MoveMouseTo(noFocusChangeBox); + InputManager.Click(MouseButton.Left); + }); + + checkFocused(() => focusableBox); + checkNotFocused(() => noFocusChangeBox); + AddAssert("no focus change box received click", () => noFocusChangeBox.ClickCount, () => Is.GreaterThan(0)); + } + + [Test] + public void TestChangeFocusDuringInputHandling_ShouldRetainFocus() + { + BasicButton button = null!; + FocusBox box = null!; + + AddStep("setup", () => + { + FocusBox b = new FocusBox + { + Position = new Vector2(0, 75) + }; + + Children = + [ + button = new BasicButton + { + Size = new Vector2(150, 50), + Text = "Focus the box", + Action = () => b.GetContainingFocusManager()!.ChangeFocus(b) + }, + box = b + ]; + }); + + AddStep("click button", () => + { + InputManager.MoveMouseTo(button); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("box is focused", () => box.HasFocus, () => Is.True); + + AddStep("click button again", () => + { + InputManager.MoveMouseTo(button); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("box is still focused", () => box.HasFocus, () => Is.True); + } + private void checkFocused(Func d) => AddAssert("check focus", () => d().HasFocus); private void checkNotFocused(Func d) => AddAssert("check not focus", () => !d().HasFocus); @@ -343,7 +429,7 @@ public RequestingFocusBox() public partial class FocusBox : CompositeDrawable { protected Box Box; - public int KeyDownCount, KeyUpCount, JoystickPressCount, JoystickReleaseCount; + public int KeyDownCount, KeyUpCount, JoystickPressCount, JoystickReleaseCount, ClickCount; public FocusBox() { @@ -358,7 +444,11 @@ public FocusBox() Size = new Vector2(0.4f); } - protected override bool OnClick(ClickEvent e) => true; + protected override bool OnClick(ClickEvent e) + { + ++ClickCount; + return true; + } public bool AllowAcceptingFocus = true; @@ -401,5 +491,22 @@ protected override void OnJoystickRelease(JoystickReleaseEvent e) base.OnJoystickRelease(e); } } + + public partial class NoFocusChangeBox : FocusBox + { + public NoFocusChangeBox() + { + Box.Colour = Color4.Green; + + AddInternal(new SpriteText + { + Text = "ChangeFocusOnClick = false", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + } + + public override bool ChangeFocusOnClick => false; + } } } diff --git a/osu.Framework.Tests/Visual/Graphics/TestSceneTripleBufferOccupancy.cs b/osu.Framework.Tests/Visual/Graphics/TestSceneTripleBufferOccupancy.cs new file mode 100644 index 0000000000..8b119c6de1 --- /dev/null +++ b/osu.Framework.Tests/Visual/Graphics/TestSceneTripleBufferOccupancy.cs @@ -0,0 +1,124 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; + +namespace osu.Framework.Tests.Visual.Graphics +{ + public partial class TestSceneTripleBufferOccupancy : FrameworkTestScene + { + private readonly CancellationTokenSource cts = new CancellationTokenSource(); + + private readonly TextFlowContainer text; + + private long[] writes = new long[3]; + private long[] reads = new long[3]; + private Stopwatch stopwatch = Stopwatch.StartNew(); + + private int writeLag; + private int readLag; + + public TestSceneTripleBufferOccupancy() + { + Add(text = new TextFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + TripleBuffer tripleBuffer = new TripleBuffer(); + + new Thread(() => + { + while (!cts.IsCancellationRequested) + { + using (var write = tripleBuffer.GetForWrite()) + writes[write.Index]++; + + if (writeLag != 0) + Thread.Sleep(writeLag); + } + }).Start(); + + new Thread(() => + { + while (!cts.IsCancellationRequested) + { + using (var read = tripleBuffer.GetForRead()) + { + if (read != null) + reads[read.Index]++; + } + + if (readLag != 0) + Thread.Sleep(readLag); + } + }).Start(); + + AddSliderStep("write lag", 0, 16, 0, v => + { + writeLag = v; + reset(); + }); + + AddSliderStep("read lag", 0, 16, 0, v => + { + readLag = v; + reset(); + }); + + reset(); + } + + private void reset() + { + writes = new long[3]; + reads = new long[3]; + stopwatch = Stopwatch.StartNew(); + } + + protected override void Update() + { + base.Update(); + + StringBuilder info = new StringBuilder(); + + double totalWrites = writes.Sum(); + double totalReads = reads.Sum(); + + info.AppendLine("write occupancy:"); + for (int i = 0; i < writes.Length; i++) + info.AppendLine($"{i}: {writes[i] / totalWrites,-10:P}({writes[i]} / {totalWrites})"); + + info.AppendLine(); + + info.AppendLine("read occupancy:"); + for (int i = 0; i < reads.Length; i++) + info.AppendLine($"{i}: {reads[i] / totalReads,-10:P}({reads[i]} / {totalReads})"); + + info.AppendLine(); + + info.AppendLine($"Speed: {stopwatch.Elapsed.TotalMicroseconds / totalReads:0.00}us/read"); + + text.Text = info.ToString(); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + cts.Cancel(); + } + } +} diff --git a/osu.Framework.Tests/Visual/Graphics/TestSceneVertexBatching.cs b/osu.Framework.Tests/Visual/Graphics/TestSceneVertexBatching.cs index 9e1b03b309..13767014d7 100644 --- a/osu.Framework.Tests/Visual/Graphics/TestSceneVertexBatching.cs +++ b/osu.Framework.Tests/Visual/Graphics/TestSceneVertexBatching.cs @@ -3,13 +3,11 @@ using System.Linq; using NUnit.Framework; -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; -using osu.Framework.Platform; using osu.Framework.Utils; using osuTK; using osuTK.Graphics; @@ -18,8 +16,10 @@ namespace osu.Framework.Tests.Visual.Graphics { public partial class TestSceneVertexBatching : FrameworkTestScene { - [Resolved] - private GameHost host { get; set; } = null!; + /// + /// Max number of quads per batch in the default quad batch (Renderer.defaultQuadBatch). + /// + private const int max_boxes_per_batch = 100; [Test] public void TestBatchUntilOverflow() @@ -28,8 +28,6 @@ public void TestBatchUntilOverflow() { Clear(); - int boxesPerBatch = host.Renderer.DefaultQuadBatch.Size; - Add(new FillFlowContainer { Anchor = Anchor.Centre, @@ -37,7 +35,7 @@ public void TestBatchUntilOverflow() RelativeSizeAxes = Axes.Both, Margin = new MarginPadding(25f), Spacing = new Vector2(10f), - ChildrenEnumerable = Enumerable.Range(0, boxesPerBatch * 2).Select(i => new Box + ChildrenEnumerable = Enumerable.Range(0, max_boxes_per_batch * 2).Select(i => new Box { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -55,8 +53,6 @@ public void TestBatchWithFlushes() { Clear(); - int boxesPerBatch = host.Renderer.DefaultQuadBatch.Size; - Add(new FillFlowContainer { Anchor = Anchor.Centre, @@ -64,7 +60,7 @@ public void TestBatchWithFlushes() RelativeSizeAxes = Axes.Both, Margin = new MarginPadding(25f), Spacing = new Vector2(10f), - ChildrenEnumerable = Enumerable.Range(0, boxesPerBatch * 2).Select(i => new BoxWithFlush + ChildrenEnumerable = Enumerable.Range(0, max_boxes_per_batch * 2).Select(i => new BoxWithFlush { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Framework.Tests/Visual/Input/TestSceneHandleInput.cs b/osu.Framework.Tests/Visual/Input/TestSceneHandleInput.cs index 86b48b3217..b50720f482 100644 --- a/osu.Framework.Tests/Visual/Input/TestSceneHandleInput.cs +++ b/osu.Framework.Tests/Visual/Input/TestSceneHandleInput.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Input; using osu.Framework.Testing; using osuTK; using osuTK.Graphics; @@ -37,7 +38,7 @@ public TestSceneHandleInput() { handleNonPositionalInput.Enabled = true; InputManager.MoveMouseTo(handleNonPositionalInput); - InputManager.TriggerFocusContention(null); + ((IFocusManager)InputManager).TriggerFocusContention(null); }); AddAssert($"check {nameof(handleNonPositionalInput)}", () => !handleNonPositionalInput.IsHovered && handleNonPositionalInput.HasFocus); diff --git a/osu.Framework.Tests/Visual/Input/TestSceneInputManager.cs b/osu.Framework.Tests/Visual/Input/TestSceneInputManager.cs index 42ff0cd1b5..b1300d18c5 100644 --- a/osu.Framework.Tests/Visual/Input/TestSceneInputManager.cs +++ b/osu.Framework.Tests/Visual/Input/TestSceneInputManager.cs @@ -74,6 +74,9 @@ public partial class ContainingInputManagerStatusText : Container mouseStatus, keyboardStatus, joystickStatus, + touchStatus, + midiStatus, + tabletStatus, onMouseDownStatus, onMouseUpStatus, onMouseMoveStatus, @@ -100,6 +103,9 @@ public ContainingInputManagerStatusText() mouseStatus = new SmallText(), keyboardStatus = new SmallText(), joystickStatus = new SmallText(), + touchStatus = new SmallText(), + midiStatus = new SmallText(), + tabletStatus = new SmallText(), onMouseDownStatus = new SmallText { Text = "OnMouseDown 0" }, onMouseUpStatus = new SmallText { Text = "OnMouseUp 0" }, onMouseMoveStatus = new SmallText { Text = "OnMouseMove 0" }, @@ -113,12 +119,14 @@ public ContainingInputManagerStatusText() protected override void Update() { var inputManager = GetContainingInputManager(); - var currentState = inputManager.CurrentState; - var mouse = currentState.Mouse; + var currentState = inputManager!.CurrentState; inputManagerStatus.Text = $"{inputManager}"; - mouseStatus.Text = $"Mouse: {mouse.Position} {mouse.Scroll} " + string.Join(' ', mouse.Buttons); + mouseStatus.Text = $"Mouse: {currentState.Mouse.Position} {currentState.Mouse.Scroll} " + string.Join(' ', currentState.Mouse.Buttons); keyboardStatus.Text = "Keyboard: " + string.Join(' ', currentState.Keyboard.Keys); joystickStatus.Text = "Joystick: " + string.Join(' ', currentState.Joystick.Buttons); + touchStatus.Text = $"Touch: {string.Join(' ', currentState.Touch.ActiveSources.Select(s => $"({s},{currentState.Touch.GetTouchPosition(s)})"))}"; + midiStatus.Text = "MIDI: " + string.Join(' ', currentState.Midi.Keys.Select(k => $"({k},{currentState.Midi.Velocities[k]})")); + tabletStatus.Text = "Tablet: " + string.Join(' ', currentState.Tablet.PenButtons) + " " + string.Join(' ', currentState.Tablet.AuxiliaryButtons); base.Update(); } @@ -167,6 +175,14 @@ protected override bool OnHover(HoverEvent e) return base.OnHover(e); } + public int KeyDownCount; + + protected override bool OnKeyDown(KeyDownEvent e) + { + ++KeyDownCount; + return base.OnKeyDown(e); + } + protected override bool OnClick(ClickEvent e) { this.MoveToOffset(new Vector2(100, 0)).Then().MoveToOffset(new Vector2(-100, 0), 1000, Easing.In); diff --git a/osu.Framework.Tests/Visual/Input/TestSceneInputPropagationForLoadStates.cs b/osu.Framework.Tests/Visual/Input/TestSceneInputPropagationForLoadStates.cs index 5a4528da23..2ec64a7380 100644 --- a/osu.Framework.Tests/Visual/Input/TestSceneInputPropagationForLoadStates.cs +++ b/osu.Framework.Tests/Visual/Input/TestSceneInputPropagationForLoadStates.cs @@ -53,7 +53,7 @@ protected override void LoadComplete() { base.LoadComplete(); var inputManager = GetContainingInputManager(); - new KeyboardKeyInput(Key.A, true).Apply(inputManager.CurrentState, inputManager); + new KeyboardKeyInput(Key.A, true).Apply(inputManager!.CurrentState, inputManager); } } } diff --git a/osu.Framework.Tests/Visual/Input/TestScenePassThroughInputManager.cs b/osu.Framework.Tests/Visual/Input/TestScenePassThroughInputManager.cs index 2f01d18b3c..7de2f5014d 100644 --- a/osu.Framework.Tests/Visual/Input/TestScenePassThroughInputManager.cs +++ b/osu.Framework.Tests/Visual/Input/TestScenePassThroughInputManager.cs @@ -50,25 +50,6 @@ private void addTestInputManagerStep() ChildrenEnumerable = Enumerable.Empty(); }); - [Test] - public void ReceiveInitialState() - { - AddStep("Press mouse left", () => InputManager.PressButton(MouseButton.Left)); - AddStep("Press A", () => InputManager.PressKey(Key.A)); - AddStep("Press Joystick", () => InputManager.PressJoystickButton(JoystickButton.Button1)); - addTestInputManagerStep(); - AddAssert("mouse left not pressed", () => !mouse.IsPressed(MouseButton.Left)); - AddAssert("A pressed", () => keyboard.IsPressed(Key.A)); - AddAssert("Joystick pressed", () => joystick.IsPressed(JoystickButton.Button1)); - AddStep("Release", () => - { - InputManager.ReleaseButton(MouseButton.Left); - InputManager.ReleaseKey(Key.A); - InputManager.ReleaseJoystickButton(JoystickButton.Button1); - }); - AddAssert("All released", () => !mouse.HasAnyButtonPressed && !keyboard.HasAnyButtonPressed && !joystick.HasAnyButtonPressed); - } - [Test] public void UseParentInputChange() { @@ -94,22 +75,7 @@ public void UseParentInputChange() } [Test] - public void TestUpReceivedOnDownFromSync() - { - addTestInputManagerStep(); - AddStep("UseParentInput = false", () => testInputManager.UseParentInput = false); - AddStep("press keyboard", () => InputManager.PressKey(Key.A)); - AddAssert("key not pressed", () => !testInputManager.CurrentState.Keyboard.Keys.HasAnyButtonPressed); - - AddStep("UseParentInput = true", () => testInputManager.UseParentInput = true); - AddAssert("key pressed", () => testInputManager.CurrentState.Keyboard.Keys.Single() == Key.A); - - AddStep("release keyboard", () => InputManager.ReleaseKey(Key.A)); - AddAssert("key released", () => !testInputManager.CurrentState.Keyboard.Keys.HasAnyButtonPressed); - } - - [Test] - public void MouseDownNoSync() + public void TestMouseDownNoSync() { addTestInputManagerStep(); AddStep("UseParentInput = false", () => testInputManager.UseParentInput = false); @@ -119,7 +85,7 @@ public void MouseDownNoSync() } [Test] - public void NoMouseUp() + public void TestNoMouseUp() { addTestInputManagerStep(); AddStep("Press left", () => InputManager.PressButton(MouseButton.Left)); @@ -134,28 +100,82 @@ public void NoMouseUp() AddAssert("mouse up count == 0", () => testInputManager.Status.MouseUpCount == 0); } + [Test] + public void TestKeyInput() + { + addTestInputManagerStep(); + AddStep("press key", () => InputManager.PressKey(Key.A)); + AddAssert("key pressed", () => testInputManager.CurrentState.Keyboard.Keys.Single() == Key.A); + + AddStep("release key", () => InputManager.ReleaseKey(Key.A)); + AddAssert("key released", () => !testInputManager.CurrentState.Keyboard.Keys.HasAnyButtonPressed); + + AddStep("press key", () => InputManager.PressKey(Key.A)); + AddStep("UseParentInput = false", () => testInputManager.UseParentInput = false); + AddStep("release key", () => InputManager.ReleaseKey(Key.A)); + AddStep("press another key", () => InputManager.PressKey(Key.B)); + + AddAssert("only first key pressed", () => testInputManager.CurrentState.Keyboard.Keys.Single() == Key.A); + + AddStep("UseParentInput = true", () => testInputManager.UseParentInput = true); + AddAssert("key released", () => !testInputManager.CurrentState.Keyboard.Keys.HasAnyButtonPressed); + } + + [Test] + public void TestPressKeyThenReleaseWhileDisabled() + { + addTestInputManagerStep(); + AddStep("press key", () => InputManager.PressKey(Key.A)); + AddStep("UseParentInput = false", () => testInputManager.UseParentInput = false); + AddStep("release key", () => InputManager.ReleaseKey(Key.A)); + AddStep("press key again", () => InputManager.PressKey(Key.A)); + AddStep("UseParentInput = true", () => testInputManager.UseParentInput = true); + AddStep("release key", () => InputManager.ReleaseKey(Key.A)); + AddAssert("key released", () => !testInputManager.CurrentState.Keyboard.Keys.HasAnyButtonPressed); + + AddStep("press key", () => InputManager.PressKey(Key.A)); + AddStep("UseParentInput = false", () => testInputManager.UseParentInput = false); + + AddStep("add blocking layer", () => Add(new HandlingBox + { + RelativeSizeAxes = Axes.Both, + OnHandle = _ => true, + })); + + // with a blocking layer existing, the next key press will not be seen by PassThroughInputManager... + AddStep("release key", () => InputManager.ReleaseKey(Key.A)); + AddStep("press key again", () => InputManager.PressKey(Key.A)); + + AddStep("UseParentInput = true", () => testInputManager.UseParentInput = true); + + // ...but ensure it'll still release the key regardless of not seeing the corresponding press event (it does that by syncing releases every frame). + AddStep("release key", () => InputManager.ReleaseKey(Key.A)); + AddAssert("key released", () => !testInputManager.CurrentState.Keyboard.Keys.HasAnyButtonPressed); + } + [Test] public void TestTouchInput() { addTestInputManagerStep(); AddStep("begin first touch", () => InputManager.BeginTouch(new Touch(TouchSource.Touch1, Vector2.Zero))); - AddAssert("synced properly", () => + AddAssert("first touch active", () => testInputManager.CurrentState.Touch.ActiveSources.Single() == TouchSource.Touch1 && testInputManager.CurrentState.Touch.TouchPositions[(int)TouchSource.Touch1] == Vector2.Zero); + AddStep("end first touch", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, Vector2.Zero))); + AddAssert("first touch not active", () => !testInputManager.CurrentState.Touch.ActiveSources.HasAnyButtonPressed); + + AddStep("begin first touch", () => InputManager.BeginTouch(new Touch(TouchSource.Touch1, Vector2.Zero))); AddStep("UseParentInput = false", () => testInputManager.UseParentInput = false); AddStep("end first touch", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, Vector2.Zero))); AddStep("begin second touch", () => InputManager.BeginTouch(new Touch(TouchSource.Touch2, Vector2.One))); + AddAssert("only first touch active", () => + testInputManager.CurrentState.Touch.ActiveSources.Single() == TouchSource.Touch1 && + testInputManager.CurrentState.Touch.TouchPositions[(int)TouchSource.Touch1] == Vector2.Zero); + AddStep("UseParentInput = true", () => testInputManager.UseParentInput = true); - AddAssert("synced properly", () => - testInputManager.CurrentState.Touch.ActiveSources.Single() == TouchSource.Touch2 && - testInputManager.CurrentState.Touch.TouchPositions[(int)TouchSource.Touch2] == Vector2.One); - - AddStep("end second touch", () => InputManager.EndTouch(new Touch(TouchSource.Touch2, new Vector2(2)))); - AddAssert("synced properly", () => - !testInputManager.CurrentState.Touch.ActiveSources.HasAnyButtonPressed && - testInputManager.CurrentState.Touch.TouchPositions[(int)TouchSource.Touch2] == new Vector2(2)); + AddAssert("no touches active", () => !testInputManager.CurrentState.Touch.ActiveSources.HasAnyButtonPressed); } [Test] @@ -164,20 +184,55 @@ public void TestMidiInput() addTestInputManagerStep(); AddStep("press C3", () => InputManager.PressMidiKey(MidiKey.C3, 70)); - AddAssert("synced properly", () => + AddAssert("C3 pressed", () => testInputManager.CurrentState.Midi.Keys.IsPressed(MidiKey.C3) && testInputManager.CurrentState.Midi.Velocities[MidiKey.C3] == 70); + AddStep("release C3", () => InputManager.ReleaseMidiKey(MidiKey.C3, 40)); + AddAssert("C3 released", () => !testInputManager.CurrentState.Midi.Keys.HasAnyButtonPressed); + + AddStep("press C3", () => InputManager.PressMidiKey(MidiKey.C3, 70)); AddStep("UseParentInput = false", () => testInputManager.UseParentInput = false); AddStep("release C3", () => InputManager.ReleaseMidiKey(MidiKey.C3, 40)); AddStep("press F#3", () => InputManager.PressMidiKey(MidiKey.FSharp3, 65)); + AddAssert("only C3 pressed", () => + testInputManager.CurrentState.Midi.Keys.Single() == MidiKey.C3 + && testInputManager.CurrentState.Midi.Velocities[MidiKey.C3] == 70); + AddStep("UseParentInput = true", () => testInputManager.UseParentInput = true); - AddAssert("synced properly", () => - !testInputManager.CurrentState.Midi.Keys.IsPressed(MidiKey.C3) && - testInputManager.CurrentState.Midi.Velocities[MidiKey.C3] == 40 && - testInputManager.CurrentState.Midi.Keys.IsPressed(MidiKey.FSharp3) && - testInputManager.CurrentState.Midi.Velocities[MidiKey.FSharp3] == 65); + AddAssert("C3 released", () => !testInputManager.CurrentState.Midi.Keys.HasAnyButtonPressed); + } + + [Test] + public void TestTabletButtonInput() + { + addTestInputManagerStep(); + + AddStep("press primary pen button", () => InputManager.PressTabletPenButton(TabletPenButton.Primary)); + AddStep("press auxiliary button 4", () => InputManager.PressTabletAuxiliaryButton(TabletAuxiliaryButton.Button4)); + AddAssert("primary pen button pressed", () => testInputManager.CurrentState.Tablet.PenButtons.Single() == TabletPenButton.Primary); + AddAssert("auxiliary button 4 pressed", () => testInputManager.CurrentState.Tablet.AuxiliaryButtons.Single() == TabletAuxiliaryButton.Button4); + + AddStep("release primary pen button", () => InputManager.ReleaseTabletPenButton(TabletPenButton.Primary)); + AddStep("release auxiliary button 4", () => InputManager.ReleaseTabletAuxiliaryButton(TabletAuxiliaryButton.Button4)); + AddAssert("primary pen button pressed", () => !testInputManager.CurrentState.Tablet.PenButtons.HasAnyButtonPressed); + AddAssert("auxiliary button 4 pressed", () => !testInputManager.CurrentState.Tablet.AuxiliaryButtons.HasAnyButtonPressed); + + AddStep("press primary pen button", () => InputManager.PressTabletPenButton(TabletPenButton.Primary)); + AddStep("press auxiliary button 4", () => InputManager.PressTabletAuxiliaryButton(TabletAuxiliaryButton.Button4)); + AddStep("UseParentInput = false", () => testInputManager.UseParentInput = false); + AddStep("release primary pen button", () => InputManager.ReleaseTabletPenButton(TabletPenButton.Primary)); + AddStep("release auxiliary button 4", () => InputManager.ReleaseTabletAuxiliaryButton(TabletAuxiliaryButton.Button4)); + AddStep("press secondary pen button", () => InputManager.PressTabletPenButton(TabletPenButton.Secondary)); + AddStep("press auxiliary button 2", () => InputManager.PressTabletAuxiliaryButton(TabletAuxiliaryButton.Button2)); + + AddAssert("only primary pen button pressed", () => testInputManager.CurrentState.Tablet.PenButtons.Single() == TabletPenButton.Primary); + AddAssert("only auxiliary button 4 pressed", () => testInputManager.CurrentState.Tablet.AuxiliaryButtons.Single() == TabletAuxiliaryButton.Button4); + + AddStep("UseParentInput = true", () => testInputManager.UseParentInput = true); + AddAssert("primary pen button released", () => !testInputManager.CurrentState.Tablet.PenButtons.HasAnyButtonPressed); + AddAssert("auxiliary button 4 released", () => !testInputManager.CurrentState.Tablet.AuxiliaryButtons.HasAnyButtonPressed); } [Test] @@ -217,30 +272,6 @@ public void TestMouseTouchProductionOnPassThrough() AddAssert("pass-through handled mouse", () => testInputManager.CurrentState.Mouse.Buttons.Single() == MouseButton.Left); } - [Test] - public void TestTabletButtonInput() - { - addTestInputManagerStep(); - - AddStep("press primary pen button", () => InputManager.PressTabletPenButton(TabletPenButton.Primary)); - AddStep("press auxiliary button 4", () => InputManager.PressTabletAuxiliaryButton(TabletAuxiliaryButton.Button4)); - - AddStep("UseParentInput = false", () => testInputManager.UseParentInput = false); - - AddStep("release primary pen button", () => InputManager.ReleaseTabletPenButton(TabletPenButton.Primary)); - AddStep("press tertiary pen button", () => InputManager.PressTabletPenButton(TabletPenButton.Tertiary)); - AddStep("release auxiliary button 4", () => InputManager.ReleaseTabletAuxiliaryButton(TabletAuxiliaryButton.Button4)); - AddStep("press auxiliary button 2", () => InputManager.PressTabletAuxiliaryButton(TabletAuxiliaryButton.Button2)); - - AddStep("UseParentInput = true", () => testInputManager.UseParentInput = true); - AddAssert("pen buttons synced properly", () => - !testInputManager.CurrentState.Tablet.PenButtons.Contains(TabletPenButton.Primary) - && testInputManager.CurrentState.Tablet.PenButtons.Contains(TabletPenButton.Tertiary)); - AddAssert("auxiliary buttons synced properly", () => - !testInputManager.CurrentState.Tablet.AuxiliaryButtons.Contains(TabletAuxiliaryButton.Button4) - && testInputManager.CurrentState.Tablet.AuxiliaryButtons.Contains(TabletAuxiliaryButton.Button2)); - } - public partial class TestInputManager : ManualInputManager { public readonly TestSceneInputManager.ContainingInputManagerStatusText Status; diff --git a/osu.Framework.Tests/Visual/Input/TestSceneReadableKeyCombinationProvider.cs b/osu.Framework.Tests/Visual/Input/TestSceneReadableKeyCombinationProvider.cs index 89df608755..21d8e43af4 100644 --- a/osu.Framework.Tests/Visual/Input/TestSceneReadableKeyCombinationProvider.cs +++ b/osu.Framework.Tests/Visual/Input/TestSceneReadableKeyCombinationProvider.cs @@ -139,7 +139,7 @@ protected override void LoadComplete() protected override bool OnKeyDown(KeyDownEvent e) { - if (keyCombination.IsPressed(new KeyCombination(KeyCombination.FromKey(e.Key)), KeyCombinationMatchingMode.Any)) + if (keyCombination.IsPressed(new KeyCombination(KeyCombination.FromKey(e.Key)), e.CurrentState, KeyCombinationMatchingMode.Any)) box.Colour = Color4.Navy; return base.OnKeyDown(e); @@ -147,7 +147,7 @@ protected override bool OnKeyDown(KeyDownEvent e) protected override void OnKeyUp(KeyUpEvent e) { - if (keyCombination.IsPressed(new KeyCombination(KeyCombination.FromKey(e.Key)), KeyCombinationMatchingMode.Any)) + if (keyCombination.IsPressed(new KeyCombination(KeyCombination.FromKey(e.Key)), e.CurrentState, KeyCombinationMatchingMode.Any)) box.Colour = Color4.DarkGray; base.OnKeyUp(e); diff --git a/osu.Framework.Tests/Visual/Performance/TestSceneCircleBoxAlternatePerformance.cs b/osu.Framework.Tests/Visual/Performance/TestSceneCircleBoxAlternatePerformance.cs new file mode 100644 index 0000000000..5f06e8c154 --- /dev/null +++ b/osu.Framework.Tests/Visual/Performance/TestSceneCircleBoxAlternatePerformance.cs @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; + +namespace osu.Framework.Tests.Visual.Performance +{ + public sealed partial class TestSceneCircleBoxAlternatePerformance : RepeatedDrawablePerformanceTestScene + { + private int index; + + protected override Drawable CreateDrawable() + { + index++; + if (index % 2 == 0) + return new Circle(); + + return new Box(); + } + } +} diff --git a/osu.Framework.Tests/Visual/Performance/TestSceneFastCircleBoxAlternatePerformance.cs b/osu.Framework.Tests/Visual/Performance/TestSceneFastCircleBoxAlternatePerformance.cs new file mode 100644 index 0000000000..137cb6ca1d --- /dev/null +++ b/osu.Framework.Tests/Visual/Performance/TestSceneFastCircleBoxAlternatePerformance.cs @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; + +namespace osu.Framework.Tests.Visual.Performance +{ + public sealed partial class TestSceneFastCircleBoxAlternatePerformance : RepeatedDrawablePerformanceTestScene + { + private int index; + + protected override Drawable CreateDrawable() + { + index++; + if (index % 2 == 0) + return new FastCircle(); + + return new Box(); + } + } +} diff --git a/osu.Framework.Tests/Visual/Performance/TestSceneFastCirclePerformance.cs b/osu.Framework.Tests/Visual/Performance/TestSceneFastCirclePerformance.cs new file mode 100644 index 0000000000..3e4bd26c0d --- /dev/null +++ b/osu.Framework.Tests/Visual/Performance/TestSceneFastCirclePerformance.cs @@ -0,0 +1,13 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; + +namespace osu.Framework.Tests.Visual.Performance +{ + public sealed partial class TestSceneFastCirclePerformance : RepeatedDrawablePerformanceTestScene + { + protected override Drawable CreateDrawable() => new FastCircle(); + } +} diff --git a/osu.Framework.Tests/Visual/Platform/TestSceneBorderless.cs b/osu.Framework.Tests/Visual/Platform/TestSceneBorderless.cs index 7c26a30830..4db6b85479 100644 --- a/osu.Framework.Tests/Visual/Platform/TestSceneBorderless.cs +++ b/osu.Framework.Tests/Visual/Platform/TestSceneBorderless.cs @@ -24,7 +24,7 @@ public partial class TestSceneBorderless : FrameworkTestScene private readonly SpriteText currentWindowMode = new SpriteText(); private readonly SpriteText currentDisplay = new SpriteText(); - private SDL3Window? window; + private ISDLWindow? window; private readonly Bindable windowMode = new Bindable(); public TestSceneBorderless() @@ -57,7 +57,7 @@ public TestSceneBorderless() [BackgroundDependencyLoader] private void load(FrameworkConfigManager config, GameHost host) { - window = host.Window as SDL3Window; + window = host.Window as ISDLWindow; config.BindWith(FrameworkSetting.WindowMode, windowMode); windowMode.BindValueChanged(mode => currentWindowMode.Text = $"Window Mode: {mode.NewValue}", true); diff --git a/osu.Framework.Tests/Visual/Platform/TestSceneFullscreen.cs b/osu.Framework.Tests/Visual/Platform/TestSceneFullscreen.cs index e1907944b1..4d4d91857c 100644 --- a/osu.Framework.Tests/Visual/Platform/TestSceneFullscreen.cs +++ b/osu.Framework.Tests/Visual/Platform/TestSceneFullscreen.cs @@ -129,7 +129,7 @@ public void TestScreenModeSwitch() if (window.SupportedWindowModes.Contains(WindowMode.Fullscreen)) { AddStep("change to fullscreen", () => windowMode.Value = WindowMode.Fullscreen); - AddAssert("window position updated", () => ((SDL3Window)window).Position, () => Is.EqualTo(window.CurrentDisplayBindable.Value.Bounds.Location)); + AddAssert("window position updated", () => ((ISDLWindow)window).Position, () => Is.EqualTo(window.CurrentDisplayBindable.Value.Bounds.Location)); testResolution(1920, 1080); testResolution(1280, 960); testResolution(9999, 9999); diff --git a/osu.Framework.Tests/Visual/Platform/TestScenePresentFileExternally.cs b/osu.Framework.Tests/Visual/Platform/TestScenePresentFileExternally.cs index 2fa1f4427b..17df49bc82 100644 --- a/osu.Framework.Tests/Visual/Platform/TestScenePresentFileExternally.cs +++ b/osu.Framework.Tests/Visual/Platform/TestScenePresentFileExternally.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -78,7 +79,7 @@ protected override void LoadComplete() ), new ButtonWithDescription ( - () => logStorage.PresentFileExternally(@"runtime.log"), + () => logStorage.PresentFileExternally(logStorage.GetFiles(string.Empty, "*runtime*").First()), @"show runtime.log", @"Opens: 'logs' Selected: 'runtime.log'" ), diff --git a/osu.Framework.Tests/Visual/Platform/TestSceneWindowed.cs b/osu.Framework.Tests/Visual/Platform/TestSceneWindowed.cs index 5015a1e4ff..77248a6e16 100644 --- a/osu.Framework.Tests/Visual/Platform/TestSceneWindowed.cs +++ b/osu.Framework.Tests/Visual/Platform/TestSceneWindowed.cs @@ -31,12 +31,12 @@ public partial class TestSceneWindowed : FrameworkTestScene [Resolved] private FrameworkConfigManager config { get; set; } - private SDL3Window sdlWindow; + private ISDLWindow sdlWindow; [BackgroundDependencyLoader] private void load() { - sdlWindow = (SDL3Window)host.Window; + sdlWindow = (ISDLWindow)host.Window; Children = new Drawable[] { new FillFlowContainer diff --git a/osu.Framework.Tests/Visual/Platform/WindowDisplaysPreview.cs b/osu.Framework.Tests/Visual/Platform/WindowDisplaysPreview.cs index abb03eae3d..03ee8503af 100644 --- a/osu.Framework.Tests/Visual/Platform/WindowDisplaysPreview.cs +++ b/osu.Framework.Tests/Visual/Platform/WindowDisplaysPreview.cs @@ -36,7 +36,7 @@ public partial class WindowDisplaysPreview : Container private static readonly Color4 window_fill = new Color4(95, 113, 197, 255); private static readonly Color4 window_stroke = new Color4(36, 59, 166, 255); - private SDL3Window? window; + private ISDLWindow? window; private readonly Bindable windowMode = new Bindable(); private readonly Bindable currentDisplay = new Bindable(); @@ -90,7 +90,7 @@ public WindowDisplaysPreview() [BackgroundDependencyLoader] private void load(FrameworkConfigManager config, GameHost host) { - window = host.Window as SDL3Window; + window = host.Window as ISDLWindow; config.BindWith(FrameworkSetting.WindowMode, windowMode); if (window != null) diff --git a/osu.Framework.Tests/Visual/Sprites/TestSceneNineSliceSprite.cs b/osu.Framework.Tests/Visual/Sprites/TestSceneNineSliceSprite.cs new file mode 100644 index 0000000000..2ee862fe54 --- /dev/null +++ b/osu.Framework.Tests/Visual/Sprites/TestSceneNineSliceSprite.cs @@ -0,0 +1,185 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Globalization; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osuTK; + +namespace osu.Framework.Tests.Visual.Sprites +{ + [TestFixture] + public partial class TestSceneNineSliceSprite : FrameworkTestScene + { + private readonly Bindable topInset = new BindableFloat(100) { MinValue = 0 }; + private readonly Bindable bottomInset = new BindableFloat(100) { MinValue = 0 }; + private readonly Bindable leftInset = new BindableFloat(100) { MinValue = 0 }; + private readonly Bindable rightInset = new BindableFloat(100) { MinValue = 0 }; + private readonly Bindable relativeInsetAxes = new Bindable(); + + private NineSliceSprite sprite = null!; + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + AddRange(new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Width = 0.3f, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + Padding = new MarginPadding(10), + Children = new Drawable[] + { + new LabelledContainer("Left") + { + Child = new InsetTextBox(leftInset) { TabbableContentContainer = this } + }, + new LabelledContainer("Right") + { + Child = new InsetTextBox(rightInset) { TabbableContentContainer = this } + }, + new LabelledContainer("Top") + { + Child = new InsetTextBox(topInset) { TabbableContentContainer = this } + }, + new LabelledContainer("Bottom") + { + Child = new InsetTextBox(bottomInset) { TabbableContentContainer = this } + }, + new LabelledContainer("RelativeInsetAxes") + { + Child = new Container + { + RelativeSizeAxes = Axes.X, + Height = 30, + Child = new BasicDropdown + { + RelativeSizeAxes = Axes.X, + Items = new[] + { + Axes.None, + Axes.X, + Axes.Y, + Axes.Both, + }, + Current = relativeInsetAxes, + } + } + } + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Width = 0.7f, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Child = sprite = new NineSliceSprite + { + Texture = textures.Get("sample-nine-slice-texture.png"), + TextureInset = new MarginPadding(100), + } + } + }); + + sprite.ResizeTo(new Vector2(600, 400), 1000, Easing.InOutCubic) + .Then() + .ResizeTo(new Vector2(256, 256), 1000, Easing.InOutCubic) + .Loop(); + + leftInset.BindValueChanged(e => sprite.TextureInset = sprite.TextureInset with { Left = e.NewValue }); + topInset.BindValueChanged(e => sprite.TextureInset = sprite.TextureInset with { Top = e.NewValue }); + rightInset.BindValueChanged(e => sprite.TextureInset = sprite.TextureInset with { Right = e.NewValue }); + bottomInset.BindValueChanged(e => sprite.TextureInset = sprite.TextureInset with { Bottom = e.NewValue }); + + relativeInsetAxes.BindValueChanged(e => + { + sprite.TextureInsetRelativeAxes = e.NewValue; + + leftInset.Value = sprite.TextureInset.Left; + topInset.Value = sprite.TextureInset.Top; + rightInset.Value = sprite.TextureInset.Right; + bottomInset.Value = sprite.TextureInset.Bottom; + }); + } + + private partial class InsetTextBox : BasicTextBox + { + private readonly Bindable insetValue; + + public InsetTextBox(Bindable insetValue) + { + this.insetValue = insetValue.GetBoundCopy(); + + RelativeSizeAxes = Axes.X; + Height = 30; + Text = insetValue.Value.ToString(CultureInfo.InvariantCulture); + CommitOnFocusLost = true; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + OnCommit += textBoxOnCommit; + + insetValue.BindValueChanged(e => Text = e.NewValue.ToString(CultureInfo.InvariantCulture)); + } + + private void textBoxOnCommit(TextBox textBox, bool newText) + { + if (float.TryParse(textBox.Text, out float value)) + insetValue.Value = value; + else + textBox.Text = insetValue.Value.ToString(CultureInfo.InvariantCulture); + } + + protected override void OnFocus(FocusEvent e) + { + base.OnFocus(e); + + SelectAll(); + } + } + + private partial class LabelledContainer : Container + { + public LabelledContainer(string label) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChildren = new Drawable[] + { + new SpriteText + { + Text = label, + Font = FrameworkFont.Regular.With(size: 20), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + content = new Container + { + Padding = new MarginPadding { Left = 140 }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + }; + } + + private readonly Container content; + + protected override Container Content => content; + } + } +} diff --git a/osu.Framework.Tests/Visual/Testing/TestSceneStepButton.cs b/osu.Framework.Tests/Visual/Testing/TestSceneStepButton.cs index 613421f7c7..602709ffc2 100644 --- a/osu.Framework.Tests/Visual/Testing/TestSceneStepButton.cs +++ b/osu.Framework.Tests/Visual/Testing/TestSceneStepButton.cs @@ -1,8 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System.Diagnostics; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing.Drawables.Steps; @@ -22,12 +21,43 @@ public TestSceneStepButton() Spacing = new Vector2(5), Children = new Drawable[] { - new LabelStep { Text = nameof(LabelStep) }, - new AssertButton { Text = nameof(AssertButton), Assertion = () => true }, - new SingleStepButton { Text = nameof(SingleStepButton) }, - new RepeatStepButton(null) { Text = nameof(RepeatStepButton) }, - new ToggleStepButton(null) { Text = nameof(ToggleStepButton) }, - new UntilStepButton(() => true) { Text = nameof(UntilStepButton) }, + new LabelStep + { + Text = nameof(LabelStep), + IsSetupStep = false, + Action = _ => { }, + }, + new AssertButton + { + Text = nameof(AssertButton), + IsSetupStep = false, + Assertion = () => true, + CallStack = new StackTrace() + }, + new SingleStepButton + { + Text = nameof(SingleStepButton), + IsSetupStep = false, + Action = () => { } + }, + new RepeatStepButton + { + Text = nameof(RepeatStepButton), + IsSetupStep = false + }, + new ToggleStepButton + { + Text = nameof(ToggleStepButton), + IsSetupStep = false, + Action = _ => { } + }, + new UntilStepButton + { + Text = nameof(UntilStepButton), + IsSetupStep = false, + Assertion = () => true, + CallStack = new StackTrace() + }, new StepSlider(nameof(StepSlider), 0, 10, 5), } }; diff --git a/osu.Framework.Tests/Visual/Testing/TestSceneTest.cs b/osu.Framework.Tests/Visual/Testing/TestSceneTest.cs index c93a996bc7..26ba31bf95 100644 --- a/osu.Framework.Tests/Visual/Testing/TestSceneTest.cs +++ b/osu.Framework.Tests/Visual/Testing/TestSceneTest.cs @@ -38,9 +38,10 @@ public virtual void SetUpSteps() if (DebugUtils.IsNUnitRunning && TestContext.CurrentContext.Test.MethodName == nameof(TestConstructor)) return; - AddStep(new SingleStepButton(true) + AddStep(new SingleStepButton { - Name = "set up dummy", + Text = "set up dummy", + IsSetupStep = true, Action = () => setupStepsDummyRun++ }); diff --git a/osu.Framework.Tests/Visual/UserInterface/TestSceneDropdown.cs b/osu.Framework.Tests/Visual/UserInterface/TestSceneDropdown.cs index 1c7d75a780..c1d247b409 100644 --- a/osu.Framework.Tests/Visual/UserInterface/TestSceneDropdown.cs +++ b/osu.Framework.Tests/Visual/UserInterface/TestSceneDropdown.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -10,9 +11,12 @@ using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Framework.Testing; using osu.Framework.Testing.Input; @@ -308,6 +312,56 @@ void checkOrder(int index, string item) => AddAssert($"item #{index + 1} is '{it () => Is.EqualTo(item)); } + [Test] + public void TestExternalManagement() + { + TestDropdown dropdown = null!; + Drawable openButton = null!; + + AddStep("setup dropdown", () => + { + dropdown = createDropdown(); + dropdown.AlwaysShowSearchBar = true; + + Add(openButton = new BasicButton + { + Size = new Vector2(150, 30), + Position = new Vector2(225, 50), + Text = "Open dropdown", + Action = openExternally + }); + }); + + // Open via setting state directly + + AddStep("open dropdown directly", openExternally); + AddAssert("dropdown open", () => dropdown.Menu.State, () => Is.EqualTo(MenuState.Open)); + AddAssert("search bar visible", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); + AddStep("press escape", () => InputManager.Key(Key.Escape)); + + // Open via clicking on an external button + + AddStep("open dropdown via external button", () => + { + InputManager.MoveMouseTo(openButton); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("dropdown open", () => dropdown.Menu.State, () => Is.EqualTo(MenuState.Open)); + AddAssert("search bar visible", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); + AddStep("press escape", () => InputManager.Key(Key.Escape)); + + // Close via setting state directly + + AddStep("open dropdown directly", openExternally); + AddAssert("dropdown open", () => dropdown.Menu.State, () => Is.EqualTo(MenuState.Open)); + AddStep("close dropdown directly", () => dropdown.ChildrenOfType().Single().Close()); + AddAssert("dropdown closed", () => dropdown.Menu.State, () => Is.EqualTo(MenuState.Closed)); + AddAssert("search bar not visible", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); + + void openExternally() => dropdown.ChildrenOfType().Single().Open(); + } + [Test] public void TestItemReplacementDoesNotAffectScroll() { @@ -429,6 +483,32 @@ public void TestSetNonExistentItem([Values] bool afterBdl) AddAssert("text is expected", () => dropdown.SelectedItem.Text.Value.ToString(), () => Is.EqualTo("loaded: non-existent item")); } + [Test] + public void TestRemoveDropdownOnSelect() + { + TestDropdown testDropdown = null!; + Bindable bindable = null!; + + AddStep("setup dropdown", () => + { + bindable = new Bindable(); + bindable.ValueChanged += _ => createDropdown(); + + testDropdown = createDropdown(); + testDropdown.Current = bindable; + }); + + toggleDropdownViaClick(() => testDropdown); + + AddStep("click item 2", () => + { + InputManager.MoveMouseTo(testDropdown.Menu.Children[2]); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("bindable value is item 2", () => bindable.Value?.Identifier == "test 2"); + } + #region Searching [Test] @@ -487,6 +567,22 @@ public void TestReleaseFocusAfterSearching() AddAssert("search bar hidden", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); } + [Test] + public void TestSelectSearchedItem() + { + ManualTextDropdown dropdown = null!; + + AddStep("setup dropdown", () => dropdown = createDropdowns(1)[0]); + toggleDropdownViaClick(() => dropdown); + + AddStep("trigger text", () => dropdown.TextInput.Text("test 4")); + AddAssert("search bar visible", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); + + AddStep("press enter", () => InputManager.Key(Key.Enter)); + AddAssert("search bar hidden", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); + AddAssert("dropdown closed", () => dropdown.Menu.State == MenuState.Closed); + } + [Test] public void TestAlwaysShowSearchBar() { @@ -527,8 +623,151 @@ public void TestAlwaysShowSearchBar() AddAssert("search bar hidden", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); } + [Test] + public void TestKeyBindingIsolation() + { + ManualTextDropdown dropdown = null!; + TestKeyBindingHandler keyBindingHandler = null!; + + AddStep("setup dropdown", () => + { + dropdown = createDropdowns(1)[0]; + dropdown.AlwaysShowSearchBar = true; + }); + + AddStep("setup key binding handler", () => + { + Add(new TestKeyBindingContainer + { + RelativeSizeAxes = Axes.Both, + Child = keyBindingHandler = new TestKeyBindingHandler + { + RelativeSizeAxes = Axes.Both, + }, + }); + }); + + AddAssert("search bar hidden", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); + toggleDropdownViaClick(() => dropdown); + + AddStep("press space", () => + { + InputManager.Key(Key.Space); + // we must send something via the text input path for TextBox to block the space key press above, + // we're not supposed to do this here, but we don't have a good way of simulating text input from ManualInputManager so let's just do this for now. + // todo: add support for simulating text typing at a ManualInputManager level for more realistic results. + dropdown.TextInput.Text(" "); + }); + AddAssert("handler did not receive press", () => !keyBindingHandler.ReceivedPress); + + toggleDropdownViaClick(() => dropdown); + + AddStep("press space", () => + { + InputManager.Key(Key.Space); + // we must send something via the text input path for TextBox to block the space key press above, + // we're not supposed to do this here, but we don't have a good way of simulating text input from ManualInputManager so let's just do this for now. + dropdown.TextInput.Text(" "); + }); + AddAssert("handler did not receive press", () => !keyBindingHandler.ReceivedPress); + } + + [Test] + public void TestMouseFromTouch() + { + ManualTextDropdown dropdown = null!; + TestClickHandler clickHandler = null!; + + AddStep("setup dropdown", () => + { + dropdown = createDropdowns(1)[0]; + dropdown.AlwaysShowSearchBar = true; + }); + + AddStep("setup click handler", () => Add(clickHandler = new TestClickHandler + { + RelativeSizeAxes = Axes.Both + })); + + AddAssert("search bar hidden", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); + AddStep("begin touch", () => InputManager.BeginTouch(new Touch(TouchSource.Touch1, dropdown.Header.ScreenSpaceDrawQuad.Centre))); + AddStep("end touch", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, dropdown.Header.ScreenSpaceDrawQuad.Centre))); + + AddAssert("search bar still hidden", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); + AddAssert("handler received click", () => clickHandler.ReceivedClick); + + AddStep("type something", () => dropdown.TextInput.Text("something")); + AddAssert("search bar still hidden", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); + AddAssert("search bar empty", () => dropdown.Header.SearchTerm.Value, () => Is.Null.Or.Empty); + + AddStep("hide click handler", () => clickHandler.Hide()); + AddStep("begin touch", () => InputManager.BeginTouch(new Touch(TouchSource.Touch1, dropdown.Header.ScreenSpaceDrawQuad.Centre))); + AddStep("end touch", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, dropdown.Header.ScreenSpaceDrawQuad.Centre))); + + AddAssert("search bar visible", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); + } + #endregion + [Test] + public void TestPaddedSearchBar() + { + SearchBarPaddedDropdown dropdown = null!; + + AddStep("setup dropdown", () => + { + Child = dropdown = new SearchBarPaddedDropdown + { + Position = new Vector2(50f, 50f), + Width = 150f, + Items = new TestModel("test").Yield(), + }; + }); + + AddStep("click on padded area", () => + { + RectangleF area = dropdown.Header.ScreenSpaceDrawQuad.AABBFloat; + InputManager.MoveMouseTo(new Vector2(area.Right - 5, area.Centre.Y)); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("dropdown is open", () => dropdown.Menu.State, () => Is.EqualTo(MenuState.Open)); + } + + [Test] + public void TestDoubleClickOnHeader([Values] bool alwaysShowSearchBar) + { + TestDropdown testDropdown = null!; + bool wasOpened = false; + bool wasClosed = false; + + AddStep("setup dropdown", () => + { + wasOpened = false; + wasClosed = false; + + testDropdown = createDropdown(); + testDropdown.AlwaysShowSearchBar = alwaysShowSearchBar; + + testDropdown.Menu.StateChanged += s => + { + wasOpened |= s == MenuState.Open; + wasClosed |= s == MenuState.Closed; + }; + }); + + AddStep("double click header", () => + { + InputManager.MoveMouseTo(testDropdown.Header); + InputManager.Click(MouseButton.Left); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("dropdown was opened at some point", () => wasOpened, () => Is.True); + AddAssert("dropdown was closed at some point", () => wasClosed, () => Is.True); + AddAssert("dropdown is closed", () => testDropdown.Menu.State, () => Is.EqualTo(MenuState.Closed)); + } + private TestDropdown createDropdown() => createDropdowns(1).Single(); private TestDropdown[] createDropdowns(int count) => createDropdowns(count); @@ -631,5 +870,57 @@ protected override LocalisableString GenerateItemText(TestModel? item) return $"{text}: {base.GenerateItemText(item)}"; } } + + private partial class SearchBarPaddedDropdown : TestDropdown + { + protected override DropdownHeader CreateHeader() => new PaddedHeader(); + + private partial class PaddedHeader : BasicDropdownHeader + { + protected override DropdownSearchBar CreateSearchBar() => base.CreateSearchBar().With(d => + { + d.Padding = new MarginPadding { Right = 25 }; + }); + } + } + + private partial class TestKeyBindingContainer : KeyBindingContainer + { + public override IEnumerable DefaultKeyBindings => new[] + { + new KeyBinding(InputKey.Space, TestAction.SpaceAction) + }; + } + + private partial class TestKeyBindingHandler : Drawable, IKeyBindingHandler + { + public bool ReceivedPress; + + public bool OnPressed(KeyBindingPressEvent e) + { + ReceivedPress = true; + return true; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } + + private partial class TestClickHandler : Drawable + { + public bool ReceivedClick; + + protected override bool OnClick(ClickEvent e) + { + ReceivedClick = true; + return true; + } + } + + private enum TestAction + { + SpaceAction, + } } } diff --git a/osu.Framework.Tests/Visual/UserInterface/TestScenePopoverContainer.cs b/osu.Framework.Tests/Visual/UserInterface/TestScenePopoverContainer.cs index bc96cd2685..08aec31b31 100644 --- a/osu.Framework.Tests/Visual/UserInterface/TestScenePopoverContainer.cs +++ b/osu.Framework.Tests/Visual/UserInterface/TestScenePopoverContainer.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Testing; using osuTK; @@ -395,7 +396,7 @@ public void TestExternalPopoverControl() }); AddAssert("popover shown", () => this.ChildrenOfType().Any()); - AddStep("take away text box focus", () => InputManager.ChangeFocus(null)); + AddStep("take away text box focus", () => ((IFocusManager)InputManager).ChangeFocus(null)); AddAssert("popover hidden", () => !this.ChildrenOfType().Any()); } @@ -571,6 +572,30 @@ public void TestAllowableAnchors() }); } + [Test] + public void TestComponentOffScreen() + { + DrawableWithPopover target = null!; + + AddStep("add button", () => popoverContainer.Child = target = new DrawableWithPopover + { + Width = 200, + Height = 30, + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + RelativePositionAxes = Axes.Both, + Text = "open", + CreateContent = _ => new BasicPopover + { + AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight }, + Child = new SpriteText { Text = "no twitching!" } + } + }); + + AddStep("show popover", () => target.ShowPopover()); + AddStep("move off screen", () => target.Y = 20); + } + private void createContent(Func creationFunc) => AddStep("create content", () => { diff --git a/osu.Framework.Tests/Visual/UserInterface/TestSceneRearrangeableListContainer.cs b/osu.Framework.Tests/Visual/UserInterface/TestSceneRearrangeableListContainer.cs index 83450a664e..9b907e1755 100644 --- a/osu.Framework.Tests/Visual/UserInterface/TestSceneRearrangeableListContainer.cs +++ b/osu.Framework.Tests/Visual/UserInterface/TestSceneRearrangeableListContainer.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -21,9 +19,8 @@ namespace osu.Framework.Tests.Visual.UserInterface { public partial class TestSceneRearrangeableListContainer : ManualInputManagerTestScene { - private TestRearrangeableList list; - - private Container listContainer; + private TestRearrangeableList list = null!; + private Container listContainer = null!; [SetUp] public void Setup() => Schedule(() => @@ -85,7 +82,7 @@ public void TestRemoveItem() addItems(item_count); - List items = null; + List items = null!; AddStep("get item references", () => items = new List(list.ItemMap.Values.ToList())); @@ -278,7 +275,7 @@ public void TestNotScrolledToTopOnRemove() [Test] public void TestRemoveDuringLoadAndReAdd() { - TestDelayedLoadRearrangeableList delayedList = null; + TestDelayedLoadRearrangeableList delayedList = null!; AddStep("create list", () => Child = delayedList = new TestDelayedLoadRearrangeableList()); @@ -327,6 +324,100 @@ public void TestDragSynchronisation() }); } + [Test] + public void TestReplaceEntireList() + { + addItems(1); + + AddStep("replace list", () => list.Items.ReplaceRange(0, list.Items.Count, [100])); + AddUntilStep("wait for items to load", () => list.ItemMap.Values.All(i => i.IsLoaded)); + } + + [Test] + public void TestPartialReplace() + { + addItems(5); + + AddStep("replace list", () => list.Items.ReplaceRange(2, 2, [100, 101])); + AddUntilStep("wait for items to load", () => list.ItemMap.Values.All(i => i.IsLoaded)); + } + + [Test] + public void TestReplaceWithNoItems() + { + addItems(5); + + AddStep("replace list", () => list.Items.ReplaceRange(0, list.Items.Count, [])); + AddUntilStep("wait for clear", () => !list.ItemMap.Values.Any()); + } + + [Test] + public void TestReplaceEmptyListWithNoItems() + { + AddStep("replace list", () => list.Items.ReplaceRange(0, 0, [])); + } + + [Test] + public void TestReplaceEmptyListWithItems() + { + AddStep("replace list", () => list.Items.ReplaceRange(0, 0, [100, 101])); + AddUntilStep("wait for items to load", () => list.ItemMap.Values.All(i => i.IsLoaded)); + } + + [Test] + public void TestReplaceOnlyAppliesDifferences() + { + // ReSharper disable once LocalVariableHidesMember (intentional, using the ƒield is wrong for this test) + TestLoadCountingList list = null!; + + AddStep("create list", () => Child = list = new TestLoadCountingList + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(500, 300) + }); + + AddStep("replace {[]} with [1]", () => list.Items.ReplaceRange(0, list.Items.Count, [1])); + assertLoadCount(1, 1); + + AddStep("replace {[1]} with [1]", () => list.Items.ReplaceRange(0, 1, [1])); + assertLoadCount(1, 1); + + AddStep("add {1, []} with [2, 3, 4]", () => list.Items.ReplaceRange(list.Items.Count, 0, [2, 3, 4])); + assertLoadCount(2, 1); + assertLoadCount(3, 1); + assertLoadCount(4, 1); + + AddStep("replace {1, [2, 3], 4} with [3, 5]", () => list.Items.ReplaceRange(1, 2, [3, 5])); + assertLoadCount(3, 1); + assertLoadCount(5, 1); + + AddStep("replace {1, [3, 5], 4} with [2, 3]", () => list.Items.ReplaceRange(1, 2, [2, 3])); + assertLoadCount(2, 2); + assertLoadCount(3, 1); + + AddStep("replace {[1, 2, 3, 4]} with [1]", () => list.Items.ReplaceRange(0, list.Items.Count, [1])); + assertLoadCount(1, 1); + + AddStep("replace {[1]} with []", () => list.Items.ReplaceRange(0, list.Items.Count, [])); + assertLoadCount(1, 1); + + AddStep("replace {[]} with [0, 1, 2, 3, 4, 5, 6]", () => list.Items.ReplaceRange(0, list.Items.Count, [0, 1, 2, 3, 4, 5, 6])); + assertLoadCount(0, 1); + assertLoadCount(1, 2); + assertLoadCount(2, 3); + assertLoadCount(3, 2); + assertLoadCount(4, 2); + assertLoadCount(5, 2); + assertLoadCount(6, 1); + + void assertLoadCount(int item, int expectedTimesLoaded) + { + AddUntilStep("wait for items to load", () => list.ItemMap.Values.All(i => i.IsLoaded)); + AddAssert($"item {item} loaded {expectedTimesLoaded} times", () => list.LoadCount[item], () => Is.EqualTo(expectedTimesLoaded)); + } + } + private void addDragSteps(int from, int to, int[] expectedSequence) { AddStep($"move to {from}", () => @@ -410,5 +501,32 @@ private void load() } } } + + private partial class TestLoadCountingList : BasicRearrangeableListContainer + { + /// + /// Dictionary of item -> # of times a drawable was loaded for it. + /// + public readonly Dictionary LoadCount = new Dictionary(); + + public new IReadOnlyDictionary> ItemMap => base.ItemMap; + + protected override BasicRearrangeableListItem CreateBasicItem(int item) + => new TestRearrangeableListItem(item, () => LoadCount[item] = LoadCount.GetValueOrDefault(item) + 1); + + private partial class TestRearrangeableListItem : BasicRearrangeableListItem + { + private readonly Action onLoad; + + public TestRearrangeableListItem(int item, Action onLoad) + : base(item, false) + { + this.onLoad = onLoad; + } + + [BackgroundDependencyLoader] + private void load() => onLoad(); + } + } } } diff --git a/osu.Framework.Tests/Visual/UserInterface/TestSceneScreenStack.cs b/osu.Framework.Tests/Visual/UserInterface/TestSceneScreenStack.cs index d8e75ce301..b570bd7cff 100644 --- a/osu.Framework.Tests/Visual/UserInterface/TestSceneScreenStack.cs +++ b/osu.Framework.Tests/Visual/UserInterface/TestSceneScreenStack.cs @@ -67,11 +67,11 @@ public void TestPushFocusLost() TestScreen screen1 = null; pushAndEnsureCurrent(() => screen1 = new TestScreen { EagerFocus = true }); - AddUntilStep("wait for focus grab", () => GetContainingInputManager().FocusedDrawable == screen1); + AddUntilStep("wait for focus grab", () => GetContainingInputManager()!.FocusedDrawable == screen1); pushAndEnsureCurrent(() => new TestScreen(), () => screen1); - AddUntilStep("focus lost", () => GetContainingInputManager().FocusedDrawable != screen1); + AddUntilStep("focus lost", () => GetContainingInputManager()!.FocusedDrawable != screen1); } [Test] @@ -80,11 +80,11 @@ public void TestPushFocusTransferred() TestScreen screen1 = null, screen2 = null; pushAndEnsureCurrent(() => screen1 = new TestScreen { EagerFocus = true }); - AddUntilStep("wait for focus grab", () => GetContainingInputManager().FocusedDrawable == screen1); + AddUntilStep("wait for focus grab", () => GetContainingInputManager()!.FocusedDrawable == screen1); pushAndEnsureCurrent(() => screen2 = new TestScreen { EagerFocus = true }, () => screen1); - AddUntilStep("focus transferred", () => GetContainingInputManager().FocusedDrawable == screen2); + AddUntilStep("focus transferred", () => GetContainingInputManager()!.FocusedDrawable == screen2); } [Test] diff --git a/osu.Framework.Tests/Visual/UserInterface/TestSceneSliderBar.cs b/osu.Framework.Tests/Visual/UserInterface/TestSceneSliderBar.cs index d41832481c..9cf6f699c3 100644 --- a/osu.Framework.Tests/Visual/UserInterface/TestSceneSliderBar.cs +++ b/osu.Framework.Tests/Visual/UserInterface/TestSceneSliderBar.cs @@ -59,6 +59,7 @@ public TestSceneSliderBar() Size = new Vector2(200, 50), BackgroundColour = Color4.White, SelectionColour = Color4.Pink, + FocusColour = Color4.OrangeRed, KeyboardStep = 1, Current = sliderBarValue }, @@ -72,6 +73,7 @@ public TestSceneSliderBar() RangePadding = 20, BackgroundColour = Color4.White, SelectionColour = Color4.Pink, + FocusColour = Color4.OrangeRed, KeyboardStep = 1, Current = sliderBarValue }, @@ -85,6 +87,7 @@ public TestSceneSliderBar() Size = new Vector2(200, 10), BackgroundColour = Color4.White, SelectionColour = Color4.Pink, + FocusColour = Color4.OrangeRed, KeyboardStep = 1, Current = sliderBarValue }, @@ -97,6 +100,7 @@ public TestSceneSliderBar() Size = new Vector2(200, 10), BackgroundColour = Color4.White, SelectionColour = Color4.Pink, + FocusColour = Color4.OrangeRed, KeyboardStep = 1, Current = sliderBarValue }, @@ -109,6 +113,8 @@ public TestSceneSliderBar() { sliderBar.Current.Disabled = false; sliderBar.Current.Value = 0; + sliderBar.GetContainingFocusManager()!.ChangeFocus(null); + sliderBarWithNub.GetContainingFocusManager()!.ChangeFocus(null); }); [Test] @@ -122,6 +128,7 @@ public void TestVerticalDragHasNoEffect() () => { InputManager.MoveMouseTo(sliderBar.ToScreenSpace(sliderBar.DrawSize * new Vector2(0.75f, 1f))); }); AddStep("Release Click", () => { InputManager.ReleaseButton(MouseButton.Left); }); checkValue(0); + AddAssert("Slider has no focus", () => !sliderBar.HasFocus); } [Test] @@ -136,6 +143,7 @@ public void TestDragOutReleaseInHasNoEffect() AddStep("Drag Up", () => { InputManager.MoveMouseTo(sliderBar.ToScreenSpace(sliderBar.DrawSize * new Vector2(0.25f, 0.5f))); }); AddStep("Release Click", () => { InputManager.ReleaseButton(MouseButton.Left); }); checkValue(0); + AddAssert("Slider has focus", () => sliderBar.HasFocus); } [Test] @@ -160,6 +168,23 @@ public void TestKeyboardInput() InputManager.ReleaseKey(Key.Right); }); checkValue(1); + + AddStep("Click slider", () => InputManager.Click(MouseButton.Left)); + checkValue(-5); + + AddAssert("Slider has focus", () => sliderBar.HasFocus); + + AddStep("move mouse outside", () => + { + InputManager.MoveMouseTo(sliderBar.ToScreenSpace(sliderBar.DrawSize * new Vector2(2f, 0.5f))); + }); + + AddStep("Press right arrow key", () => + { + InputManager.PressKey(Key.Right); + InputManager.ReleaseKey(Key.Right); + }); + checkValue(-4); } [TestCase(false)] @@ -246,6 +271,7 @@ public void TestAbsoluteDrag() () => { InputManager.MoveMouseTo(sliderBarWithNub.ToScreenSpace(sliderBarWithNub.DrawSize * new Vector2(0.4f, 1f))); }); AddStep("Release Click", () => { InputManager.ReleaseButton(MouseButton.Left); }); checkValue(-2); + AddAssert("Slider has focus", () => sliderBarWithNub.HasFocus); } [Test] @@ -259,6 +285,7 @@ public void TestRelativeDrag() () => { InputManager.MoveMouseTo(sliderBarWithNub.ToScreenSpace(sliderBarWithNub.DrawSize * new Vector2(0.75f, 1f))); }); AddStep("Release Click", () => { InputManager.ReleaseButton(MouseButton.Left); }); checkValue(3); + AddAssert("Slider has focus", () => sliderBarWithNub.HasFocus); } [Test] diff --git a/osu.Framework.Tests/Visual/UserInterface/TestSceneTextBox.cs b/osu.Framework.Tests/Visual/UserInterface/TestSceneTextBox.cs index 70a1b702a7..1242f984f3 100644 --- a/osu.Framework.Tests/Visual/UserInterface/TestSceneTextBox.cs +++ b/osu.Framework.Tests/Visual/UserInterface/TestSceneTextBox.cs @@ -881,6 +881,127 @@ public void TestCursorMovementWithSelection() AddAssert("second-from-last word selected", () => textBox.SelectedText == "bank"); } + [Test] + public void TestTypingCancelsOngoingDragSelection() + { + InsertableTextBox textBox = null; + + AddStep("add textbox", () => + { + textBoxes.Add(textBox = new InsertableTextBox + { + Size = new Vector2(300, 40), + Text = "123", + ReadOnly = false + }); + }); + + AddStep("focus textbox", () => + { + InputManager.MoveMouseTo(textBox); + InputManager.Click(MouseButton.Left); + }); + + // drag text, insert, keep mouse held, drag more and ensure it's ignored + AddStep("hold from middle of textbox", () => InputManager.PressButton(MouseButton.Left)); + AddStep("move mouse to left of textbox", () => InputManager.MoveMouseTo(textBox.ScreenSpaceDrawQuad.TopLeft - new Vector2(20f, 0f))); + AddAssert("text selected by drag", () => textBox.SelectedText == "123"); + AddStep("insert character", () => textBox.InsertString("1")); + AddAssert("text overwritten", () => textBox.Text == "1"); + AddStep("move mouse a little", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position - new Vector2(10f, 0f))); + AddAssert("text not selected by drag", () => string.IsNullOrEmpty(textBox.SelectedText)); + AddStep("release mouse", () => InputManager.ReleaseButton(MouseButton.Left)); + + // drag text, release mouse, insert, drag again and ensure dragging still works + AddStep("hold from middle of textbox", () => + { + InputManager.MoveMouseTo(textBox); + InputManager.PressButton(MouseButton.Left); + }); + AddStep("drag again", () => InputManager.MoveMouseTo(textBox.ScreenSpaceDrawQuad.TopLeft - new Vector2(20f, 0f))); + AddAssert("text selected by drag", () => textBox.SelectedText == "1"); + AddStep("release mouse", () => InputManager.ReleaseButton(MouseButton.Left)); + AddStep("insert character", () => textBox.InsertString("1")); + AddAssert("text overwritten", () => textBox.Text == "1"); + AddStep("hold from middle of textbox", () => + { + InputManager.MoveMouseTo(textBox); + InputManager.PressButton(MouseButton.Left); + }); + AddStep("drag again", () => InputManager.MoveMouseTo(textBox.ScreenSpaceDrawQuad.TopLeft - new Vector2(20f, 0f))); + AddAssert("text selected by drag", () => textBox.SelectedText == "1"); + } + + [Test] + public void TestTabbing() + { + AddStep("add textboxes", () => + { + textBoxes.AddRange([ + new InsertableTextBox + { + Size = new Vector2(300, 40), + Text = "first!", + ReadOnly = false, + TabbableContentContainer = textBoxes, + }, + new InsertableTextBox + { + Size = new Vector2(300, 40), + Text = "second!", + ReadOnly = false, + TabbableContentContainer = textBoxes, + }, + new InsertableTextBox + { + Size = new Vector2(300, 40), + Text = "third! (readonly)", + ReadOnly = true, + TabbableContentContainer = textBoxes, + }, + new InsertableTextBox + { + Size = new Vector2(300, 40), + Text = "fourth!", + ReadOnly = false, + TabbableContentContainer = textBoxes, + } + ]); + }); + + AddStep("focus first textbox", () => + { + InputManager.MoveMouseTo(textBoxes[0]); + InputManager.Click(MouseButton.Left); + }); + + AddStep("press tab", () => InputManager.Key(Key.Tab)); + AddAssert("second textbox focused", () => textBoxes[1].HasFocus); + + AddStep("press tab", () => InputManager.Key(Key.Tab)); + AddAssert("readonly textbox skipped", () => textBoxes[3].HasFocus); + + AddStep("press tab", () => InputManager.Key(Key.Tab)); + AddAssert("first textbox focused", () => textBoxes[0].HasFocus); + + AddStep("press shift-tab", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.Tab); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + AddAssert("fourth textbox focused", () => textBoxes[3].HasFocus); + + AddStep("hide second textbox", () => textBoxes[1].Alpha = 0); + AddStep("press shift-tab", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.Tab); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + AddAssert("first textbox focused", () => textBoxes[0].HasFocus); + } + private void prependString(InsertableTextBox textBox, string text) { InputManager.Keys(PlatformAction.MoveBackwardLine); diff --git a/osu.Framework.iOS.props b/osu.Framework.iOS.props index d80894430a..502cfb3184 100644 --- a/osu.Framework.iOS.props +++ b/osu.Framework.iOS.props @@ -15,12 +15,6 @@ false - - ios-arm64 - - - iossimulator-x64 - diff --git a/osu.Framework.iOS/IOSGameHost.cs b/osu.Framework.iOS/IOSGameHost.cs index 4f5c7a3f6b..7b696fd97f 100644 --- a/osu.Framework.iOS/IOSGameHost.cs +++ b/osu.Framework.iOS/IOSGameHost.cs @@ -21,23 +21,14 @@ namespace osu.Framework.iOS { - public class IOSGameHost : SDL3GameHost + public class IOSGameHost : SDLGameHost { public IOSGameHost() : base(string.Empty) { } - protected override IWindow CreateWindow(GraphicsSurfaceType preferredSurface) => new IOSWindow(preferredSurface); - - protected override void SetupForRun() - { - base.SetupForRun(); - - AllowScreenSuspension.Result.BindValueChanged(allow => - InputThread.Scheduler.Add(() => UIApplication.SharedApplication.IdleTimerDisabled = !allow.NewValue), - true); - } + protected override IWindow CreateWindow(GraphicsSurfaceType preferredSurface) => new IOSWindow(preferredSurface, Options.FriendlyGameName); protected override void SetupConfig(IDictionary defaultOverrides) { @@ -47,8 +38,6 @@ protected override void SetupConfig(IDictionary defaul base.SetupConfig(defaultOverrides); } - public override bool OnScreenKeyboardOverlapsGameWindow => true; - public override bool CanExit => false; public override Storage GetStorage(string path) => new IOSStorage(path, this); diff --git a/osu.Framework.iOS/IOSWindow.cs b/osu.Framework.iOS/IOSWindow.cs index 165c8445a6..1583ffccb8 100644 --- a/osu.Framework.iOS/IOSWindow.cs +++ b/osu.Framework.iOS/IOSWindow.cs @@ -10,12 +10,13 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Platform; -using SDL; +using osu.Framework.Platform.SDL3; +using static SDL.SDL3; using UIKit; namespace osu.Framework.iOS { - internal class IOSWindow : SDL3Window + internal class IOSWindow : SDL3MobileWindow { private UIWindow? window; @@ -31,21 +32,15 @@ protected set } } - public IOSWindow(GraphicsSurfaceType surfaceType) - : base(surfaceType) + public IOSWindow(GraphicsSurfaceType surfaceType, string appName) + : base(surfaceType, appName) { } - protected override unsafe void UpdateWindowStateAndSize(WindowState state, Display display, DisplayMode displayMode) - { - // This sets the status bar to hidden. - SDL3.SDL_SetWindowFullscreen(SDLWindowHandle, SDL_bool.SDL_TRUE); - - // Don't run base logic at all. Let's keep things simple. - } - public override void Create() { + SDL_SetHint(SDL_HINT_IOS_HIDE_HOME_INDICATOR, "2"u8); + base.Create(); window = Runtime.GetNSObject(WindowHandle); @@ -62,8 +57,9 @@ protected override unsafe void RunMainLoop() // iOS may be a good forward direction if this ever comes up, as a user may see a potentially higher // frame rate with multi-threaded mode turned on, but it is going to give them worse input latency // and higher power usage. - SDL3.SDL_iOSSetEventPump(SDL_bool.SDL_FALSE); - SDL3.SDL_iOSSetAnimationCallback(SDLWindowHandle, 1, &runFrame, ObjectHandle.Handle); + + SDL_SetiOSEventPump(false); + SDL_SetiOSAnimationCallback(SDLWindowHandle, 1, &runFrame, ObjectHandle.Handle); } [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] diff --git a/osu.Framework/Allocation/CachedModelDependencyContainer.cs b/osu.Framework/Allocation/CachedModelDependencyContainer.cs index ec3f0768f1..12f37dbe87 100644 --- a/osu.Framework/Allocation/CachedModelDependencyContainer.cs +++ b/osu.Framework/Allocation/CachedModelDependencyContainer.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Reflection; using osu.Framework.Bindables; @@ -19,7 +17,7 @@ namespace osu.Framework.Allocation /// /// The type of the model to cache. Must contain only fields or auto-properties. public class CachedModelDependencyContainer : IReadOnlyDependencyContainer - where TModel : class, IDependencyInjectionCandidate, new() + where TModel : class?, IDependencyInjectionCandidate?, new() { private const BindingFlags activator_flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly; @@ -32,17 +30,16 @@ public class CachedModelDependencyContainer : IReadOnlyDependencyContain public readonly Bindable Model = new Bindable(); private readonly TModel shadowModel = new TModel(); - - private readonly IReadOnlyDependencyContainer parent; + private readonly IReadOnlyDependencyContainer? parent; private readonly IReadOnlyDependencyContainer shadowDependencies; - public CachedModelDependencyContainer(IReadOnlyDependencyContainer parent) + public CachedModelDependencyContainer(IReadOnlyDependencyContainer? parent) { this.parent = parent; shadowDependencies = DependencyActivator.MergeDependencies(shadowModel, null, new CacheInfo(parent: typeof(TModel))); - TModel currentModel = null; + TModel? currentModel = null; Model.BindValueChanged(e => { // When setting a null model, we actually want to reset the shadow model to a default state @@ -55,9 +52,9 @@ public CachedModelDependencyContainer(IReadOnlyDependencyContainer parent) }); } - public object Get(Type type) => Get(type, default); + public object? Get(Type type) => Get(type, default); - public object Get(Type type, CacheInfo info) + public object? Get(Type type, CacheInfo info) { if (info.Parent == null) return type == typeof(TModel) ? createChildShadowModel() : parent?.Get(type, info); @@ -87,65 +84,43 @@ private TModel createChildShadowModel() /// The shadow model to update. /// The model to unbind from. /// The model to bind to. - private void updateShadowModel(TModel targetShadowModel, TModel lastModel, TModel newModel) + private void updateShadowModel(TModel targetShadowModel, TModel? lastModel, TModel newModel) { - // Due to static-constructor checks, we are guaranteed that all fields will be IBindable - - foreach (var type in typeof(TModel).EnumerateBaseTypes()) + if (lastModel != null) { - foreach (var field in type.GetFields(activator_flags)) + foreach (var type in typeof(TModel).EnumerateBaseTypes()) { - perform(targetShadowModel, field, lastModel, (shadowProp, modelProp) => shadowProp.UnbindFrom(modelProp)); + foreach (var field in type.GetFields(activator_flags)) + perform(field, targetShadowModel, lastModel, (shadowProp, modelProp) => shadowProp.UnbindFrom(modelProp)); } } foreach (var type in typeof(TModel).EnumerateBaseTypes()) { foreach (var field in type.GetFields(activator_flags)) - { - perform(targetShadowModel, field, newModel, (shadowProp, modelProp) => shadowProp.BindTo(modelProp)); - } + perform(field, targetShadowModel, newModel, (shadowProp, modelProp) => shadowProp.BindTo(modelProp)); } } /// /// Perform an arbitrary action across a shadow model and model. /// - private void perform(TModel targetShadowModel, MemberInfo member, TModel target, Action action) + private static void perform(FieldInfo field, TModel shadowModel, TModel targetModel, Action action) { - if (target == null) return; + IBindable? shadowBindable = null; + IBindable? targetBindable = null; - switch (member) + try { - case PropertyInfo pi: - action((IBindable)pi.GetValue(targetShadowModel), (IBindable)pi.GetValue(target)); - break; - - case FieldInfo fi: - action((IBindable)fi.GetValue(targetShadowModel), (IBindable)fi.GetValue(target)); - break; + shadowBindable = field.GetValue(shadowModel) as IBindable; + targetBindable = field.GetValue(targetModel) as IBindable; } - } - - static CachedModelDependencyContainer() - { - foreach (var type in typeof(TModel).EnumerateBaseTypes()) + catch { - foreach (var field in type.GetFields(activator_flags)) - { - if (!typeof(IBindable).IsAssignableFrom(field.FieldType)) - { - throw new InvalidOperationException($"\"{field.DeclaringType}.{field.Name}\" does not subclass {nameof(IBindable)}. " - + $"All fields of {typeof(TModel)} must subclass {nameof(IBindable)} to be used in a {nameof(CachedModelDependencyContainer)}."); - } - - if (!field.IsInitOnly) - { - throw new InvalidOperationException($"\"{field.DeclaringType}.{field.Name}\" is not readonly. " - + $"All fields of {typeof(TModel)} must be readonly to be used in a {nameof(CachedModelDependencyContainer)}."); - } - } } + + if (shadowBindable != null && targetBindable != null) + action(shadowBindable, targetBindable); } } } diff --git a/osu.Framework/Allocation/ObjectHandle.cs b/osu.Framework/Allocation/ObjectHandle.cs index 26b6758c2c..c66b02edc3 100644 --- a/osu.Framework/Allocation/ObjectHandle.cs +++ b/osu.Framework/Allocation/ObjectHandle.cs @@ -29,7 +29,7 @@ public struct ObjectHandle : IDisposable private GCHandle handle; - private readonly bool fromPointer; + private readonly bool canFree; /// /// Wraps the provided object with a , using the given . @@ -39,18 +39,19 @@ public struct ObjectHandle : IDisposable public ObjectHandle(T target, GCHandleType handleType) { handle = GCHandle.Alloc(target, handleType); - fromPointer = false; + canFree = true; } /// /// Recreates an based on the passed . - /// Disposing this object will not free the handle, the original object must be disposed instead. + /// If is true, disposing this object will free the handle. /// - /// Handle. - public ObjectHandle(IntPtr handle) + /// from a previously constructed . + /// Whether this instance owns the underlying . + public ObjectHandle(IntPtr handle, bool ownsHandle = false) { this.handle = GCHandle.FromIntPtr(handle); - fromPointer = true; + canFree = ownsHandle; } /// @@ -87,7 +88,7 @@ public bool GetTarget(out T target) public void Dispose() { - if (!fromPointer && handle.IsAllocated) + if (canFree && handle.IsAllocated) handle.Free(); } diff --git a/osu.Framework/Allocation/ObjectUsage.cs b/osu.Framework/Allocation/ObjectUsage.cs deleted file mode 100644 index c229fe16a8..0000000000 --- a/osu.Framework/Allocation/ObjectUsage.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; - -namespace osu.Framework.Allocation -{ - public class ObjectUsage : IDisposable - where T : class - { - public T? Object; - - /// - /// Whether this usage is actively being written to or read from. - /// - public UsageType Usage; - - public readonly int Index; - - private readonly Action>? finish; - - public ObjectUsage(int index, Action>? finish) - { - Index = index; - this.finish = finish; - } - - public void Dispose() - { - finish?.Invoke(this); - } - } - - public enum UsageType - { - None, - Read, - Write - } -} diff --git a/osu.Framework/Allocation/TripleBuffer.cs b/osu.Framework/Allocation/TripleBuffer.cs index db5dd2fd2b..16a849a2de 100644 --- a/osu.Framework/Allocation/TripleBuffer.cs +++ b/osu.Framework/Allocation/TripleBuffer.cs @@ -3,7 +3,6 @@ using System; using System.Diagnostics; -using System.Linq; using System.Threading; namespace osu.Framework.Allocation @@ -13,131 +12,91 @@ namespace osu.Framework.Allocation /// Thread safety assumes at most one writer and one reader. /// Comes with the added assurance that the most recent object is not written to. /// - public class TripleBuffer + internal class TripleBuffer where T : class { - private readonly ObjectUsage[] buffers = new ObjectUsage[buffer_count]; - - /// - /// The freshest buffer index which has finished a write, and is waiting to be read. - /// Will be set to null after being read once. - /// - private int pendingCompletedWriteIndex = -1; - - /// - /// The last buffer index which was obtained for writing. - /// - private int lastWriteIndex = -1; + private const int buffer_count = 3; + private const long read_timeout_milliseconds = 100; - /// - /// The last buffer index which was obtained for reading. - /// Note that this will remain "active" even after a ends, to give benefit of doubt that the usage may still be accessing it. - /// - private int lastReadIndex = -1; + private readonly Buffer[] buffers = new Buffer[buffer_count]; - private readonly ManualResetEventSlim writeCompletedEvent = new ManualResetEventSlim(); + private readonly Stopwatch stopwatch = new Stopwatch(); - private const int buffer_count = 3; + private int writeIndex; + private int flipIndex = 1; + private int readIndex = 2; public TripleBuffer() { for (int i = 0; i < buffer_count; i++) - buffers[i] = new ObjectUsage(i, finishUsage); + buffers[i] = new Buffer(i, finishUsage); } - public ObjectUsage GetForWrite() + public Buffer GetForWrite() { - // Only one write should be allowed at once - Debug.Assert(buffers.All(b => b.Usage != UsageType.Write)); - - ObjectUsage buffer = getNextWriteBuffer(); - - return buffer; + Buffer usage = buffers[writeIndex]; + usage.LastUsage = UsageType.Write; + return usage; } - public ObjectUsage? GetForRead() + public Buffer? GetForRead() { - // Only one read should be allowed at once - Debug.Assert(buffers.All(b => b.Usage != UsageType.Read)); + stopwatch.Restart(); - writeCompletedEvent.Reset(); + do + { + flip(ref readIndex); - var buffer = getPendingReadBuffer(); + // This should really never happen, but prevents a potential infinite loop if the usage can never be retrieved. + if (stopwatch.ElapsedMilliseconds > read_timeout_milliseconds) + return null; + } while (buffers[readIndex].LastUsage == UsageType.Read); - if (buffer != null) - return buffer; + Buffer usage = buffers[readIndex]; - // A completed write wasn't available, so wait for the next to complete. - if (!writeCompletedEvent.Wait(100)) - // Generally shouldn't happen, but this avoids spinning forever. - return null; + Debug.Assert(usage.LastUsage == UsageType.Write); + usage.LastUsage = UsageType.Read; - return GetForRead(); + return usage; } - private ObjectUsage? getPendingReadBuffer() + private void finishUsage(Buffer usage) { - // Avoid locking to see if there's a pending write. - int pendingWrite = Interlocked.Exchange(ref pendingCompletedWriteIndex, -1); - - if (pendingWrite == -1) - return null; - - lock (buffers) - { - var buffer = buffers[pendingWrite]; - - Debug.Assert(lastReadIndex != buffer.Index); - lastReadIndex = buffer.Index; - - Debug.Assert(buffer.Usage == UsageType.None); - buffer.Usage = UsageType.Read; - return buffer; - } + if (usage.LastUsage == UsageType.Write) + flip(ref writeIndex); } - private ObjectUsage getNextWriteBuffer() + private void flip(ref int localIndex) { - lock (buffers) - { - for (int i = 0; i < buffer_count; i++) - { - // Never write to the last read index. - // We assume there could be some reads still occurring even after the usage is finished. - if (i == lastReadIndex) continue; + localIndex = Interlocked.Exchange(ref flipIndex, localIndex); + } - // Never write to the same buffer twice in a row. - // This would defeat the purpose of having a triple buffer. - if (i == lastWriteIndex) continue; + public class Buffer : IDisposable + { + public T? Object; - lastWriteIndex = i; + public volatile UsageType LastUsage; - var buffer = buffers[i]; + public readonly int Index; - Debug.Assert(buffer.Usage == UsageType.None); - buffer.Usage = UsageType.Write; + private readonly Action? finish; - return buffer; - } + public Buffer(int index, Action? finish) + { + Index = index; + this.finish = finish; } - throw new InvalidOperationException("No buffer could be obtained. This should never ever happen."); + public void Dispose() + { + finish?.Invoke(this); + } } - private void finishUsage(ObjectUsage obj) + public enum UsageType { - // This implementation is intentionally written this way to avoid requiring locking overhead. - bool wasWrite = obj.Usage == UsageType.Write; - - obj.Usage = UsageType.None; - - if (wasWrite) - { - Debug.Assert(pendingCompletedWriteIndex != obj.Index); - Interlocked.Exchange(ref pendingCompletedWriteIndex, obj.Index); - - writeCompletedEvent.Set(); - } + Read, + Write } } } diff --git a/osu.Framework/Audio/Mixing/AudioMixer.cs b/osu.Framework/Audio/Mixing/AudioMixer.cs index 0e260af752..4cb612e0d0 100644 --- a/osu.Framework/Audio/Mixing/AudioMixer.cs +++ b/osu.Framework/Audio/Mixing/AudioMixer.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using ManagedBass; -using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; namespace osu.Framework.Audio.Mixing @@ -28,8 +27,6 @@ protected AudioMixer(AudioMixer? fallbackMixer, string identifier) Identifier = identifier; } - public abstract BindableList Effects { get; } - public void Add(IAudioChannel channel) { channel.EnqueueAction(() => @@ -48,6 +45,12 @@ public void Add(IAudioChannel channel) public void Remove(IAudioChannel channel) => Remove(channel, true); + public abstract void AddEffect(IEffectParameter effect, int priority = 0); + + public abstract void RemoveEffect(IEffectParameter effect); + + public abstract void UpdateEffect(IEffectParameter effect); + /// /// Removes an from the mix. /// diff --git a/osu.Framework/Audio/Mixing/Bass/BassAudioMixer.cs b/osu.Framework/Audio/Mixing/Bass/BassAudioMixer.cs index 777bfc97b7..07147af650 100644 --- a/osu.Framework/Audio/Mixing/Bass/BassAudioMixer.cs +++ b/osu.Framework/Audio/Mixing/Bass/BassAudioMixer.cs @@ -3,15 +3,11 @@ using System; using System.Collections.Generic; -using System.Collections.Specialized; using System.Diagnostics; -using System.Linq; using System.Runtime.InteropServices; using ManagedBass; using ManagedBass.Mix; -using osu.Framework.Bindables; using osu.Framework.Extensions.EnumExtensions; -using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Statistics; namespace osu.Framework.Audio.Mixing.Bass @@ -28,16 +24,13 @@ internal class BassAudioMixer : AudioMixer, IBassAudio /// public int Handle { get; private set; } - /// - /// The list of effects which are currently active in the BASS mix. - /// - internal readonly List ActiveEffects = new List(); - /// /// The list of channels which are currently active in the BASS mix. /// private readonly List activeChannels = new List(); + private readonly Dictionary activeEffects = new Dictionary(); + private const int frequency = 44100; /// @@ -53,7 +46,32 @@ public BassAudioMixer(AudioManager? manager, AudioMixer? fallbackMixer, string i EnqueueAction(createMixer); } - public override BindableList Effects { get; } = new BindableList(); + public override void AddEffect(IEffectParameter effect, int priority = 0) => EnqueueAction(() => + { + if (activeEffects.ContainsKey(effect)) + return; + + int handle = ManagedBass.Bass.ChannelSetFX(Handle, effect.FXType, priority); + ManagedBass.Bass.FXSetParameters(handle, effect); + + activeEffects[effect] = handle; + }); + + public override void RemoveEffect(IEffectParameter effect) => EnqueueAction(() => + { + if (!activeEffects.Remove(effect, out int handle)) + return; + + ManagedBass.Bass.ChannelRemoveFX(Handle, handle); + }); + + public override void UpdateEffect(IEffectParameter effect) => EnqueueAction(() => + { + if (!activeEffects.TryGetValue(effect, out int handle)) + return; + + ManagedBass.Bass.FXSetParameters(handle, effect); + }); protected override void AddInternal(IAudioChannel channel) { @@ -301,8 +319,6 @@ private void createMixer() foreach (var channel in toAdd) AddChannelToBassMix(channel); - Effects.BindCollectionChanged(onEffectsChanged, true); - if (manager?.GlobalMixerHandle.Value != null) BassMix.MixerAddChannel(manager.GlobalMixerHandle.Value.Value, Handle, BassFlags.MixerChanBuffer | BassFlags.MixerChanNoRampin); @@ -341,93 +357,6 @@ private void removeChannelFromBassMix(IBassAudioChannel channel) BassMix.MixerRemoveChannel(channel.Handle); } - private void onEffectsChanged(object? sender, NotifyCollectionChangedEventArgs e) => EnqueueAction(() => - { - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - { - Debug.Assert(e.NewItems != null); - - // Work around BindableList sending initial event start with index -1. - int startIndex = Math.Max(0, e.NewStartingIndex); - - ActiveEffects.InsertRange(startIndex, e.NewItems.OfType().Select(eff => new EffectWithHandle(eff))); - applyEffects(startIndex, ActiveEffects.Count - 1); - break; - } - - case NotifyCollectionChangedAction.Move: - { - EffectWithHandle effect = ActiveEffects[e.OldStartingIndex]; - ActiveEffects.RemoveAt(e.OldStartingIndex); - ActiveEffects.Insert(e.NewStartingIndex, effect); - applyEffects(Math.Min(e.OldStartingIndex, e.NewStartingIndex), ActiveEffects.Count - 1); - break; - } - - case NotifyCollectionChangedAction.Remove: - { - Debug.Assert(e.OldItems != null); - - for (int i = 0; i < e.OldItems.Count; i++) - removeEffect(ActiveEffects[e.OldStartingIndex + i]); - ActiveEffects.RemoveRange(e.OldStartingIndex, e.OldItems.Count); - applyEffects(e.OldStartingIndex, ActiveEffects.Count - 1); - break; - } - - case NotifyCollectionChangedAction.Replace: - { - Debug.Assert(e.NewItems != null); - - EffectWithHandle oldEffect = ActiveEffects[e.NewStartingIndex]; - EffectWithHandle newEffect = new EffectWithHandle((IEffectParameter)e.NewItems[0].AsNonNull()) { Handle = oldEffect.Handle }; - - ActiveEffects[e.NewStartingIndex] = newEffect; - - // If the effect types don't match, the old effect has to be removed altogether. Otherwise, the new parameters can be applied onto the existing handle. - if (oldEffect.Effect.FXType != newEffect.Effect.FXType) - removeEffect(oldEffect); - - applyEffects(e.NewStartingIndex, e.NewStartingIndex); - break; - } - - case NotifyCollectionChangedAction.Reset: - { - foreach (var effect in ActiveEffects) - removeEffect(effect); - ActiveEffects.Clear(); - break; - } - } - - void removeEffect(EffectWithHandle effect) - { - Debug.Assert(effect.Handle != 0); - - ManagedBass.Bass.ChannelRemoveFX(Handle, effect.Handle); - effect.Handle = 0; - } - - void applyEffects(int startIndex, int endIndex) - { - for (int i = startIndex; i <= endIndex; i++) - { - var effect = ActiveEffects[i]; - - // Effects with greatest priority are stored at the front of the list. - effect.Priority = -i; - - if (effect.Handle == 0) - effect.Handle = ManagedBass.Bass.ChannelSetFX(Handle, effect.Effect.FXType, effect.Priority); - - ManagedBass.Bass.FXSetParameters(effect.Handle, effect.Effect); - } - } - }); - /// /// Flushes the mixer, causing pause and seek events to take effect immediately. /// @@ -454,18 +383,5 @@ protected override void Dispose(bool disposing) Handle = 0; } } - - internal class EffectWithHandle - { - public int Handle { get; set; } - public int Priority { get; set; } - - public readonly IEffectParameter Effect; - - public EffectWithHandle(IEffectParameter effect) - { - Effect = effect; - } - } } } diff --git a/osu.Framework/Audio/Mixing/IAudioMixer.cs b/osu.Framework/Audio/Mixing/IAudioMixer.cs index 8b4f4bcd4d..9ae5c106bc 100644 --- a/osu.Framework/Audio/Mixing/IAudioMixer.cs +++ b/osu.Framework/Audio/Mixing/IAudioMixer.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using ManagedBass; -using osu.Framework.Bindables; +using ManagedBass.Fx; namespace osu.Framework.Audio.Mixing { @@ -12,15 +12,6 @@ namespace osu.Framework.Audio.Mixing /// public interface IAudioMixer { - /// - /// The effects currently applied to the mix. - /// - /// Effects are stored in order of decreasing priority such that the effect at index = 0 in the list has the highest priority - /// and the effect at index = Count - 1 in the list has the lowest priority. - /// - /// - BindableList Effects { get; } - /// /// Adds a channel to the mix. /// @@ -32,5 +23,25 @@ public interface IAudioMixer /// /// The channel to remove. void Remove(IAudioChannel channel); + + /// + /// Applies an effect to the mixer. + /// + /// The effect (e.g. . + /// The effect priority. Lower values indicate higher priority and negative values are allowed. + /// When there are multiple effects with the same priority, their ordering depends on the order in which they are added to the . + void AddEffect(IEffectParameter effect, int priority = 0); + + /// + /// Removes an effect from the mixer. + /// + /// The effect. + void RemoveEffect(IEffectParameter effect); + + /// + /// Updates an effect's parameters. + /// + /// + void UpdateEffect(IEffectParameter effect); } } diff --git a/osu.Framework/Audio/Sample/SampleChannelBass.cs b/osu.Framework/Audio/Sample/SampleChannelBass.cs index ee9bc84c7b..f08f5baa49 100644 --- a/osu.Framework/Audio/Sample/SampleChannelBass.cs +++ b/osu.Framework/Audio/Sample/SampleChannelBass.cs @@ -19,7 +19,18 @@ internal sealed class SampleChannelBass : SampleChannel, IBassAudioChannel /// /// This is set to true immediately upon , but the channel may not be audibly playing yet. /// - public override bool Playing => playing || enqueuedPlaybackStart; + public override bool Playing + { + get + { + // When short samples loop (especially within mixers), there's a small window where the ChannelIsActive state could be Stopped. + // In order to not provide a "stale" value here, we'll not trust the internal playing state from BASS. + if (Looping && userRequestedPlay) + return true; + + return playing || enqueuedPlaybackStart; + } + } private volatile bool playing; diff --git a/osu.Framework/Audio/Track/TrackBass.cs b/osu.Framework/Audio/Track/TrackBass.cs index 55088f81d8..d5d250be85 100644 --- a/osu.Framework/Audio/Track/TrackBass.cs +++ b/osu.Framework/Audio/Track/TrackBass.cs @@ -181,9 +181,9 @@ private int prepareStream(Stream data, bool quick) Bass.ChannelSetDevice(tempoAdjustStream, bass_nodevice); stream = BassFx.ReverseCreate(tempoAdjustStream, 5f, BassFlags.Default | BassFlags.FxFreeSource | BassFlags.Decode); - Bass.ChannelSetAttribute(stream, ChannelAttribute.TempoUseQuickAlgorithm, 1); - Bass.ChannelSetAttribute(stream, ChannelAttribute.TempoOverlapMilliseconds, 4); - Bass.ChannelSetAttribute(stream, ChannelAttribute.TempoSequenceMilliseconds, 30); + Bass.ChannelSetAttribute(tempoAdjustStream, ChannelAttribute.TempoUseQuickAlgorithm, 1); + Bass.ChannelSetAttribute(tempoAdjustStream, ChannelAttribute.TempoOverlapMilliseconds, 4); + Bass.ChannelSetAttribute(tempoAdjustStream, ChannelAttribute.TempoSequenceMilliseconds, 30); } return stream; diff --git a/osu.Framework/Extensions/BridgingExtensions.cs b/osu.Framework/Extensions/BridgingExtensions.cs index 43a50fa64e..ed67e8925c 100644 --- a/osu.Framework/Extensions/BridgingExtensions.cs +++ b/osu.Framework/Extensions/BridgingExtensions.cs @@ -1,12 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Platform; using TKVector2 = osuTK.Vector2; using SNVector2 = System.Numerics.Vector2; using SDPoint = System.Drawing.Point; using SDSize = System.Drawing.Size; -using TKWindowState = osuTK.WindowState; namespace osu.Framework.Extensions { @@ -33,46 +31,5 @@ public static SDSize ToSystemDrawingSize(this SNVector2 vec) => public static SDPoint ToSystemDrawingPoint(this SNVector2 vec) => new SDPoint((int)vec.X, (int)vec.Y); - - public static TKWindowState ToOsuTK(this WindowState state) - { - switch (state) - { - case WindowState.Normal: - return TKWindowState.Normal; - - case WindowState.Fullscreen: - case WindowState.FullscreenBorderless: - return TKWindowState.Fullscreen; - - case WindowState.Maximised: - return TKWindowState.Maximized; - - case WindowState.Minimised: - return TKWindowState.Minimized; - } - - return TKWindowState.Normal; - } - - public static WindowState ToFramework(this TKWindowState state) - { - switch (state) - { - case TKWindowState.Normal: - return WindowState.Normal; - - case TKWindowState.Minimized: - return WindowState.Minimised; - - case TKWindowState.Maximized: - return WindowState.Maximised; - - case TKWindowState.Fullscreen: - return WindowState.Fullscreen; - } - - return WindowState.Normal; - } } } diff --git a/osu.Framework/Extensions/ExtensionMethods.cs b/osu.Framework/Extensions/ExtensionMethods.cs index 6f9d093742..3728936c20 100644 --- a/osu.Framework/Extensions/ExtensionMethods.cs +++ b/osu.Framework/Extensions/ExtensionMethods.cs @@ -15,8 +15,6 @@ using System.Text; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Localisation; -using osu.Framework.Platform; -using osuTK; // this is an abusive thing to do, but it increases the visibility of Extension Methods to virtually every file. @@ -290,18 +288,6 @@ public static string ComputeMD5Hash(this Stream stream) public static string ComputeMD5Hash(this string input) => MD5.HashData(Encoding.UTF8.GetBytes(input)).toLowercaseHex(); - public static DisplayIndex GetIndex(this DisplayDevice display) - { - if (display == null) return DisplayIndex.Default; - - for (int i = 0; true; i++) - { - var device = DisplayDevice.GetDisplay((DisplayIndex)i); - if (device == null) return DisplayIndex.Default; - if (device == display) return (DisplayIndex)i; - } - } - /// /// Standardise the path string using '/' as directory separator. /// Useful as output. @@ -335,23 +321,6 @@ public static string TrimDirectorySeparator(this string path) [Obsolete("Use char.IsAsciiDigit.")] // can be removed 20240901 public static bool IsAsciiDigit(this char character) => char.IsAsciiDigit(character); - /// - /// Converts an osuTK to a structure. - /// - /// The to convert. - /// A structure populated with the corresponding properties and s. - internal static Display ToDisplay(this DisplayDevice device) => - new Display((int)device.GetIndex(), device.GetIndex().ToString(), device.Bounds, device.AvailableResolutions.Select(ToDisplayMode).ToArray()); - - /// - /// Converts an osuTK to a structure. - /// It is not possible to retrieve the pixel format from . - /// - /// The to convert. - /// A structure populated with the corresponding properties. - internal static DisplayMode ToDisplayMode(this DisplayResolution resolution) => - new DisplayMode(null, new Size(resolution.Width, resolution.Height), resolution.BitsPerPixel, resolution.RefreshRate, 0); - /// /// Checks whether the provided URL is a safe protocol to execute a system call with. /// diff --git a/osu.Framework/Extensions/InputKeyExtensions.cs b/osu.Framework/Extensions/InputKeyExtensions.cs new file mode 100644 index 0000000000..50b83acd0e --- /dev/null +++ b/osu.Framework/Extensions/InputKeyExtensions.cs @@ -0,0 +1,42 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Input.Bindings; + +namespace osu.Framework.Extensions +{ + public static class InputKeyExtensions + { + public static bool IsPhysical(this InputKey key) + { + if (!Enum.IsDefined(key) || IsVirtual(key)) + return false; + + switch (key) + { + case InputKey.None: + case InputKey.LastKey: + return false; + + default: + return true; + } + } + + public static bool IsVirtual(this InputKey key) + { + switch (key) + { + case InputKey.Shift: + case InputKey.Control: + case InputKey.Alt: + case InputKey.Super: + return true; + + default: + return false; + } + } + } +} diff --git a/osu.Framework/FrameworkEnvironment.cs b/osu.Framework/FrameworkEnvironment.cs index 64258aacc8..5b4b5ee8ee 100644 --- a/osu.Framework/FrameworkEnvironment.cs +++ b/osu.Framework/FrameworkEnvironment.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Development; using osu.Framework.Platform; namespace osu.Framework @@ -19,6 +20,13 @@ public static class FrameworkEnvironment public static int? VertexBufferCount { get; } public static bool NoStructuredBuffers { get; } public static string? DeferredRendererEventsOutputPath { get; } + public static bool UseSDL3 { get; } + + /// + /// Whether non-SSL requests should be allowed. Debug only. Defaults to disabled. + /// When disabled, http:// requests will be automatically converted to https://. + /// + public static bool AllowInsecureRequests { get; internal set; } static FrameworkEnvironment() { @@ -41,6 +49,11 @@ static FrameworkEnvironment() NoStructuredBuffers = parseBool(Environment.GetEnvironmentVariable("OSU_GRAPHICS_NO_SSBO")) ?? false; DeferredRendererEventsOutputPath = Environment.GetEnvironmentVariable("DEFERRED_RENDERER_EVENTS_OUTPUT"); + + if (DebugUtils.IsDebugBuild) + AllowInsecureRequests = parseBool(Environment.GetEnvironmentVariable("OSU_INSECURE_REQUESTS")) ?? false; + + UseSDL3 = RuntimeInfo.IsMobile || (parseBool(Environment.GetEnvironmentVariable("OSU_SDL3")) ?? false); } private static bool? parseBool(string? value) diff --git a/osu.Framework/Graphics/Audio/DrawableAudioMixer.cs b/osu.Framework/Graphics/Audio/DrawableAudioMixer.cs index b01e34d5a6..4e3c696ea9 100644 --- a/osu.Framework/Graphics/Audio/DrawableAudioMixer.cs +++ b/osu.Framework/Graphics/Audio/DrawableAudioMixer.cs @@ -8,7 +8,6 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Mixing; -using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; namespace osu.Framework.Graphics.Audio @@ -21,11 +20,8 @@ public partial class DrawableAudioMixer : AudioContainer, IAudioMixer private void load(AudioManager audio) { mixer = audio.CreateAudioMixer(Name); - mixer.Effects.BindTo(Effects); } - public BindableList Effects { get; } = new BindableList(); - public void Add(IAudioChannel channel) { if (LoadState < LoadState.Ready) @@ -48,6 +44,39 @@ public void Remove(IAudioChannel channel) } } + public void AddEffect(IEffectParameter effect, int priority = 0) + { + if (LoadState < LoadState.Ready) + Schedule(() => mixer.AddEffect(effect, priority)); + else + { + Debug.Assert(mixer != null); + mixer.AddEffect(effect, priority); + } + } + + public void RemoveEffect(IEffectParameter effect) + { + if (LoadState < LoadState.Ready) + Schedule(() => mixer.RemoveEffect(effect)); + else + { + Debug.Assert(mixer != null); + mixer.RemoveEffect(effect); + } + } + + public void UpdateEffect(IEffectParameter effect) + { + if (LoadState < LoadState.Ready) + Schedule(() => mixer.UpdateEffect(effect)); + else + { + Debug.Assert(mixer != null); + mixer.UpdateEffect(effect); + } + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Framework/Graphics/BufferedDrawNode.cs b/osu.Framework/Graphics/BufferedDrawNode.cs index 7e9da554db..f250b96411 100644 --- a/osu.Framework/Graphics/BufferedDrawNode.cs +++ b/osu.Framework/Graphics/BufferedDrawNode.cs @@ -143,7 +143,7 @@ protected virtual void DrawContents(IRenderer renderer) /// /// The to bind. /// A token that must be disposed upon finishing use of . - protected IDisposable BindFrameBuffer(IFrameBuffer frameBuffer) + protected ValueInvokeOnDisposal BindFrameBuffer(IFrameBuffer frameBuffer) { // This setter will also take care of allocating a texture of appropriate size within the frame buffer. frameBuffer.Size = frameBufferSize; @@ -153,7 +153,7 @@ protected IDisposable BindFrameBuffer(IFrameBuffer frameBuffer) return new ValueInvokeOnDisposal(frameBuffer, static b => b.Unbind()); } - private IDisposable establishFrameBufferViewport(IRenderer renderer) + private ValueInvokeOnDisposal<(BufferedDrawNode node, IRenderer renderer)> establishFrameBufferViewport(IRenderer renderer) { // Disable masking for generating the frame buffer since masking will be re-applied // when actually drawing later on anyways. This allows more information to be captured diff --git a/osu.Framework/Graphics/Colour/SRGBColour.cs b/osu.Framework/Graphics/Colour/SRGBColour.cs index 93b89940d9..d5c994200e 100644 --- a/osu.Framework/Graphics/Colour/SRGBColour.cs +++ b/osu.Framework/Graphics/Colour/SRGBColour.cs @@ -39,6 +39,36 @@ public struct SRGBColour : IEquatable public static SRGBColour operator *(SRGBColour first, SRGBColour second) { + if (isWhite(first)) + { + if (first.Alpha == 1) + return second; + + return new SRGBColour + { + SRGB = new Color4( + second.SRGB.R, + second.SRGB.G, + second.SRGB.B, + first.Alpha * second.Alpha) + }; + } + + if (isWhite(second)) + { + if (second.Alpha == 1) + return first; + + return new SRGBColour + { + SRGB = new Color4( + first.SRGB.R, + first.SRGB.G, + first.SRGB.B, + first.Alpha * second.Alpha) + }; + } + var firstLinear = first.Linear; var secondLinear = second.Linear; @@ -54,6 +84,9 @@ public struct SRGBColour : IEquatable public static SRGBColour operator *(SRGBColour first, float second) { + if (second == 1) + return first; + var firstLinear = first.Linear; return new SRGBColour @@ -92,6 +125,8 @@ public struct SRGBColour : IEquatable /// The alpha factor to multiply with. public void MultiplyAlpha(float alpha) => SRGB.A *= alpha; + private static bool isWhite(SRGBColour colour) => colour.SRGB.R == 1 && colour.SRGB.G == 1 && colour.SRGB.B == 1; + public readonly bool Equals(SRGBColour other) => SRGB.Equals(other.SRGB); public override string ToString() => $"srgb: {SRGB}, linear: {Linear}"; } diff --git a/osu.Framework/Graphics/Containers/BufferedContainer.cs b/osu.Framework/Graphics/Containers/BufferedContainer.cs index 5a83d0b87a..aa9aa210a5 100644 --- a/osu.Framework/Graphics/Containers/BufferedContainer.cs +++ b/osu.Framework/Graphics/Containers/BufferedContainer.cs @@ -271,16 +271,16 @@ private void load(ShaderManager shaders) protected override DrawNode CreateDrawNode() => new BufferedContainerDrawNode(this, sharedData); - public override bool UpdateSubTreeMasking(Drawable source, RectangleF maskingBounds) + public override bool UpdateSubTreeMasking() { - bool result = base.UpdateSubTreeMasking(source, maskingBounds); + bool result = base.UpdateSubTreeMasking(); childrenUpdateVersion = updateVersion; return result; } - protected override RectangleF ComputeChildMaskingBounds(RectangleF maskingBounds) => ScreenSpaceDrawQuad.AABBFloat; // Make sure children never get masked away + protected override RectangleF ComputeChildMaskingBounds() => ScreenSpaceDrawQuad.AABBFloat; // Make sure children never get masked away private Vector2 lastScreenSpaceSize; diff --git a/osu.Framework/Graphics/Containers/CompositeDrawable.cs b/osu.Framework/Graphics/Containers/CompositeDrawable.cs index 2c775e1d0f..3d9de04b5f 100644 --- a/osu.Framework/Graphics/Containers/CompositeDrawable.cs +++ b/osu.Framework/Graphics/Containers/CompositeDrawable.cs @@ -35,7 +35,7 @@ namespace osu.Framework.Graphics.Containers { /// /// A drawable consisting of a composite of child drawables which are - /// manages by the composite object itself. Transformations applied to + /// managed by the composite object itself. Transformations applied to /// a are also applied to its children. /// Additionally, s support various effects, such as masking, edge effect, /// padding, and automatic sizing depending on their children. @@ -58,6 +58,7 @@ protected CompositeDrawable() childrenSizeDependencies.Validate(); AddLayout(childrenSizeDependencies); + AddLayout(childMaskingBoundsBacking); } /// @@ -958,12 +959,10 @@ private void updateChild(Drawable c) /// Updates all masking calculations for this and its . /// This occurs post- to ensure that all updates have taken place. /// - /// The parent that triggered this update on this . - /// The that defines the masking bounds. /// Whether masking calculations have taken place. - public override bool UpdateSubTreeMasking(Drawable source, RectangleF maskingBounds) + public override bool UpdateSubTreeMasking() { - if (!base.UpdateSubTreeMasking(source, maskingBounds)) + if (!base.UpdateSubTreeMasking()) return false; if (IsMaskedAway) @@ -974,10 +973,8 @@ public override bool UpdateSubTreeMasking(Drawable source, RectangleF maskingBou if (RequiresChildrenUpdate) { - var childMaskingBounds = ComputeChildMaskingBounds(maskingBounds); - for (int i = 0; i < aliveInternalChildren.Count; i++) - aliveInternalChildren[i].UpdateSubTreeMasking(this, childMaskingBounds); + aliveInternalChildren[i].UpdateSubTreeMasking(); } return true; @@ -996,9 +993,15 @@ protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) /// /// Computes the to be used as the masking bounds for all . /// - /// The that defines the masking bounds for this . /// The to be used as the masking bounds for . - protected virtual RectangleF ComputeChildMaskingBounds(RectangleF maskingBounds) => Masking ? RectangleF.Intersect(maskingBounds, ScreenSpaceDrawQuad.AABBFloat) : maskingBounds; + protected virtual RectangleF ComputeChildMaskingBounds() => Masking ? RectangleF.Intersect(ComputeMaskingBounds(), ScreenSpaceDrawQuad.AABBFloat) : ComputeMaskingBounds(); + + private readonly LayoutValue childMaskingBoundsBacking = new LayoutValue(Invalidation.DrawInfo | Invalidation.RequiredParentSizeToFit | Invalidation.Presence); + + /// + /// The to be used as the masking bounds for all . + /// + public RectangleF ChildMaskingBounds => childMaskingBoundsBacking.IsValid ? childMaskingBoundsBacking : childMaskingBoundsBacking.Value = ComputeChildMaskingBounds(); /// /// Invoked after and state checks have taken place, @@ -1478,7 +1481,10 @@ protected set return; masking = value; - Invalidate(Invalidation.DrawNode); + // DrawInfo invalidation will propagate masking bounds changes in the sub-tree. + // While this can invalidate other layouts, there are rarely any use cases of enabling/disabling masking "on the fly" + // so this won't hurt performance under normal circumstances. + Invalidate(Invalidation.DrawNode | Invalidation.DrawInfo); } } diff --git a/osu.Framework/Graphics/Containers/CompositeDrawable_DrawNode.cs b/osu.Framework/Graphics/Containers/CompositeDrawable_DrawNode.cs index 901ea0c68f..f88845c6ea 100644 --- a/osu.Framework/Graphics/Containers/CompositeDrawable_DrawNode.cs +++ b/osu.Framework/Graphics/Containers/CompositeDrawable_DrawNode.cs @@ -176,6 +176,9 @@ private void updateQuadBatch(IRenderer renderer) quadBatch = renderer.CreateQuadBatch(100, 1000); } + // Children will set their own blending parameters. + internal override bool SetBlending => false; + protected override void Draw(IRenderer renderer) { updateQuadBatch(renderer); diff --git a/osu.Framework/Graphics/Containers/DelayedLoadWrapper.cs b/osu.Framework/Graphics/Containers/DelayedLoadWrapper.cs index db123a852d..988e9dba2a 100644 --- a/osu.Framework/Graphics/Containers/DelayedLoadWrapper.cs +++ b/osu.Framework/Graphics/Containers/DelayedLoadWrapper.cs @@ -5,9 +5,9 @@ using System; using System.Threading; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Extensions.PolygonExtensions; -using osu.Framework.Graphics.Primitives; using osu.Framework.Layout; using osu.Framework.Threading; @@ -189,8 +189,10 @@ protected virtual void CancelTasks() protected bool IsIntersecting { get; private set; } + [CanBeNull] internal IOnScreenOptimisingContainer OptimisingContainer { get; private set; } + [CanBeNull] internal IOnScreenOptimisingContainer FindParentOptimisingContainer() => this.FindClosestParent(); protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source) @@ -211,9 +213,9 @@ protected override bool OnInvalidate(Invalidation invalidation, InvalidationSour return result; } - public override bool UpdateSubTreeMasking(Drawable source, RectangleF maskingBounds) + public override bool UpdateSubTreeMasking() { - bool result = base.UpdateSubTreeMasking(source, maskingBounds); + bool result = base.UpdateSubTreeMasking(); // We can accurately compute intersections - the scheduled reset is no longer required. isIntersectingResetDelegate?.Cancel(); @@ -230,7 +232,7 @@ public override bool UpdateSubTreeMasking(Drawable source, RectangleF maskingBou // The first condition is an intersection against the hierarchy, including any parents that may be masking this wrapper. // It is the same calculation as Drawable.IsMaskedAway, however IsMaskedAway is optimised out for some CompositeDrawables (which this wrapper is). // The second condition is an exact intersection against the optimising container, which further optimises rotated AABBs where the wrapper content is not visible. - IsIntersecting = maskingBounds.IntersectsWith(ScreenSpaceDrawQuad.AABBFloat) + IsIntersecting = ComputeMaskingBounds().IntersectsWith(ScreenSpaceDrawQuad.AABBFloat) && OptimisingContainer?.ScreenSpaceDrawQuad.Intersects(ScreenSpaceDrawQuad) != false; isIntersectingCache.Validate(); diff --git a/osu.Framework/Graphics/Containers/FocusedOverlayContainer.cs b/osu.Framework/Graphics/Containers/FocusedOverlayContainer.cs index 990bfb95fe..873f64df59 100644 --- a/osu.Framework/Graphics/Containers/FocusedOverlayContainer.cs +++ b/osu.Framework/Graphics/Containers/FocusedOverlayContainer.cs @@ -22,11 +22,11 @@ protected override void UpdateState(ValueChangedEvent state) { case Visibility.Hidden: if (HasFocus) - GetContainingInputManager().ChangeFocus(null); + GetContainingFocusManager()?.ChangeFocus(null); break; case Visibility.Visible: - Schedule(() => GetContainingInputManager().TriggerFocusContention(this)); + Schedule(() => GetContainingFocusManager()?.TriggerFocusContention(this)); break; } } diff --git a/osu.Framework/Graphics/Containers/RearrangeableListContainer.cs b/osu.Framework/Graphics/Containers/RearrangeableListContainer.cs index 1363206638..ebef10929e 100644 --- a/osu.Framework/Graphics/Containers/RearrangeableListContainer.cs +++ b/osu.Framework/Graphics/Containers/RearrangeableListContainer.cs @@ -1,10 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; -using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; @@ -22,6 +19,7 @@ namespace osu.Framework.Graphics.Containers /// /// The type of rearrangeable item. public abstract partial class RearrangeableListContainer : CompositeDrawable + where TModel : notnull { private const float exp_base = 1.05f; @@ -51,7 +49,7 @@ public abstract partial class RearrangeableListContainer : CompositeDraw protected IReadOnlyDictionary> ItemMap => itemMap; private readonly Dictionary> itemMap = new Dictionary>(); - private RearrangeableListItem currentlyDraggedItem; + private RearrangeableListItem? currentlyDraggedItem; private Vector2 screenSpaceDragPosition; /// @@ -82,16 +80,16 @@ protected virtual void OnItemsChanged() { } - private void collectionChanged(object sender, NotifyCollectionChangedEventArgs e) + private void collectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { switch (e.Action) { case NotifyCollectionChangedAction.Add: - addItems(e.NewItems); + addItems(e.NewItems?.Cast() ?? []); break; case NotifyCollectionChangedAction.Remove: - removeItems(e.OldItems); + removeItems(e.OldItems?.Cast() ?? []); // Explicitly reset scroll position here so that ScrollContainer doesn't retain our // scroll position if we quickly add new items after calling a Clear(). @@ -107,8 +105,11 @@ private void collectionChanged(object sender, NotifyCollectionChangedEventArgs e break; case NotifyCollectionChangedAction.Replace: - removeItems(e.OldItems); - addItems(e.NewItems); + IEnumerable tOldItems = e.OldItems?.Cast() ?? []; + IEnumerable tNewItems = e.NewItems?.Cast() ?? []; + + removeItems(tOldItems.Except(tNewItems)); + addItems(tNewItems.Except(tOldItems)); break; case NotifyCollectionChangedAction.Move: @@ -118,9 +119,9 @@ private void collectionChanged(object sender, NotifyCollectionChangedEventArgs e } } - private void removeItems(IList items) + private void removeItems(IEnumerable items) { - foreach (var item in items.Cast()) + foreach (var item in items) { if (currentlyDraggedItem != null && EqualityComparer.Default.Equals(currentlyDraggedItem.Model, item)) currentlyDraggedItem = null; @@ -137,11 +138,11 @@ private void removeItems(IList items) OnItemsChanged(); } - private void addItems(IList items) + private void addItems(IEnumerable items) { var drawablesToAdd = new List(); - foreach (var item in items.Cast()) + foreach (var item in items) { if (itemMap.ContainsKey(item)) { @@ -183,11 +184,15 @@ private void sortItems() { for (int i = 0; i < Items.Count; i++) { - var drawable = itemMap[Items[i]]; + // A drawable for the item may not exist yet, for example in a replace-range operation where the removal happens first. + if (!itemMap.TryGetValue(Items[i], out var drawable)) + continue; + + // The item may not be loaded yet, because add operations are asynchronous. + if (drawable.Parent != ListContainer) + continue; - // If the async load didn't complete, the item wouldn't exist in the container and an exception would be thrown - if (drawable.Parent == ListContainer) - ListContainer!.SetLayoutPosition(drawable, i); + ListContainer.SetLayoutPosition(drawable, i); } } @@ -212,9 +217,7 @@ protected override void Update() protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); - - if (currentlyDraggedItem != null) - updateArrangement(); + updateArrangement(); } private void updateScrollPosition() @@ -239,6 +242,9 @@ private void updateScrollPosition() private void updateArrangement() { + if (currentlyDraggedItem == null) + return; + var localPos = ListContainer.ToLocalSpace(screenSpaceDragPosition); int srcIndex = Items.IndexOf(currentlyDraggedItem.Model); diff --git a/osu.Framework/Graphics/Containers/SafeAreaDefiningContainer.cs b/osu.Framework/Graphics/Containers/SafeAreaDefiningContainer.cs index 9e06fa1ea1..22e751120c 100644 --- a/osu.Framework/Graphics/Containers/SafeAreaDefiningContainer.cs +++ b/osu.Framework/Graphics/Containers/SafeAreaDefiningContainer.cs @@ -25,7 +25,7 @@ public partial class SafeAreaDefiningContainer : Container, ISafeArea /// /// Initialises a by optionally providing a custom . - /// If no such binding is provided, the container will default to . + /// If no such binding is provided, the container will default to . /// /// The custom to bind to, if required. public SafeAreaDefiningContainer(BindableSafeArea safeArea = null) diff --git a/osu.Framework/Graphics/Containers/ScrollContainer.cs b/osu.Framework/Graphics/Containers/ScrollContainer.cs index cc4e194d0b..dbd89fed1c 100644 --- a/osu.Framework/Graphics/Containers/ScrollContainer.cs +++ b/osu.Framework/Graphics/Containers/ScrollContainer.cs @@ -5,7 +5,9 @@ using System; using System.Diagnostics; +using JetBrains.Annotations; using osu.Framework.Caching; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; @@ -134,7 +136,10 @@ public bool ScrollbarOverlapsContent /// /// The maximum distance that the scrollbar can move in the scroll direction. /// - public float ScrollbarMovementExtent => Math.Max(DisplayableContent - Scrollbar.DrawSize[ScrollDim], 0); + /// + /// May not be accurate to actual display of scrollbar if or are overridden. + /// + protected float ScrollbarMovementExtent => Math.Max(DisplayableContent - Scrollbar.DrawSize[ScrollDim], 0); /// /// Clamp a value to the available scroll range. @@ -185,6 +190,7 @@ public bool IsHandlingKeyboardScrolling private readonly LayoutValue parentScrollContainerCache = new LayoutValue(Invalidation.Parent); + [CanBeNull] private IScrollContainer parentScrollContainer => parentScrollContainerCache.IsValid ? parentScrollContainerCache.Value : parentScrollContainerCache.Value = this.FindClosestParent(); @@ -236,7 +242,7 @@ private void updateSize() private void updatePadding() { - if (scrollbarOverlapsContent || AvailableContent <= DisplayableContent) + if (scrollbarOverlapsContent || !Precision.DefinitelyBigger(AvailableContent, DisplayableContent, 1f)) ScrollContent.Padding = new MarginPadding(); else { @@ -272,7 +278,7 @@ protected override bool OnDragStart(DragStartEvent e) IsDragging = true; - dragButtonManager = GetContainingInputManager().GetButtonEventManagerFor(e.Button); + dragButtonManager = GetContainingInputManager().AsNonNull().GetButtonEventManagerFor(e.Button); return true; } @@ -342,8 +348,6 @@ protected override void OnDrag(DragEvent e) float scrollOffset = -childDelta[ScrollDim]; float clampedScrollOffset = Clamp(Target + scrollOffset) - Clamp(Target); - Debug.Assert(Precision.AlmostBigger(Math.Abs(scrollOffset), clampedScrollOffset * Math.Sign(scrollOffset))); - // If we are dragging past the extent of the scrollable area, half the offset // such that the user can feel it. scrollOffset = clampedScrollOffset + (scrollOffset - clampedScrollOffset) / 2; @@ -408,7 +412,7 @@ protected override bool OnScroll(ScrollEvent e) return true; } - private void onScrollbarMovement(float value) => OnUserScroll(Clamp(fromScrollbarPosition(value)), false); + private void onScrollbarMovement(float value) => OnUserScroll(Clamp(FromScrollbarPosition(value)), false); /// /// Immediately offsets the current and target scroll position. @@ -567,7 +571,7 @@ protected override void UpdateAfterChildren() float size = ScrollDirection == Direction.Horizontal ? DrawWidth : DrawHeight; if (size > 0) Scrollbar.ResizeTo(Math.Clamp(AvailableContent > 0 ? DisplayableContent / AvailableContent : 0, Math.Min(Scrollbar.MinimumDimSize / size, 1), 1), 200, Easing.OutQuint); - Scrollbar.FadeTo(ScrollbarVisible && AvailableContent - 1 > DisplayableContent ? 1 : 0, 200); + Scrollbar.FadeTo(ScrollbarVisible && Precision.DefinitelyBigger(AvailableContent, DisplayableContent, 1f) ? 1 : 0, 200); updatePadding(); scrollbarCache.Validate(); @@ -575,12 +579,12 @@ protected override void UpdateAfterChildren() if (ScrollDirection == Direction.Horizontal) { - Scrollbar.X = toScrollbarPosition(Current); + Scrollbar.X = ToScrollbarPosition(Current); ScrollContent.X = -Current + ScrollableExtent * ScrollContent.RelativeAnchorPosition.X; } else { - Scrollbar.Y = toScrollbarPosition(Current); + Scrollbar.Y = ToScrollbarPosition(Current); ScrollContent.Y = -Current + ScrollableExtent * ScrollContent.RelativeAnchorPosition.Y; } } @@ -590,7 +594,7 @@ protected override void UpdateAfterChildren() /// /// The absolute scroll position (e.g. ). /// The scrollbar position. - private float toScrollbarPosition(float scrollPosition) + protected virtual float ToScrollbarPosition(float scrollPosition) { if (Precision.AlmostEquals(0, ScrollableExtent)) return 0; @@ -603,7 +607,7 @@ private float toScrollbarPosition(float scrollPosition) /// /// The scrollbar position. /// The absolute scroll position. - private float fromScrollbarPosition(float scrollbarPosition) + protected virtual float FromScrollbarPosition(float scrollbarPosition) { if (Precision.AlmostEquals(0, ScrollbarMovementExtent)) return 0; diff --git a/osu.Framework/Graphics/Containers/TabbableContainer.cs b/osu.Framework/Graphics/Containers/TabbableContainer.cs index 4db57c624d..9a49804fb6 100644 --- a/osu.Framework/Graphics/Containers/TabbableContainer.cs +++ b/osu.Framework/Graphics/Containers/TabbableContainer.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Input.Events; using osuTK.Input; @@ -43,13 +44,14 @@ protected override bool OnKeyDown(KeyDownEvent e) if (TabbableContentContainer == null || e.Key != Key.Tab) return false; - var nextTab = nextTabStop(TabbableContentContainer, e.ShiftPressed); - if (nextTab != null) GetContainingInputManager().ChangeFocus(nextTab); + moveToNextTabStop(TabbableContentContainer, e.ShiftPressed); return true; } - private Drawable nextTabStop(CompositeDrawable target, bool reverse) + private void moveToNextTabStop(CompositeDrawable target, bool reverse) { + var focusManager = GetContainingFocusManager().AsNonNull(); + Stack stack = new Stack(); stack.Push(target); // Extra push for circular tabbing stack.Push(target); @@ -62,8 +64,8 @@ private Drawable nextTabStop(CompositeDrawable target, bool reverse) if (!started) started = ReferenceEquals(drawable, this); - else if (drawable is ITabbableContainer tabbable && tabbable.CanBeTabbedTo) - return drawable; + else if (drawable is ITabbableContainer tabbable && tabbable.CanBeTabbedTo && focusManager.ChangeFocus(drawable)) + return; if (drawable is CompositeDrawable composite) { @@ -90,8 +92,6 @@ private Drawable nextTabStop(CompositeDrawable target, bool reverse) } } } - - return null; } } } diff --git a/osu.Framework/Graphics/Containers/VirtualisedListContainer.cs b/osu.Framework/Graphics/Containers/VirtualisedListContainer.cs new file mode 100644 index 0000000000..9c27d2324c --- /dev/null +++ b/osu.Framework/Graphics/Containers/VirtualisedListContainer.cs @@ -0,0 +1,294 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Caching; +using osu.Framework.Graphics.Pooling; +using osu.Framework.Graphics.UserInterface; + +namespace osu.Framework.Graphics.Containers +{ + /// + /// This class is an alternative to combined / usage + /// for large lists (think thousands of items). + /// This class efficiently handles collection change events, as well as applies pooling to only allocate drawables for what is actually visible + /// on screen. + /// The trade-off is that every row has to have an estimated or fixed height so that layout can be estimated cheaply pre-emptively. + /// + public abstract partial class VirtualisedListContainer : CompositeDrawable + where TData : notnull + where TDrawable : PoolableDrawable, IHasCurrentValue, new() + { + public BindableList RowData { get; } = new BindableList(); + + protected ScrollContainer Scroll { get; private set; } = null!; + protected ItemFlow Items { get; private set; } = null!; + + private readonly float rowHeight; + private readonly int initialPoolSize; + + private (int min, int max) visibleRange; + private readonly Cached visibilityCache = new Cached(); + + private DrawablePool pool = null!; + + protected VirtualisedListContainer(float rowHeight, int initialPoolSize) + { + this.rowHeight = rowHeight; + this.initialPoolSize = initialPoolSize; + } + + [BackgroundDependencyLoader] + private void load() + { + pool = new DrawablePool(initialPoolSize); + + InternalChildren = new Drawable[] + { + pool, + Scroll = CreateScrollContainer().With(s => + { + s.RelativeSizeAxes = Axes.Both; + s.Child = Items = new ItemFlow(pool, rowHeight) + { + RelativeSizeAxes = Axes.X + }; + }) + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + RowData.BindCollectionChanged(itemsChanged, true); + } + + private void itemsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + visibilityCache.Invalidate(); + Items.Height = rowHeight * RowData.Count; + + var items = Items.FlowingChildren.ToArray(); + + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + { + Debug.Assert(e.NewItems != null); + + if (e.NewStartingIndex >= 0) + { + for (int i = e.NewStartingIndex; i < items.Length; ++i) + Items.Move(items[i], i + e.NewItems.Count); + } + + for (int i = 0; i < e.NewItems.Count; ++i) + Items.Insert((TData)e.NewItems[i]!, Math.Max(e.NewStartingIndex, 0) + i); + + break; + } + + case NotifyCollectionChangedAction.Remove: + { + Debug.Assert(e.OldItems != null); + + for (int i = 0; i < e.OldItems.Count; ++i) + Items.Remove(items[e.OldStartingIndex + i]); + + for (int i = e.OldStartingIndex + e.OldItems.Count; i < items.Length; ++i) + Items.Move(items[i], i - e.OldItems.Count); + + break; + } + + case NotifyCollectionChangedAction.Replace: + { + Items[e.OldStartingIndex].Row = (TData)e.NewItems![0]!; + break; + } + + case NotifyCollectionChangedAction.Move: + { + var allMoves = new List<(ItemRow, int)>(); + + for (int i = Math.Min(e.OldStartingIndex, e.NewStartingIndex); i <= Math.Max(e.OldStartingIndex, e.NewStartingIndex); ++i) + { + if (i == e.OldStartingIndex) + allMoves.Add((items[i], e.NewStartingIndex)); + else if (e.NewStartingIndex < e.OldStartingIndex) + allMoves.Add((items[i], i + 1)); + else + allMoves.Add((items[i], i - 1)); + } + + foreach (var (item, newPosition) in allMoves) + Items.Move(item, newPosition); + + break; + } + + case NotifyCollectionChangedAction.Reset: + { + Items.Clear(); + break; + } + } + + assertCorrectOrder(); + } + + [Conditional("DEBUG")] + private void assertCorrectOrder() + { + for (int i = 0; i < Items.Count; ++i) + { + TData expectedItem = RowData[i]; + TData actualItem = Items[i].Row; + Debug.Assert(ReferenceEquals(expectedItem, actualItem), $"Data item mismatch at index {i} when handling changes in VirtualisedListContainer"); + + float expectedY = i * rowHeight; + float actualY = Items[i].Y; + Debug.Assert(expectedY == actualY, $"Y mismatch at index {i} when handling changes in VirtualisedListContainer: expected {expectedY} actual {actualY}"); + } + } + + protected override void Update() + { + base.Update(); + + var currentVisibleRange = ((int)(Scroll.Current / rowHeight), (int)((Scroll.Current + Scroll.DrawHeight) / rowHeight) + 1); + + if (currentVisibleRange != visibleRange) + { + visibilityCache.Invalidate(); + visibleRange = currentVisibleRange; + } + + if (!visibilityCache.IsValid) + { + foreach (var row in Items) + { + if (!row.IsPresent) + continue; + + float rowTop = row.Y; + float rowBottom = rowTop + rowHeight; + + if (rowTop < visibleRange.min * rowHeight || rowBottom > visibleRange.max * rowHeight) + { + if (row.Visible) + row.Unload(); + } + else + { + if (!row.Visible) + row.Load(); + } + } + + visibilityCache.Validate(); + assertCorrectOrder(); + } + } + + protected partial class ItemFlow : Container + { + private readonly DrawablePool pool; + private readonly float rowHeight; + + public ItemFlow(DrawablePool pool, float rowHeight) + { + this.pool = pool; + this.rowHeight = rowHeight; + } + + public IEnumerable FlowingChildren => Children.Where(d => d.IsPresent); + + public void Insert(TData row, int index) + { + Add(new ItemRow(row, pool) + { + Height = rowHeight, + LifetimeEnd = double.NegativeInfinity, + // the depth management is mostly done so that enumeration order of `Children` matches expectations. + // in edge cases it could also ensure correct Z-ordering of children, but it's a secondary consideration. + Depth = -index, + Y = index * rowHeight + }); + } + + public void Remove(ItemRow itemRow) + { + itemRow.Unload(); + Remove(itemRow, true); + } + + public void Move(ItemRow itemRow, int newIndex) + { + itemRow.Y = newIndex * rowHeight; + ChangeChildDepth(itemRow, -newIndex); + } + } + + protected partial class ItemRow : CompositeDrawable + { + public override bool RemoveWhenNotAlive => false; + public override bool DisposeOnDeathRemoval => false; + + private TData row; + + public TData Row + { + get => row; + [MemberNotNull(nameof(row))] + set + { + row = value; + if (InternalChildren.SingleOrDefault() is TDrawable rowDrawable) + rowDrawable.Current.Value = value; + } + } + + public bool Visible { get; private set; } + + private readonly DrawablePool pool; + + public ItemRow(TData row, DrawablePool pool) + { + Row = row; + this.pool = pool; + RelativeSizeAxes = Axes.X; + + // to avoid overheads from input handling (or, to be more specific, from constructing input queues), + // the row manages its own lifetime. + // if the row is not alive, it is not in `AliveInternalChildren` of its parent, + // which means that it is omitted from consideration in `Build(Non)PositionalInputQueue()` et al. + LifetimeEnd = double.NegativeInfinity; + } + + public void Load() + { + InternalChild = pool.Get(d => d.Current.Value = Row); + Visible = true; + LifetimeEnd = double.PositiveInfinity; + } + + public void Unload() + { + ClearInternal(false); + Visible = false; + LifetimeEnd = double.NegativeInfinity; + } + } + + protected abstract ScrollContainer CreateScrollContainer(); + } +} diff --git a/osu.Framework/Graphics/Cursor/CursorContainer.cs b/osu.Framework/Graphics/Cursor/CursorContainer.cs index 47c5ee738c..78da25041d 100644 --- a/osu.Framework/Graphics/Cursor/CursorContainer.cs +++ b/osu.Framework/Graphics/Cursor/CursorContainer.cs @@ -43,7 +43,7 @@ protected override void LoadComplete() { base.LoadComplete(); - inputManager = GetContainingInputManager(); + inputManager = GetContainingInputManager().AsNonNull(); inputManager.TouchLongPressBegan += onLongPressBegan; inputManager.TouchLongPressCancelled += longPressFeedback.CancelAnimation; } diff --git a/osu.Framework/Graphics/DrawNode.cs b/osu.Framework/Graphics/DrawNode.cs index 450fc529e0..3bf4d0839d 100644 --- a/osu.Framework/Graphics/DrawNode.cs +++ b/osu.Framework/Graphics/DrawNode.cs @@ -67,6 +67,11 @@ public virtual void ApplyState() InvalidationID = Source.InvalidationID; } + /// + /// Whether the renderer should set blending parameters of this . + /// + internal virtual bool SetBlending => true; + /// /// Draws this to the screen. /// @@ -76,7 +81,9 @@ public virtual void ApplyState() /// The renderer to draw with. protected virtual void Draw(IRenderer renderer) { - renderer.SetBlend(DrawColourInfo.Blending); + if (SetBlending) + renderer.SetBlend(DrawColourInfo.Blending); + renderer.BackbufferDepth.Set(drawDepth); } diff --git a/osu.Framework/Graphics/Drawable.cs b/osu.Framework/Graphics/Drawable.cs index 93d701a4eb..0ef037c605 100644 --- a/osu.Framework/Graphics/Drawable.cs +++ b/osu.Framework/Graphics/Drawable.cs @@ -25,6 +25,7 @@ using System.Linq; using System.Reflection; using System.Threading; +using JetBrains.Annotations; using osu.Framework.Bindables; using osu.Framework.Development; using osu.Framework.Extensions.EnumExtensions; @@ -495,22 +496,34 @@ public virtual bool UpdateSubTree() return true; } + /// + /// Computes the masking bounds of this . + /// + /// The that defines the masking bounds. + public virtual RectangleF ComputeMaskingBounds() + { + if (HasProxy) + return proxy.ComputeMaskingBounds(); + + if (parent == null) + return ScreenSpaceDrawQuad.AABBFloat; + + return parent.ChildMaskingBounds; + } + private RectangleF? lastMaskingBounds; /// /// Updates all masking calculations for this . /// This occurs post- to ensure that all updates have taken place. /// - /// The parent that triggered this update on this . - /// The that defines the masking bounds. /// Whether masking calculations have taken place. - public virtual bool UpdateSubTreeMasking(Drawable source, RectangleF maskingBounds) + public virtual bool UpdateSubTreeMasking() { if (!IsPresent) return false; - if (HasProxy && source != proxy) - return false; + var maskingBounds = ComputeMaskingBounds(); if (!maskingBacking.IsValid || lastMaskingBounds != maskingBounds) { @@ -1478,7 +1491,16 @@ protected internal virtual bool ShouldBeAlive /// As this is performing an upward tree traversal, avoid calling every frame. /// /// The first parent . - protected InputManager GetContainingInputManager() => this.FindClosestParent(); + [CanBeNull] + protected internal InputManager GetContainingInputManager() => this.FindClosestParent(); + + /// + /// Retrieve the first parent in the tree which implements . + /// As this is performing an upward tree traversal, avoid calling every frame. + /// + /// The first parent . + [CanBeNull] + protected internal IFocusManager GetContainingFocusManager() => this.FindClosestParent(); private CompositeDrawable parent; @@ -1525,7 +1547,7 @@ internal set public bool HasProxy => proxy != null; /// - /// True iff this is not a proxy of any . + /// True iff this is a proxy of any . /// public bool IsProxy => Original != this; @@ -2384,6 +2406,11 @@ public bool TriggerEvent(UIEvent e) /// public virtual bool AcceptsFocus => false; + /// + /// If true, returning true in causes the current focus target to be unfocused. + /// + public virtual bool ChangeFocusOnClick => true; + /// /// Whether this Drawable is currently hovered over. /// diff --git a/osu.Framework/Graphics/DrawableExtensions.cs b/osu.Framework/Graphics/DrawableExtensions.cs index e0f13a7c1f..ec61b220a7 100644 --- a/osu.Framework/Graphics/DrawableExtensions.cs +++ b/osu.Framework/Graphics/DrawableExtensions.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using osu.Framework.Development; @@ -22,7 +20,7 @@ public static class DrawableExtensions public static T With(this T drawable, Action adjustment) where T : Drawable { - adjustment?.Invoke(drawable); + adjustment.Invoke(drawable); return drawable; } @@ -34,15 +32,15 @@ public static T With(this T drawable, Action adjustment) /// /// The type to match. /// The first matching parent, or null if no parent of type is found. - public static T FindClosestParent(this Drawable drawable) where T : class, IDrawable + public static T? FindClosestParent(this Drawable? drawable) where T : class, IDrawable { - while ((drawable = drawable.Parent) != null) + while ((drawable = drawable?.Parent) != null) { if (drawable is T match) return match; } - return default; + return null; } /// @@ -51,11 +49,11 @@ public static T FindClosestParent(this Drawable drawable) where T : class, ID /// The to be checked. /// The root to be checked against. /// Whether the drawable was rooted. - internal static bool IsRootedAt(this Drawable drawable, Drawable root) + internal static bool IsRootedAt(this Drawable? drawable, Drawable root) { if (drawable == root) return true; - while ((drawable = drawable.Parent) != null) + while ((drawable = drawable?.Parent) != null) { if (drawable == root) return true; diff --git a/osu.Framework/Graphics/Drawable_ProxyDrawable.cs b/osu.Framework/Graphics/Drawable_ProxyDrawable.cs index 10daa3ebc9..bb8f76aada 100644 --- a/osu.Framework/Graphics/Drawable_ProxyDrawable.cs +++ b/osu.Framework/Graphics/Drawable_ProxyDrawable.cs @@ -3,7 +3,6 @@ #nullable disable -using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Rendering; namespace osu.Framework.Graphics @@ -60,13 +59,7 @@ internal override DrawNode GenerateDrawNodeSubtree(ulong frame, int treeIndex, b // We do not want to receive updates. That is the business of the original drawable. public override bool IsPresent => false; - public override bool UpdateSubTreeMasking(Drawable source, RectangleF maskingBounds) - { - if (Original.IsDisposed) - return false; - - return Original.UpdateSubTreeMasking(this, maskingBounds); - } + public override bool UpdateSubTreeMasking() => true; private class ProxyDrawNode : DrawNode { diff --git a/osu.Framework/Graphics/Lines/Path.cs b/osu.Framework/Graphics/Lines/Path.cs index e0c502e760..85c69475d9 100644 --- a/osu.Framework/Graphics/Lines/Path.cs +++ b/osu.Framework/Graphics/Lines/Path.cs @@ -13,6 +13,7 @@ using osu.Framework.Caching; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics.Rendering; +using osu.Framework.Layout; using osuTK.Graphics; namespace osu.Framework.Graphics.Lines @@ -305,9 +306,44 @@ public virtual Color4 BackgroundColour } } + public long PathInvalidationID { get; private set; } + + protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source) + { + bool result = base.OnInvalidate(invalidation, source); + + // Colour is being applied to the buffer instead of the actual drawable, thus removing the need to redraw the path on colour invalidation. + invalidation &= ~Invalidation.Colour; + + if (invalidation != Invalidation.None) + PathInvalidationID++; + + return result; + } + private readonly BufferedDrawNodeSharedData sharedData = new BufferedDrawNodeSharedData(new[] { RenderBufferFormat.D16 }, clipToRootNode: true); - protected override DrawNode CreateDrawNode() => new BufferedDrawNode(this, new PathDrawNode(this), sharedData); + protected override DrawNode CreateDrawNode() => new PathBufferedDrawNode(this, new PathDrawNode(this), sharedData); + + private class PathBufferedDrawNode : BufferedDrawNode + { + protected new Path Source => (Path)base.Source; + + public PathBufferedDrawNode(Path source, PathDrawNode child, BufferedDrawNodeSharedData sharedData) + : base(source, child, sharedData) + { + } + + private long pathInvalidationID = -1; + + public override void ApplyState() + { + base.ApplyState(); + pathInvalidationID = Source.PathInvalidationID; + } + + protected override long GetDrawVersion() => pathInvalidationID; + } protected override void Dispose(bool isDisposing) { diff --git a/osu.Framework/Graphics/MarginPadding.cs b/osu.Framework/Graphics/MarginPadding.cs index 65df10cfa8..65bacccb8c 100644 --- a/osu.Framework/Graphics/MarginPadding.cs +++ b/osu.Framework/Graphics/MarginPadding.cs @@ -95,6 +95,15 @@ public MarginPadding(float allSides) Bottom = -mp.Bottom, }; + public static MarginPadding operator *(MarginPadding mp, Vector2 v) => + new MarginPadding + { + Left = mp.Left * v.X, + Top = mp.Top * v.Y, + Right = mp.Right * v.X, + Bottom = mp.Bottom * v.Y, + }; + public MarginPadding ValueAt(double time, MarginPadding startValue, MarginPadding endValue, double startTime, double endTime, in TEasing easing) where TEasing : IEasingFunction => new MarginPadding diff --git a/osu.Framework/Graphics/OpenGL/Buffers/GLShaderStorageBufferObject.cs b/osu.Framework/Graphics/OpenGL/Buffers/GLShaderStorageBufferObject.cs index 4dff9b5314..ee4e3d88c9 100644 --- a/osu.Framework/Graphics/OpenGL/Buffers/GLShaderStorageBufferObject.cs +++ b/osu.Framework/Graphics/OpenGL/Buffers/GLShaderStorageBufferObject.cs @@ -2,7 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; using System.Runtime.InteropServices; +using osu.Framework.Development; using osu.Framework.Graphics.Rendering; using osuTK.Graphics.ES30; @@ -20,6 +22,8 @@ internal class GLShaderStorageBufferObject : IShaderStorageBufferObject : IUniformBuffer, IGLUniformBuffer public GLUniformBuffer(GLRenderer renderer) { + Trace.Assert(ThreadSafety.IsDrawThread); + this.renderer = renderer; size = Marshal.SizeOf(default(TData)); diff --git a/osu.Framework/Graphics/Performance/PerformanceOverlay.cs b/osu.Framework/Graphics/Performance/PerformanceOverlay.cs index 1d0f58b061..598a2e51f1 100644 --- a/osu.Framework/Graphics/Performance/PerformanceOverlay.cs +++ b/osu.Framework/Graphics/Performance/PerformanceOverlay.cs @@ -12,6 +12,8 @@ using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Platform; +using osu.Framework.Platform.SDL2; +using osu.Framework.Platform.SDL3; using osu.Framework.Threading; using osuTK; using osuTK.Graphics; @@ -231,6 +233,17 @@ private void updateInfoText() addHeader("Mode:"); addValue(configWindowMode.ToString()); + switch (host.Window) + { + case SDL3Window: + addValue(" (SDL3)"); + break; + + case SDL2Window: + addValue(" (SDL2)"); + break; + } + void addHeader(string text) => infoText.AddText($"{text} ", cp => { cp.Padding = new MarginPadding { Left = 5 }; diff --git a/osu.Framework/Graphics/Rendering/Deferred/Allocation/DeviceBufferPool.cs b/osu.Framework/Graphics/Rendering/Deferred/Allocation/DeviceBufferPool.cs index 36ec247584..0ee1403d8c 100644 --- a/osu.Framework/Graphics/Rendering/Deferred/Allocation/DeviceBufferPool.cs +++ b/osu.Framework/Graphics/Rendering/Deferred/Allocation/DeviceBufferPool.cs @@ -32,7 +32,7 @@ public DeviceBufferPool(GraphicsPipeline pipeline, uint bufferSize, BufferUsage public IPooledDeviceBuffer Get() { - if (TryGet(_ => true, out IPooledDeviceBuffer? existing)) + if (TryGet(out IPooledDeviceBuffer? existing)) return existing; existing = new PooledDeviceBuffer(Pipeline, bufferSize, usage); diff --git a/osu.Framework/Graphics/Rendering/Deferred/DeferredFrameBuffer.cs b/osu.Framework/Graphics/Rendering/Deferred/DeferredFrameBuffer.cs index b055e35963..192e8405d2 100644 --- a/osu.Framework/Graphics/Rendering/Deferred/DeferredFrameBuffer.cs +++ b/osu.Framework/Graphics/Rendering/Deferred/DeferredFrameBuffer.cs @@ -91,7 +91,10 @@ private sealed class DeferredFrameBufferTexture : IVeldridTexture private readonly DeferredFrameBuffer deferredFrameBuffer; private readonly VeldridTextureResources?[] resourcesArray = new VeldridTextureResources?[1]; + + private global::Veldrid.Texture? depthTexture; private Framebuffer? framebuffer; + private Vector2I resourceSize = Vector2I.One; public DeferredFrameBufferTexture(DeferredFrameBuffer deferredFrameBuffer) @@ -107,6 +110,9 @@ public void Resize(Vector2I size) resources?.Dispose(); resources = null; + depthTexture?.Dispose(); + depthTexture = null; + framebuffer?.Dispose(); framebuffer = null; @@ -139,7 +145,7 @@ public void EnsureCreated() (uint)resourceSize.Y, 1, 1, - PixelFormat.R8_G8_B8_A8_UNorm, + PixelFormat.R8G8B8A8UNorm, TextureUsage.Sampled | TextureUsage.RenderTarget)), deferredFrameBuffer.renderer.Factory.CreateSampler( new SamplerDescription( @@ -154,8 +160,6 @@ public void EnsureCreated() 0, SamplerBorderColor.TransparentBlack))); - global::Veldrid.Texture? depthTexture = null; - if (deferredFrameBuffer.formats?[0] is PixelFormat depth) { depthTexture = deferredFrameBuffer.renderer.Factory.CreateTexture( diff --git a/osu.Framework/Graphics/Rendering/Deferred/DeferredShaderStorageBufferObject.cs b/osu.Framework/Graphics/Rendering/Deferred/DeferredShaderStorageBufferObject.cs index 929f232656..c0628ae22d 100644 --- a/osu.Framework/Graphics/Rendering/Deferred/DeferredShaderStorageBufferObject.cs +++ b/osu.Framework/Graphics/Rendering/Deferred/DeferredShaderStorageBufferObject.cs @@ -2,7 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; using System.Runtime.CompilerServices; +using osu.Framework.Development; using osu.Framework.Graphics.Rendering.Deferred.Allocation; using osu.Framework.Graphics.Rendering.Deferred.Events; using osu.Framework.Graphics.Veldrid.Buffers; @@ -22,6 +24,8 @@ internal class DeferredShaderStorageBufferObject : IShaderStorageBufferOb public DeferredShaderStorageBufferObject(DeferredRenderer renderer, int ssboSize) { + Trace.Assert(ThreadSafety.IsDrawThread); + this.renderer = renderer; elementSize = Unsafe.SizeOf(); diff --git a/osu.Framework/Graphics/Rendering/Deferred/DeferredUniformBuffer.cs b/osu.Framework/Graphics/Rendering/Deferred/DeferredUniformBuffer.cs index a8373115b4..194c688616 100644 --- a/osu.Framework/Graphics/Rendering/Deferred/DeferredUniformBuffer.cs +++ b/osu.Framework/Graphics/Rendering/Deferred/DeferredUniformBuffer.cs @@ -3,6 +3,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using osu.Framework.Development; using osu.Framework.Graphics.Rendering.Deferred.Allocation; using osu.Framework.Graphics.Rendering.Deferred.Events; using osu.Framework.Graphics.Veldrid.Buffers; @@ -24,6 +26,8 @@ internal class DeferredUniformBuffer : IUniformBuffer, IDeferredUn public DeferredUniformBuffer(DeferredRenderer renderer) { + Trace.Assert(ThreadSafety.IsDrawThread); + this.renderer = renderer; } diff --git a/osu.Framework/Graphics/Rendering/Dummy/DummyFrameBuffer.cs b/osu.Framework/Graphics/Rendering/Dummy/DummyFrameBuffer.cs index 519893bf8b..f4a1d18a1e 100644 --- a/osu.Framework/Graphics/Rendering/Dummy/DummyFrameBuffer.cs +++ b/osu.Framework/Graphics/Rendering/Dummy/DummyFrameBuffer.cs @@ -26,7 +26,7 @@ public Vector2 Size public DummyFrameBuffer(IRenderer renderer) { - Texture = new Texture(new DummyNativeTexture(renderer), WrapMode.None, WrapMode.None); + Texture = new Texture(new DummyNativeTexture(renderer, 1, 1), WrapMode.None, WrapMode.None); } public void Bind() diff --git a/osu.Framework/Graphics/Rendering/Dummy/DummyNativeTexture.cs b/osu.Framework/Graphics/Rendering/Dummy/DummyNativeTexture.cs index 36f07a96ea..c014cc44b9 100644 --- a/osu.Framework/Graphics/Rendering/Dummy/DummyNativeTexture.cs +++ b/osu.Framework/Graphics/Rendering/Dummy/DummyNativeTexture.cs @@ -14,8 +14,8 @@ internal class DummyNativeTexture : INativeTexture public string Identifier => string.Empty; public int MaxSize => 4096; // Sane default for testing purposes. - public int Width { get; set; } = 1; - public int Height { get; set; } = 1; + public int Width { get; set; } + public int Height { get; set; } public int? MipLevel { get; set; } public bool Available => true; public bool BypassTextureUploadQueueing { get; set; } @@ -23,9 +23,11 @@ internal class DummyNativeTexture : INativeTexture public bool IsQueuedForUpload { get; set; } ulong INativeTexture.TotalBindCount { get; set; } - public DummyNativeTexture(IRenderer renderer) + public DummyNativeTexture(IRenderer renderer, int width, int height) { Renderer = renderer; + Width = width; + Height = height; } public void FlushUploads() diff --git a/osu.Framework/Graphics/Rendering/Dummy/DummyRenderer.cs b/osu.Framework/Graphics/Rendering/Dummy/DummyRenderer.cs index 04783126cb..0dff42beb4 100644 --- a/osu.Framework/Graphics/Rendering/Dummy/DummyRenderer.cs +++ b/osu.Framework/Graphics/Rendering/Dummy/DummyRenderer.cs @@ -1,245 +1,142 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Framework.Graphics.Primitives; -using osu.Framework.Graphics.Rendering.Vertices; using osu.Framework.Graphics.Shaders; using osu.Framework.Graphics.Textures; using osu.Framework.Platform; -using osu.Framework.Threading; -using osuTK; using osuTK.Graphics; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; -using RectangleF = osu.Framework.Graphics.Primitives.RectangleF; namespace osu.Framework.Graphics.Rendering.Dummy { /// /// An that does nothing. May be used for tests that don't have a visual output. /// - public sealed class DummyRenderer : IRenderer + public sealed class DummyRenderer : Renderer { - public int MaxTextureSize => int.MaxValue; - public int MaxTexturesUploadedPerFrame { get; set; } = int.MaxValue; - public int MaxPixelsUploadedPerFrame { get; set; } = int.MaxValue; + protected internal override bool VerticalSync { get; set; } = true; + protected internal override bool AllowTearing { get; set; } + public override bool IsDepthRangeZeroToOne => true; + public override bool IsUvOriginTopLeft => true; + public override bool IsClipSpaceYInverted => true; - public bool IsDepthRangeZeroToOne => true; - public bool IsUvOriginTopLeft => true; - public bool IsClipSpaceYInverted => true; - public ref readonly MaskingInfo CurrentMaskingInfo => ref maskingInfo; - private readonly MaskingInfo maskingInfo; + protected internal override Image TakeScreenshot() + => new Image(1, 1); - public RectangleI Viewport => RectangleI.Empty; - public RectangleF Ortho => RectangleF.Empty; - public RectangleI Scissor => RectangleI.Empty; - public Vector2I ScissorOffset => Vector2I.Zero; - public Matrix4 ProjectionMatrix => Matrix4.Identity; - public DepthInfo CurrentDepthInfo => DepthInfo.Default; - public StencilInfo CurrentStencilInfo => StencilInfo.Default; - public WrapMode CurrentWrapModeS => WrapMode.None; - public WrapMode CurrentWrapModeT => WrapMode.None; - public bool IsMaskingActive => false; - public bool UsingBackbuffer => false; - public Texture WhitePixel { get; } - DepthValue IRenderer.BackbufferDepth { get; } = new DepthValue(); - - public bool IsInitialised { get; private set; } - - public DummyRenderer() - { - maskingInfo = default; - WhitePixel = new TextureWhitePixel(new Texture(new DummyNativeTexture(this), WrapMode.None, WrapMode.None)); - } - - public ulong FrameIndex { get; private set; } - - bool IRenderer.VerticalSync { get; set; } = true; - - bool IRenderer.AllowTearing { get; set; } - - Storage? IRenderer.CacheStorage { set { } } + protected override IShaderPart CreateShaderPart(IShaderStore store, string name, byte[]? rawData, ShaderPartType partType) + => new DummyShaderPart(); - void IRenderer.Initialise(IGraphicsSurface graphicsSurface) - { - IsInitialised = true; - } + protected override IShader CreateShader(string name, IShaderPart[] parts, ShaderCompilationStore compilationStore) + => new DummyShader(this); - void IRenderer.BeginFrame(Vector2 windowSize) - { - FrameIndex++; - } + protected override IVertexBatch CreateLinearBatch(int size, int maxBuffers, PrimitiveTopology topology) + => new DummyVertexBatch(); - void IRenderer.FinishFrame() - { - } + protected override IVertexBatch CreateQuadBatch(int size, int maxBuffers) + => new DummyVertexBatch(); - void IRenderer.FlushCurrentBatch(FlushBatchSource? source) - { - } + protected override IUniformBuffer CreateUniformBuffer() + => new DummyUniformBuffer(); - void IRenderer.SwapBuffers() - { - } + protected override IShaderStorageBufferObject CreateShaderStorageBufferObject(int uboSize, int ssboSize) + => new DummyShaderStorageBufferObject(ssboSize); - void IRenderer.WaitUntilIdle() - { - } + public Texture CreateTexture(int width, int height, bool manualMipmaps = false, TextureFilteringMode filteringMode = TextureFilteringMode.Linear, WrapMode wrapModeS = WrapMode.None) + => base.CreateTexture(width, height, manualMipmaps, filteringMode, wrapModeS, wrapModeS, null); - void IRenderer.WaitUntilNextFrameReady() - { - } + protected override INativeTexture CreateNativeTexture(int width, int height, bool manualMipmaps = false, TextureFilteringMode filteringMode = TextureFilteringMode.Linear, + Color4? initialisationColour = null) + => new DummyNativeTexture(this, width, height); - void IRenderer.MakeCurrent() - { - } + protected override INativeTexture CreateNativeVideoTexture(int width, int height) + => new DummyNativeTexture(this, width, height); - void IRenderer.ClearCurrent() + protected override void Initialise(IGraphicsSurface graphicsSurface) { } - public bool BindTexture(Texture texture, int unit = 0, WrapMode? wrapModeS = null, WrapMode? wrapModeT = null) - => true; - - public void UseProgram(IShader? shader) + protected internal override void SwapBuffers() { } - public void Clear(ClearInfo clearInfo) + protected internal override void WaitUntilIdle() { } - public void PushScissorState(bool enabled) + protected internal override void WaitUntilNextFrameReady() { } - public void PopScissorState() + protected internal override void MakeCurrent() { } - public void SetBlend(BlendingParameters blendingParameters) + protected internal override void ClearCurrent() { } - public void SetBlendMask(BlendingMask blendingMask) + protected override void ClearImplementation(ClearInfo clearInfo) { } - public void PushViewport(RectangleI viewport) + protected override void SetBlendImplementation(BlendingParameters blendingParameters) { } - public void PopViewport() + protected override void SetBlendMaskImplementation(BlendingMask blendingMask) { } - public void PushScissor(RectangleI scissor) + protected override void SetViewportImplementation(RectangleI viewport) { } - public void PopScissor() + protected override void SetScissorImplementation(RectangleI scissor) { } - public void PushScissorOffset(Vector2I offset) + protected override void SetScissorStateImplementation(bool enabled) { } - public void PopScissorOffset() + protected override void SetDepthInfoImplementation(DepthInfo depthInfo) { } - public void PushProjectionMatrix(Matrix4 matrix) + protected override void SetStencilInfoImplementation(StencilInfo stencilInfo) { } - public void PopProjectionMatrix() - { - } + protected override bool SetTextureImplementation(INativeTexture? texture, int unit) + => true; - public void PushMaskingInfo(in MaskingInfo maskingInfo, bool overwritePreviousScissor = false) + protected override void SetFrameBufferImplementation(IFrameBuffer? frameBuffer) { } - public void PopMaskingInfo() + protected override void DeleteFrameBufferImplementation(IFrameBuffer frameBuffer) { } - public void PushDepthInfo(DepthInfo depthInfo) + public override void DrawVerticesImplementation(PrimitiveTopology topology, int vertexStart, int verticesCount) { } - public void PopDepthInfo() + protected override void SetShaderImplementation(IShader shader) { } - public void PushStencilInfo(StencilInfo stencilInfo) + protected override void SetUniformImplementation(IUniformWithValue uniform) { } - public void PopStencilInfo() + protected override void SetUniformBufferImplementation(string blockName, IUniformBuffer buffer) { } - public void ScheduleExpensiveOperation(ScheduledDelegate operation) => operation.RunTask(); - - public void ScheduleDisposal(Action disposalAction, T target) => disposalAction(target); - - Image IRenderer.TakeScreenshot() => new Image(1366, 768); - - IShaderPart IRenderer.CreateShaderPart(IShaderStore manager, string name, byte[]? rawData, ShaderPartType partType) - => new DummyShaderPart(); - - IShader IRenderer.CreateShader(string name, IShaderPart[] parts) - => new DummyShader(this); - - public IFrameBuffer CreateFrameBuffer(RenderBufferFormat[]? renderBufferFormats = null, TextureFilteringMode filteringMode = TextureFilteringMode.Linear) + public override IFrameBuffer CreateFrameBuffer(RenderBufferFormat[]? renderBufferFormats = null, TextureFilteringMode filteringMode = TextureFilteringMode.Linear) => new DummyFrameBuffer(this); - - public Texture CreateTexture(int width, int height, bool manualMipmaps = false, TextureFilteringMode filteringMode = TextureFilteringMode.Linear, WrapMode wrapModeS = WrapMode.None, - WrapMode wrapModeT = WrapMode.None, Color4? initialisationColour = null) - => new Texture(new DummyNativeTexture(this) { Width = width, Height = height }, wrapModeS, wrapModeT); - - public Texture CreateVideoTexture(int width, int height) - => CreateTexture(width, height); - - public IVertexBatch CreateLinearBatch(int size, int maxBuffers, PrimitiveTopology topology) where TVertex : unmanaged, IEquatable, IVertex - => new DummyVertexBatch(); - - public IVertexBatch CreateQuadBatch(int size, int maxBuffers) where TVertex : unmanaged, IEquatable, IVertex - => new DummyVertexBatch(); - - public IUniformBuffer CreateUniformBuffer() where TData : unmanaged, IEquatable - => new DummyUniformBuffer(); - - public IShaderStorageBufferObject CreateShaderStorageBufferObject(int uboSize, int ssboSize) where TData : unmanaged, IEquatable - => new DummyShaderStorageBufferObject(ssboSize); - - void IRenderer.SetUniform(IUniformWithValue uniform) - { - } - - IVertexBatch IRenderer.DefaultQuadBatch => new DummyVertexBatch(); - - void IRenderer.PushQuadBatch(IVertexBatch quadBatch) - { - } - - void IRenderer.PopQuadBatch() - { - } - - event Action? IRenderer.TextureCreated - { - add - { - } - remove - { - } - } - - Texture[] IRenderer.GetAllTextures() => Array.Empty(); } } diff --git a/osu.Framework/Graphics/Rendering/Renderer.cs b/osu.Framework/Graphics/Rendering/Renderer.cs index cf8a4d43ea..01a8a1f39a 100644 --- a/osu.Framework/Graphics/Rendering/Renderer.cs +++ b/osu.Framework/Graphics/Rendering/Renderer.cs @@ -793,6 +793,8 @@ public bool BindTexture(Texture texture, int unit, WrapMode? wrapModeS, WrapMode if (texture is TextureWhitePixel && lastBoundTextureIsAtlas[unit]) { + setWrapMode(wrapModeS ?? texture.WrapModeS, wrapModeT ?? texture.WrapModeT); + // We can use the special white space from any atlas texture. return true; } @@ -816,33 +818,45 @@ public bool BindTexture(Texture texture, int unit, WrapMode? wrapModeS, WrapMode public bool BindTexture(INativeTexture texture, int unit = 0, WrapMode wrapModeS = WrapMode.None, WrapMode wrapModeT = WrapMode.None) { if (lastActiveTextureUnit == unit && lastBoundTexture[unit] == texture) + { + setWrapMode(wrapModeS, wrapModeT); return true; + } FlushCurrentBatch(FlushBatchSource.BindTexture); if (!SetTextureImplementation(texture, unit)) return false; + setWrapMode(wrapModeS, wrapModeT); + + lastBoundTexture[unit] = texture; + lastBoundTextureIsAtlas[unit] = false; + lastActiveTextureUnit = unit; + + FrameStatistics.Increment(StatisticsCounterType.TextureBinds); + texture.TotalBindCount++; + + return true; + } + + private void setWrapMode(WrapMode wrapModeS, WrapMode wrapModeT) + { if (wrapModeS != CurrentWrapModeS) { + FlushCurrentBatch(FlushBatchSource.BindTexture); + CurrentWrapModeS = wrapModeS; globalUniformsChanged = true; } if (wrapModeT != CurrentWrapModeT) { + FlushCurrentBatch(FlushBatchSource.BindTexture); + CurrentWrapModeT = wrapModeT; globalUniformsChanged = true; } - - lastBoundTexture[unit] = texture; - lastBoundTextureIsAtlas[unit] = false; - lastActiveTextureUnit = unit; - - FrameStatistics.Increment(StatisticsCounterType.TextureBinds); - texture.TotalBindCount++; - - return true; } /// @@ -1252,8 +1266,6 @@ IShaderStorageBufferObject IRenderer.CreateShaderStorageBufferObject() { - Trace.Assert(ThreadSafety.IsDrawThread); - if (validUboTypes.Contains(typeof(TData))) return; diff --git a/osu.Framework/Graphics/Shapes/FastCircle.cs b/osu.Framework/Graphics/Shapes/FastCircle.cs new file mode 100644 index 0000000000..660239ad45 --- /dev/null +++ b/osu.Framework/Graphics/Shapes/FastCircle.cs @@ -0,0 +1,131 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Rendering.Vertices; +using osu.Framework.Graphics.Shaders; +using osuTK; + +namespace osu.Framework.Graphics.Shapes +{ + /// + /// A circle that is rendered directly to the screen using a specialised shader. + /// This behaves slightly differently from but offers + /// higher performance in scenarios where many circles are drawn at once. + /// + public partial class FastCircle : Drawable + { + private float edgeSmoothness = 1f; + + public float EdgeSmoothness + { + get => edgeSmoothness; + set + { + if (edgeSmoothness == value) + return; + + edgeSmoothness = value; + + if (IsLoaded) + Invalidate(Invalidation.DrawNode); + } + } + + private IShader shader = null!; + + [BackgroundDependencyLoader] + private void load(ShaderManager shaders) + { + shader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, "FastCircle"); + } + + public float Radius => MathF.Min(DrawSize.X, DrawSize.Y) * 0.5f; + + public override bool Contains(Vector2 screenSpacePos) + { + if (!base.Contains(screenSpacePos)) + return false; + + float cRadius = Radius; + return DrawRectangle.Shrink(cRadius).DistanceExponentiated(ToLocalSpace(screenSpacePos), 2f) <= cRadius * cRadius; + } + + protected override DrawNode CreateDrawNode() => new FastCircleDrawNode(this); + + private class FastCircleDrawNode : DrawNode + { + protected new FastCircle Source => (FastCircle)base.Source; + + public FastCircleDrawNode(FastCircle source) + : base(source) + { + } + + private Quad screenSpaceDrawQuad; + private Vector4 drawRectangle; + private Vector2 blend; + private IShader shader = null!; + + public override void ApplyState() + { + base.ApplyState(); + + screenSpaceDrawQuad = Source.ScreenSpaceDrawQuad; + drawRectangle = new Vector4(0, 0, Source.DrawWidth, Source.DrawHeight); + shader = Source.shader; + blend = new Vector2(Source.edgeSmoothness * Math.Min(Source.DrawWidth, Source.DrawHeight) / Math.Min(screenSpaceDrawQuad.Width, screenSpaceDrawQuad.Height)); + } + + protected override void Draw(IRenderer renderer) + { + base.Draw(renderer); + + if (!renderer.BindTexture(renderer.WhitePixel)) + return; + + shader.Bind(); + + var vertexAction = renderer.DefaultQuadBatch.AddAction; + + vertexAction(new TexturedVertex2D(renderer) + { + Position = screenSpaceDrawQuad.BottomLeft, + TexturePosition = new Vector2(0, drawRectangle.W), + TextureRect = drawRectangle, + BlendRange = blend, + Colour = DrawColourInfo.Colour.BottomLeft.SRGB, + }); + vertexAction(new TexturedVertex2D(renderer) + { + Position = screenSpaceDrawQuad.BottomRight, + TexturePosition = new Vector2(drawRectangle.Z, drawRectangle.W), + TextureRect = drawRectangle, + BlendRange = blend, + Colour = DrawColourInfo.Colour.BottomRight.SRGB, + }); + vertexAction(new TexturedVertex2D(renderer) + { + Position = screenSpaceDrawQuad.TopRight, + TexturePosition = new Vector2(drawRectangle.Z, 0), + TextureRect = drawRectangle, + BlendRange = blend, + Colour = DrawColourInfo.Colour.TopRight.SRGB, + }); + vertexAction(new TexturedVertex2D(renderer) + { + Position = screenSpaceDrawQuad.TopLeft, + TexturePosition = Vector2.Zero, + TextureRect = drawRectangle, + BlendRange = blend, + Colour = DrawColourInfo.Colour.TopLeft.SRGB, + }); + + shader.Unbind(); + } + } + } +} diff --git a/osu.Framework/Graphics/Sprites/NineSliceSprite.cs b/osu.Framework/Graphics/Sprites/NineSliceSprite.cs new file mode 100644 index 0000000000..72f27059d6 --- /dev/null +++ b/osu.Framework/Graphics/Sprites/NineSliceSprite.cs @@ -0,0 +1,269 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Textures; +using osu.Framework.Utils; +using osuTK; + +namespace osu.Framework.Graphics.Sprites +{ + /// + /// A that uses 9-slice scaling to stretch a Texture. + /// When resizing a , the corners will remain unscaled. + /// + /// A B + /// +---+----------------------+---+ + /// C | 1 | 2 | 3 | + /// +---+----------------------+---+ + /// | | | | + /// | 4 | 5 | 6 | + /// | | | | + /// +---+----------------------+---+ + /// D | 7 | 8 | 9 | + /// +---+----------------------+---+ + /// + /// When changing the , areas 1, 4, 7, 3, 6, and 9 (A and B) will remain unscaled. + /// When changing the , areas 1, 2, 3, 7, 8, and 9 (C and D) will remain unscaled. + /// + public partial class NineSliceSprite : Sprite + { + private MarginPadding textureInset; + + /// + /// The inset of the texture that will remain unscaled when resizing this . + /// May be absolute or relative units (controlled by ). + /// + public MarginPadding TextureInset + { + get => textureInset; + set + { + if (textureInset.Equals(value)) + return; + + textureInset = value; + + Invalidate(Invalidation.DrawNode); + } + } + + private Axes textureInsetRelativeAxes; + + /// + /// Controls which of are relative w.r.t. + /// 's (from 0 to 1) rather than absolute. + /// + /// + /// When setting this property, the is converted such that the absolute TextureInset + /// remains invariant. + /// + public Axes TextureInsetRelativeAxes + { + get => textureInsetRelativeAxes; + set + { + if (textureInsetRelativeAxes == value) + return; + + if (Texture != null) + { + Vector2 textureSize = Texture.DisplaySize; + + Vector2 conversion = Vector2.One; + + if ((value & Axes.X) > 0 && (textureInsetRelativeAxes & Axes.X) == 0) + conversion.X = Precision.AlmostEquals(textureSize.X, 0) ? 0 : 1 / textureSize.X; + else if ((value & Axes.X) == 0 && (textureInsetRelativeAxes & Axes.X) > 0) + conversion.X = textureSize.X; + + if ((value & Axes.Y) > 0 && (textureInsetRelativeAxes & Axes.Y) == 0) + conversion.Y = Precision.AlmostEquals(textureSize.Y, 0) ? 0 : 1 / textureSize.Y; + else if ((value & Axes.Y) == 0 && (textureInsetRelativeAxes & Axes.Y) > 0) + conversion.Y = textureSize.Y; + + textureInset *= conversion; + } + + textureInsetRelativeAxes = value; + + Invalidate(Invalidation.DrawNode); + } + } + + internal MarginPadding RelativeTextureInset + { + get + { + if (Texture == null) + return default; + + Vector2 conversion = Vector2.One; + + if ((TextureInsetRelativeAxes & Axes.X) == 0) + conversion.X = Precision.AlmostEquals(Texture.DisplayWidth, 0) ? 0 : 1 / Texture.DisplayWidth; + + if ((TextureInsetRelativeAxes & Axes.Y) == 0) + conversion.Y = Precision.AlmostEquals(Texture.DisplayHeight, 0) ? 0 : 1 / Texture.DisplayHeight; + + return textureInset * conversion; + } + } + + internal MarginPadding RelativeGeometryInset + { + get + { + if (Texture == null) + return default; + + var result = textureInset; + + var conversion = new Vector2( + Precision.AlmostEquals(DrawWidth, 0) ? 0 : 1 / DrawWidth, + Precision.AlmostEquals(DrawHeight, 0) ? 0 : 1 / DrawHeight + ); + + if ((TextureInsetRelativeAxes & Axes.X) != 0) + conversion.X *= Texture.DisplayWidth; + if ((TextureInsetRelativeAxes & Axes.Y) != 0) + conversion.Y *= Texture.DisplayHeight; + + return result * conversion; + } + } + + protected override DrawNode CreateDrawNode() => new NineSliceSpriteDrawNode(this); + + private class NineSliceSpriteDrawNode : SpriteDrawNode + { + public NineSliceSpriteDrawNode(NineSliceSprite source) + : base(source) + { + } + + protected new NineSliceSprite Source => (NineSliceSprite)base.Source; + + protected override void Blit(IRenderer renderer) + { + if (DrawRectangle.Width == 0 || DrawRectangle.Height == 0) + return; + + for (int i = 0; i < DrawQuads.Length; i++) + renderer.DrawQuad(Texture, DrawQuads[i], DrawColourInfo.Colour, null, null, Vector2.Zero, null, TextureRects[i]); + } + + protected readonly RectangleF[] TextureRects = new RectangleF[9]; + protected readonly Quad[] DrawQuads = new Quad[9]; + + public override void ApplyState() + { + base.ApplyState(); + + computeTextureRects(Source.RelativeTextureInset); + computeDrawQuads(Source.RelativeGeometryInset); + } + + private void computeDrawQuads(MarginPadding inset) + { + DrawQuads[0] = computePart(Anchor.TopLeft); + DrawQuads[1] = computePart(Anchor.TopCentre); + DrawQuads[2] = computePart(Anchor.TopRight); + DrawQuads[3] = computePart(Anchor.CentreLeft); + DrawQuads[4] = computePart(Anchor.Centre); + DrawQuads[5] = computePart(Anchor.CentreRight); + DrawQuads[6] = computePart(Anchor.BottomLeft); + DrawQuads[7] = computePart(Anchor.BottomCentre); + DrawQuads[8] = computePart(Anchor.BottomRight); + + Quad computePart(Anchor anchor) + { + Quad drawQuad = ScreenSpaceDrawQuad; + + if ((anchor & Anchor.x0) > 0) + drawQuad = horizontalSlice(drawQuad, 0, inset.Left); + else if ((anchor & Anchor.x1) > 0) + drawQuad = horizontalSlice(drawQuad, inset.Left, 1 - inset.Right); + else if ((anchor & Anchor.x2) > 0) + drawQuad = horizontalSlice(drawQuad, 1 - inset.Right, 1); + + if ((anchor & Anchor.y0) > 0) + drawQuad = verticalSlice(drawQuad, 0, inset.Top); + else if ((anchor & Anchor.y1) > 0) + drawQuad = verticalSlice(drawQuad, inset.Top, 1 - inset.Bottom); + else if ((anchor & Anchor.y2) > 0) + drawQuad = verticalSlice(drawQuad, 1 - inset.Bottom, 1); + + return drawQuad; + } + + static Quad horizontalSlice(Quad quad, float start, float end) => + new Quad( + Vector2.Lerp(quad.TopLeft, quad.TopRight, start), + Vector2.Lerp(quad.TopLeft, quad.TopRight, end), + Vector2.Lerp(quad.BottomLeft, quad.BottomRight, start), + Vector2.Lerp(quad.BottomLeft, quad.BottomRight, end) + ); + + static Quad verticalSlice(Quad quad, float start, float end) => + new Quad( + Vector2.Lerp(quad.TopLeft, quad.BottomLeft, start), + Vector2.Lerp(quad.TopRight, quad.BottomRight, start), + Vector2.Lerp(quad.TopLeft, quad.BottomLeft, end), + Vector2.Lerp(quad.TopRight, quad.BottomRight, end) + ); + } + + private void computeTextureRects(MarginPadding inset) + { + TextureRects[0] = computePart(Anchor.TopLeft); + TextureRects[1] = computePart(Anchor.TopCentre); + TextureRects[2] = computePart(Anchor.TopRight); + TextureRects[3] = computePart(Anchor.CentreLeft); + TextureRects[4] = computePart(Anchor.Centre); + TextureRects[5] = computePart(Anchor.CentreRight); + TextureRects[6] = computePart(Anchor.BottomLeft); + TextureRects[7] = computePart(Anchor.BottomCentre); + TextureRects[8] = computePart(Anchor.BottomRight); + + RectangleF computePart(Anchor anchor) + { + var textureCoords = TextureCoords; + + if ((anchor & Anchor.x0) > 0) + { + textureCoords.Width *= inset.Left; + } + else if ((anchor & Anchor.x1) > 0) + { + textureCoords.X += textureCoords.Width * inset.Left; + textureCoords.Width *= 1 - inset.TotalHorizontal; + } + else if ((anchor & Anchor.x2) > 0) + { + textureCoords.X += textureCoords.Width * (1 - inset.Right); + textureCoords.Width *= inset.Right; + } + + if ((anchor & Anchor.y0) > 0) + { + textureCoords.Height *= inset.Top; + } + else if ((anchor & Anchor.y1) > 0) + { + textureCoords.Y += textureCoords.Height * inset.Top; + textureCoords.Height *= 1 - inset.TotalVertical; + } + else if ((anchor & Anchor.y2) > 0) + { + textureCoords.Y += textureCoords.Height * (1 - inset.Bottom); + textureCoords.Height *= inset.Bottom; + } + + return textureCoords; + } + } + } + } +} diff --git a/osu.Framework/Graphics/Sprites/Sprite.cs b/osu.Framework/Graphics/Sprites/Sprite.cs index 3c126ccc1b..bcfad11ec2 100644 --- a/osu.Framework/Graphics/Sprites/Sprite.cs +++ b/osu.Framework/Graphics/Sprites/Sprite.cs @@ -30,6 +30,12 @@ private void load(ShaderManager shaders) TextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE); } + /// + /// The shader which should be used for rendering this sprite. + /// + /// + /// This is automatically populated, but may be overridden if required for special cases. + /// If overriding, set in a method or later. public IShader TextureShader { get; protected set; } private RectangleF textureRectangle = new RectangleF(0, 0, 1, 1); diff --git a/osu.Framework/Graphics/Sprites/SpriteText.cs b/osu.Framework/Graphics/Sprites/SpriteText.cs index 36b8eec0e9..dce188d6b6 100644 --- a/osu.Framework/Graphics/Sprites/SpriteText.cs +++ b/osu.Framework/Graphics/Sprites/SpriteText.cs @@ -41,7 +41,13 @@ public partial class SpriteText : Drawable, IHasLineBaseHeight, ITexturedShaderD private ILocalisedBindableString localisedText; - public IShader TextureShader { get; private set; } + /// + /// The shader which should be used for rendering this sprite text. + /// + /// + /// This is automatically populated, but may be overridden if required for special cases. + /// If overriding, set in a method or later. + public IShader TextureShader { get; protected set; } public SpriteText() { diff --git a/osu.Framework/Graphics/Textures/Texture.cs b/osu.Framework/Graphics/Textures/Texture.cs index 5b1efd149e..fb93dcf0ce 100644 --- a/osu.Framework/Graphics/Textures/Texture.cs +++ b/osu.Framework/Graphics/Textures/Texture.cs @@ -43,6 +43,11 @@ public class Texture : IDisposable /// public float DisplayHeight => Height / ScaleAdjust; + /// + /// The size of this texture when drawn to the screen. + /// + public Vector2 DisplaySize => Vector2.Divide(Size, ScaleAdjust); + /// /// The texture opacity. /// diff --git a/osu.Framework/Graphics/UserInterface/BasicSliderBar.cs b/osu.Framework/Graphics/UserInterface/BasicSliderBar.cs index 17b932cee9..9b6697d13d 100644 --- a/osu.Framework/Graphics/UserInterface/BasicSliderBar.cs +++ b/osu.Framework/Graphics/UserInterface/BasicSliderBar.cs @@ -4,6 +4,7 @@ using System.Numerics; using osuTK.Graphics; using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; using Vector2 = osuTK.Vector2; namespace osu.Framework.Graphics.UserInterface @@ -23,6 +24,18 @@ public Color4 SelectionColour set => SelectionBox.Colour = value; } + private Color4 focusColour = FrameworkColour.YellowGreen; + + public Color4 FocusColour + { + get => focusColour; + set + { + focusColour = value; + updateFocus(); + } + } + protected readonly Box SelectionBox; protected readonly Box Box; @@ -37,10 +50,37 @@ public BasicSliderBar() }, SelectionBox = new Box { - RelativeSizeAxes = Axes.Both, Colour = FrameworkColour.Yellow, + RelativeSizeAxes = Axes.Both, } }; + + Masking = true; + } + + protected override void OnFocus(FocusEvent e) + { + updateFocus(); + base.OnFocus(e); + } + + protected override void OnFocusLost(FocusLostEvent e) + { + updateFocus(); + base.OnFocusLost(e); + } + + private void updateFocus() + { + if (HasFocus) + { + BorderThickness = 3; + BorderColour = FocusColour; + } + else + { + BorderThickness = 0; + } } protected override void UpdateValue(float value) diff --git a/osu.Framework/Graphics/UserInterface/BasicTextBox.cs b/osu.Framework/Graphics/UserInterface/BasicTextBox.cs index 46802d8cbb..381699178e 100644 --- a/osu.Framework/Graphics/UserInterface/BasicTextBox.cs +++ b/osu.Framework/Graphics/UserInterface/BasicTextBox.cs @@ -119,7 +119,7 @@ public override void Show() public override void Hide() { this.FadeOut(200); - this.MoveToY(DrawSize.Y, 200, Easing.InExpo); + this.MoveToY(DrawSize.Y, 200, Easing.InQuad); } } diff --git a/osu.Framework/Graphics/UserInterface/Dropdown.cs b/osu.Framework/Graphics/UserInterface/Dropdown.cs index 089b2c3129..daa3af9d36 100644 --- a/osu.Framework/Graphics/UserInterface/Dropdown.cs +++ b/osu.Framework/Graphics/UserInterface/Dropdown.cs @@ -8,6 +8,7 @@ using System.Collections.Specialized; using System.Diagnostics; using System.Linq; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Extensions.IEnumerableExtensions; @@ -28,7 +29,8 @@ namespace osu.Framework.Graphics.UserInterface /// A drop-down menu to select from a group of values. /// /// Type of value to select. - public abstract partial class Dropdown : CompositeDrawable, IHasCurrentValue + [Cached(typeof(IDropdown))] + public abstract partial class Dropdown : CompositeDrawable, IHasCurrentValue, IFocusManager, IDropdown { protected internal DropdownHeader Header; protected internal DropdownMenu Menu; @@ -96,6 +98,8 @@ public IBindableList ItemSource } } + private readonly BindableBool enabled = new BindableBool(true); + private void setItems(IEnumerable value) { clearItems(); @@ -255,25 +259,16 @@ protected Dropdown() AutoSizeAxes = Axes.Y }; - Header.ToggleMenu = Menu.Toggle; Header.ChangeSelection += selectionKeyPressed; - Header.SearchTerm.ValueChanged += t => Menu.SearchTerm = t.NewValue; Menu.RelativeSizeAxes = Axes.X; - Menu.PreselectionConfirmed += preselectionConfirmed; Menu.FilterCompleted += filterCompleted; - Menu.StateChanged += state => - { - Menu.State = state; - Header.UpdateSearchBarFocus(state); - }; - Current.ValueChanged += val => Scheduler.AddOnce(updateItemSelection, val.NewValue); Current.DisabledChanged += disabled => { - Header.Enabled.Value = !disabled; + enabled.Value = !disabled; if (disabled && Menu.State == MenuState.Open) Menu.State = MenuState.Closed; }; @@ -281,12 +276,6 @@ protected Dropdown() ItemSource.CollectionChanged += collectionChanged; } - private void preselectionConfirmed(DropdownMenuItem item) - { - SelectedItem = item; - Menu.State = MenuState.Closed; - } - private void filterCompleted() { if (!string.IsNullOrEmpty(Menu.SearchTerm)) @@ -522,8 +511,6 @@ private void clearPreselection(MenuState obj) public DrawableDropdownMenuItem PreselectedItem => VisibleMenuItems.FirstOrDefault(c => c.IsPreSelected) ?? VisibleMenuItems.FirstOrDefault(c => c.IsSelected); - public event Action> PreselectionConfirmed; - /// /// Selects an item from this . /// @@ -746,11 +733,6 @@ protected override bool OnKeyDown(KeyDownEvent e) else PreselectItem(visibleMenuItemsList.IndexOf(lastVisibleItem)); return true; - - case Key.Enter: - var preselectedItem = VisibleMenuItems.ElementAt(targetPreselectionIndex); - PreselectionConfirmed?.Invoke((DropdownMenuItem)preselectedItem.Item); - return true; } } @@ -800,5 +782,67 @@ public SearchableItemsFlow() } #endregion + + #region IFocusManager + + // Isolate input so that the Menu doesn't disturb focus. Focus is managed via the IDropdown interface. + void IFocusManager.TriggerFocusContention(Drawable triggerSource) { } + + // Isolate input so that the Menu doesn't disturb focus. Focus is managed via the IDropdown interface. + bool IFocusManager.ChangeFocus(Drawable potentialFocusTarget) => false; + + #endregion + + #region IDropdown + + event Action IDropdown.MenuStateChanged + { + add => Menu.StateChanged += value; + remove => Menu.StateChanged -= value; + } + + IBindable IDropdown.Enabled => enabled; + + MenuState IDropdown.MenuState => Menu.State; + + void IDropdown.ToggleMenu() + { + if (!Current.Disabled) + Menu.Toggle(); + } + + void IDropdown.OpenMenu() + { + if (!Current.Disabled) + Menu.State = MenuState.Open; + } + + void IDropdown.CloseMenu() + { + if (!Current.Disabled) + Menu.State = MenuState.Closed; + } + + void IDropdown.CommitPreselection() + { + if (Current.Disabled) + return; + + var visibleMenuItemsList = Menu.VisibleMenuItems.ToList(); + + if (visibleMenuItemsList.Count == 0) + return; + + int targetPreselectionIndex = visibleMenuItemsList.IndexOf(Menu.PreselectedItem); + var preselectedItem = Menu.VisibleMenuItems.ElementAt(targetPreselectionIndex); + + SelectedItem = (DropdownMenuItem)preselectedItem.Item; + } + + void IDropdown.TriggerFocusContention(Drawable triggerSource) => GetContainingFocusManager()?.TriggerFocusContention(triggerSource); + + bool IDropdown.ChangeFocus(Drawable potentialFocusTarget) => GetContainingFocusManager()?.ChangeFocus(potentialFocusTarget) ?? false; + + #endregion } } diff --git a/osu.Framework/Graphics/UserInterface/DropdownHeader.cs b/osu.Framework/Graphics/UserInterface/DropdownHeader.cs index 31145fe692..433a0e349e 100644 --- a/osu.Framework/Graphics/UserInterface/DropdownHeader.cs +++ b/osu.Framework/Graphics/UserInterface/DropdownHeader.cs @@ -5,6 +5,7 @@ using osuTK.Graphics; using System; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -63,9 +64,10 @@ protected Color4 DisabledColour protected internal abstract LocalisableString Label { get; set; } - public BindableBool Enabled { get; } = new BindableBool(true); + public readonly IBindable Enabled = new Bindable(true); - public Action ToggleMenu; + [Resolved] + private IDropdown dropdown { get; set; } = null!; protected DropdownHeader() { @@ -96,6 +98,11 @@ protected DropdownHeader() AutoSizeAxes = Axes.Y }, SearchBar = CreateSearchBar(), + new ClickHandler + { + RelativeSizeAxes = Axes.Both, + Click = onClick + } }; } @@ -105,18 +112,10 @@ protected override void LoadComplete() { base.LoadComplete(); + Enabled.BindTo(dropdown.Enabled); Enabled.BindValueChanged(_ => updateState(), true); } - protected override bool OnClick(ClickEvent e) - { - if (!Enabled.Value) - return false; - - ToggleMenu?.Invoke(); - return false; - } - protected override bool OnHover(HoverEvent e) { updateState(); @@ -129,20 +128,26 @@ protected override void OnHoverLost(HoverLostEvent e) base.OnHoverLost(e); } - public void UpdateSearchBarFocus(MenuState state) - { - if (state == MenuState.Open) - SearchBar.ObtainFocus(); - else - SearchBar.ReleaseFocus(); - } - private void updateState() { Colour = Enabled.Value ? Color4.White : DisabledColour; Background.Colour = IsHovered && Enabled.Value ? BackgroundColourHover : BackgroundColour; } + /// + /// Handles clicks on the header to open/close the menu. + /// + private bool onClick(ClickEvent e) + { + // Allow input to fall through to the search bar (and its contained textbox) if there's any search text. + if (SearchBar.State.Value == Visibility.Visible && !string.IsNullOrEmpty(SearchTerm.Value)) + return false; + + // Otherwise, the header acts as a button to show/hide the menu. + dropdown.ToggleMenu(); + return true; + } + public override bool HandleNonPositionalInput => IsHovered; protected override bool OnKeyDown(KeyDownEvent e) @@ -198,5 +203,11 @@ public enum DropdownSelectionAction FirstVisible, LastVisible } + + private partial class ClickHandler : Drawable + { + public required Func Click { get; init; } + protected override bool OnClick(ClickEvent e) => Click(e); + } } } diff --git a/osu.Framework/Graphics/UserInterface/DropdownSearchBar.cs b/osu.Framework/Graphics/UserInterface/DropdownSearchBar.cs index f42c0e286c..c0879d6ac5 100644 --- a/osu.Framework/Graphics/UserInterface/DropdownSearchBar.cs +++ b/osu.Framework/Graphics/UserInterface/DropdownSearchBar.cs @@ -4,27 +4,22 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; using osu.Framework.Input; using osu.Framework.Platform; using osuTK; namespace osu.Framework.Graphics.UserInterface { - public abstract partial class DropdownSearchBar : VisibilityContainer + public abstract partial class DropdownSearchBar : VisibilityContainer, IFocusManager { - [Resolved] - private GameHost? host { get; set; } - - private TextBox textBox = null!; - private PassThroughInputManager textBoxInputManager = null!; - public Bindable SearchTerm { get; } = new Bindable(); - // handling mouse input on dropdown header is not easy, since the menu would lose focus on release and automatically close - public override bool HandlePositionalInput => false; - public override bool PropagatePositionalInputSubTree => false; + [Resolved] + private IDropdown dropdown { get; set; } = null!; - private bool obtainedFocus; + private TextBox textBox = null!; + private DropdownTextInputSource? inputSource; private bool alwaysDisplayOnFocus; @@ -35,90 +30,216 @@ public bool AlwaysDisplayOnFocus { alwaysDisplayOnFocus = value; + if (inputSource != null) + inputSource.AlwaysDisplayOnFocus = value; + if (IsLoaded) - updateVisibility(); + updateTextBoxVisibility(); } } [BackgroundDependencyLoader] private void load() { - AlwaysPresent = true; RelativeSizeAxes = Axes.Both; + AlwaysPresent = true; - // Dropdown menus rely on their focus state to determine when they should be closed. - // On the other hand, text boxes require to be focused in order for the user to interact with them. - // To handle that matter, we'll wrap the search text box inside a local input manager, and manage its focus state accordingly. - InternalChild = textBoxInputManager = new PassThroughInputManager + InternalChildren = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Child = textBox = CreateTextBox().With(t => + textBox = CreateTextBox().With(t => { - t.ReleaseFocusOnCommit = false; t.RelativeSizeAxes = Axes.Both; t.Size = new Vector2(1f); t.Current = SearchTerm; + t.ReleaseFocusOnCommit = true; + t.CommitOnFocusLost = false; + t.OnCommit += onTextBoxCommit; }) }; + + dropdown.MenuStateChanged += onMenuStateChanged; + } + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + inputSource = new DropdownTextInputSource(parent.Get(), parent.Get()) + { + AlwaysDisplayOnFocus = AlwaysDisplayOnFocus + }; + + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + dependencies.CacheAs(typeof(TextInputSource), inputSource); + return dependencies; } protected override void LoadComplete() { base.LoadComplete(); + SearchTerm.BindValueChanged(_ => updateTextBoxVisibility(), true); + } + + public override bool PropagateNonPositionalInputSubTree => dropdown.Enabled.Value && base.PropagateNonPositionalInputSubTree; + + // Importantly, this also removes the visibility condition of the base implementation - this element is always present even though it may not be physically visible on the screen. + public override bool PropagatePositionalInputSubTree => dropdown.Enabled.Value && RequestsPositionalInputSubTree && !IsMaskedAway; - SearchTerm.BindValueChanged(v => updateVisibility()); - updateVisibility(); + protected override void Update() + { + base.Update(); + + updateMenuState(); + updateTextBoxVisibility(); + } + + /// + /// Clears the search term. + /// + /// If the search term was cleared. + public bool Back() + { + if (string.IsNullOrEmpty(SearchTerm.Value)) + return false; + + SearchTerm.Value = string.Empty; + return true; + } + + /// + /// Opens or closes the menu depending on whether the textbox is focused. + /// + private void updateMenuState() + { + if (textBox.HasFocus) + dropdown.OpenMenu(); + else + dropdown.CloseMenu(); + } + + /// + /// Updates the textbox visibility. + /// + private void updateTextBoxVisibility() + { + bool showTextBox = AlwaysDisplayOnFocus || !string.IsNullOrEmpty(SearchTerm.Value); + State.Value = textBox.HasFocus && showTextBox ? Visibility.Visible : Visibility.Hidden; } - public void ObtainFocus() + /// + /// Handles textbox commits to select the current item. + /// + private void onTextBoxCommit(TextBox sender, bool newText) { - // On mobile platforms, let's not make the keyboard popup unless the dropdown is intentionally searchable. - // Unfortunately it is not enough to just early-return here, - // as even despite that the text box will receive focus via the text box input manager; - // it is necessary to cut off the text box input manager from parent input entirely. - // TODO: preferably figure out a better way to do this. - bool willShowOverlappingKeyboard = host?.OnScreenKeyboardOverlapsGameWindow == true; - - if (willShowOverlappingKeyboard && !AlwaysDisplayOnFocus) + dropdown.CommitPreselection(); + dropdown.CloseMenu(); + } + + /// + /// Handles changes to the menu visibility. + /// + private void onMenuStateChanged(MenuState state) + { + if (state == MenuState.Closed) { - textBoxInputManager.UseParentInput = false; - return; + // Reset states when the menu is closed by any means. + SearchTerm.Value = string.Empty; + + if (textBox.HasFocus) + dropdown.ChangeFocus(null); + + dropdown.CloseMenu(); } + else + dropdown.ChangeFocus(textBox); + + updateTextBoxVisibility(); + } - textBoxInputManager.ChangeFocus(textBox); - obtainedFocus = true; + /// + /// Creates the . + /// + protected abstract TextBox CreateTextBox(); - updateVisibility(); + void IFocusManager.TriggerFocusContention(Drawable? triggerSource) + { + // Clear search text first without releasing focus. + if (Back()) + return; + + dropdown.TriggerFocusContention(triggerSource); } - public void ReleaseFocus() + bool IFocusManager.ChangeFocus(Drawable? potentialFocusTarget) { - textBoxInputManager.ChangeFocus(null); - SearchTerm.Value = string.Empty; - obtainedFocus = false; + // Clear search text first without releasing focus. + if (Back()) + return false; - updateVisibility(); + return dropdown.ChangeFocus(potentialFocusTarget); } - public bool Back() + private class DropdownTextInputSource : TextInputSource { - // text box may have lost focus from pressing escape, retain it. - if (obtainedFocus && !textBox.HasFocus) - ObtainFocus(); + public bool AlwaysDisplayOnFocus { get; set; } + + private bool allowTextInput => !host.OnScreenKeyboardOverlapsGameWindow || AlwaysDisplayOnFocus; + + private readonly TextInputSource platformSource; + private readonly GameHost host; + private RectangleF? imeRectangle; - if (!string.IsNullOrEmpty(SearchTerm.Value)) + public DropdownTextInputSource(TextInputSource platformSource, GameHost host) { - SearchTerm.Value = string.Empty; - return true; + this.platformSource = platformSource; + this.host = host; + + platformSource.OnTextInput += TriggerTextInput; + platformSource.OnImeComposition += TriggerImeComposition; + platformSource.OnImeResult += TriggerImeResult; } - return false; - } + protected override void ActivateTextInput(bool allowIme) + { + base.ActivateTextInput(allowIme); - private void updateVisibility() => State.Value = obtainedFocus && (AlwaysDisplayOnFocus || !string.IsNullOrEmpty(SearchTerm.Value)) - ? Visibility.Visible - : Visibility.Hidden; + if (allowTextInput) + platformSource.Activate(allowIme, imeRectangle ?? RectangleF.Empty); + } - protected abstract TextBox CreateTextBox(); + protected override void EnsureTextInputActivated(bool allowIme) + { + base.EnsureTextInputActivated(allowIme); + + if (allowTextInput) + platformSource.EnsureActivated(allowIme, imeRectangle); + } + + protected override void DeactivateTextInput() + { + base.DeactivateTextInput(); + + imeRectangle = null; + + if (allowTextInput) + platformSource.Deactivate(); + } + + public override void SetImeRectangle(RectangleF rectangle) + { + base.SetImeRectangle(rectangle); + + imeRectangle = rectangle; + + if (allowTextInput) + platformSource.SetImeRectangle(rectangle); + } + + public override void ResetIme() + { + base.ResetIme(); + + if (allowTextInput) + platformSource.ResetIme(); + } + } } } diff --git a/osu.Framework/Graphics/UserInterface/IDropdown.cs b/osu.Framework/Graphics/UserInterface/IDropdown.cs new file mode 100644 index 0000000000..21e7750b05 --- /dev/null +++ b/osu.Framework/Graphics/UserInterface/IDropdown.cs @@ -0,0 +1,63 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Bindables; +using osu.Framework.Input; + +namespace osu.Framework.Graphics.UserInterface +{ + internal interface IDropdown + { + /// + /// An event that occurs when the menu state changes. + /// + event Action MenuStateChanged; + + /// + /// Whether the dropdown is currently enabled. + /// + IBindable Enabled { get; } + + /// + /// The current menu state. + /// + MenuState MenuState { get; } + + /// + /// Toggles the menu. + /// + void ToggleMenu(); + + /// + /// Opens the menu. + /// + void OpenMenu(); + + /// + /// Closes the menu. + /// + void CloseMenu(); + + /// + /// Commits the current pre-selected value. + /// + void CommitPreselection(); + + /// + /// Triggers focus contention on the parenting . + /// + /// + /// Focus management is isolated by the . This invokes the method on the parenting un-interrupted. + /// + void TriggerFocusContention(Drawable? triggerSource); + + /// + /// Triggers a change of focus on the parenting . + /// + /// + /// Focus management is isolated by the . This invokes the method on the parenting un-interrupted. + /// + bool ChangeFocus(Drawable? potentialFocusTarget); + } +} diff --git a/osu.Framework/Graphics/UserInterface/Menu.cs b/osu.Framework/Graphics/UserInterface/Menu.cs index e8aa0ea860..ac178cf5ba 100644 --- a/osu.Framework/Graphics/UserInterface/Menu.cs +++ b/osu.Framework/Graphics/UserInterface/Menu.cs @@ -9,6 +9,7 @@ using JetBrains.Annotations; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Extensions.ObjectExtensions; using osuTK.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -249,7 +250,7 @@ private void updateState() AnimateClose(); if (HasFocus) - GetContainingInputManager()?.ChangeFocus(parentMenu); + GetContainingFocusManager()?.ChangeFocus(parentMenu); break; case MenuState.Open: @@ -262,7 +263,7 @@ private void updateState() { Schedule(delegate { - if (State == MenuState.Open) GetContainingInputManager().ChangeFocus(this); + if (State == MenuState.Open) GetContainingFocusManager().AsNonNull().ChangeFocus(this); }); } @@ -386,7 +387,7 @@ protected override void Update() if (!positionLayout.IsValid && State == MenuState.Open && parentMenu != null) { - var inputManager = GetContainingInputManager(); + var inputManager = GetContainingInputManager().AsNonNull(); // This is the default position to which this menu should be anchored to the parent menu item which triggered it (top left of the triggering item) var triggeringItemTopLeftPosition = triggeringItem.ToSpaceOfOtherDrawable(Vector2.Zero, parentMenu); @@ -570,7 +571,7 @@ private void openSubmenuFor(DrawableMenuItem item) if (item.Item.Items.Count > 0) { if (submenu.State == MenuState.Open) - Schedule(delegate { GetContainingInputManager().ChangeFocus(submenu); }); + Schedule(delegate { GetContainingFocusManager().AsNonNull().ChangeFocus(submenu); }); else submenu.Open(); } diff --git a/osu.Framework/Graphics/UserInterface/Popover.cs b/osu.Framework/Graphics/UserInterface/Popover.cs index 72ec1cfa6a..48e60e5ee0 100644 --- a/osu.Framework/Graphics/UserInterface/Popover.cs +++ b/osu.Framework/Graphics/UserInterface/Popover.cs @@ -7,8 +7,10 @@ using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Framework.Utils; using osuTK; using osuTK.Input; @@ -101,6 +103,8 @@ internal set protected override Container Content { get; } = new Container { AutoSizeAxes = Axes.Both }; + protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => !Precision.AlmostIntersects(maskingBounds, Content.ScreenSpaceDrawQuad.AABBFloat); + protected Popover() { base.AddInternal(BoundingBoxContainer = new Container @@ -108,7 +112,10 @@ protected Popover() AutoSizeAxes = Axes.Both, Children = new[] { - Arrow = CreateArrow(), + Arrow = CreateArrow().With(arr => + { + arr.BypassAutoSizeAxes = Axes.Both; + }), Body = new Container { AutoSizeAxes = Axes.Both, diff --git a/osu.Framework/Graphics/UserInterface/SliderBar.cs b/osu.Framework/Graphics/UserInterface/SliderBar.cs index 9b173f832e..20fe6a18b7 100644 --- a/osu.Framework/Graphics/UserInterface/SliderBar.cs +++ b/osu.Framework/Graphics/UserInterface/SliderBar.cs @@ -145,7 +145,7 @@ protected override bool OnClick(ClickEvent e) if (handleClick) { handleMouseInput(e); - commit(); + Commit(); } return true; @@ -166,18 +166,21 @@ protected override bool OnDragStart(DragStartEvent e) return false; } + GetContainingFocusManager()?.ChangeFocus(this); handleMouseInput(e); return true; } - protected override void OnDragEnd(DragEndEvent e) => commit(); + protected override void OnDragEnd(DragEndEvent e) => Commit(); + + public override bool AcceptsFocus => true; protected override bool OnKeyDown(KeyDownEvent e) { if (currentNumberInstantaneous.Disabled) return false; - if (!IsHovered) + if (!IsHovered && !HasFocus) return false; float step = KeyboardStep != 0 ? KeyboardStep : (Convert.ToSingle(currentNumberInstantaneous.MaxValue) - Convert.ToSingle(currentNumberInstantaneous.MinValue)) / 20; @@ -203,12 +206,12 @@ protected override bool OnKeyDown(KeyDownEvent e) protected override void OnKeyUp(KeyUpEvent e) { if (e.Key == Key.Left || e.Key == Key.Right) - commit(); + Commit(); } private bool uncommittedChanges; - private bool commit() + protected virtual bool Commit() { if (!uncommittedChanges) return false; diff --git a/osu.Framework/Graphics/UserInterface/TextBox.cs b/osu.Framework/Graphics/UserInterface/TextBox.cs index c6c8ef05e1..e825299cd2 100644 --- a/osu.Framework/Graphics/UserInterface/TextBox.cs +++ b/osu.Framework/Graphics/UserInterface/TextBox.cs @@ -8,10 +8,12 @@ using System.Diagnostics; using System.Linq; using System.Text; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Caching; using osu.Framework.Development; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Extensions.PlatformActionExtensions; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; @@ -535,7 +537,7 @@ protected override void Dispose(bool isDisposing) { OnCommit = null; - unbindInput(false); + unbindInput(null); base.Dispose(isDisposing); } @@ -720,6 +722,8 @@ private void endTextChange(bool started) textChanging = false; } + private bool ignoreOngoingDragSelection; + /// /// Removes the selected text if a selection persists. /// @@ -866,7 +870,9 @@ private void insertString(string value, Action drawableCreationParamet drawableCreationParameters?.Invoke(drawable); text = text.Insert(selectionLeft, c.ToString()); + selectionStart = selectionEnd = selectionLeft + 1; + ignoreOngoingDragSelection = true; cursorAndLayout.Invalidate(); } @@ -1154,9 +1160,8 @@ protected override bool OnKeyDown(KeyDownEvent e) private void killFocus() { - var manager = GetContainingInputManager(); - if (manager?.FocusedDrawable == this) - manager.ChangeFocus(null); + if (GetContainingInputManager()?.FocusedDrawable == this) + GetContainingFocusManager()?.ChangeFocus(null); } /// @@ -1187,6 +1192,17 @@ protected override void OnKeyUp(KeyUpEvent e) base.OnKeyUp(e); } + protected override bool OnDragStart(DragStartEvent e) + { + ignoreOngoingDragSelection = false; + + if (HasFocus) + return true; + + Vector2 posDiff = e.MouseDownPosition - e.MousePosition; + return Math.Abs(posDiff.X) > Math.Abs(posDiff.Y); + } + protected override void OnDrag(DragEvent e) { if (ReadOnly) @@ -1194,6 +1210,9 @@ protected override void OnDrag(DragEvent e) FinalizeImeComposition(true); + if (ignoreOngoingDragSelection) + return; + var lastSelectionBounds = getTextSelectionBounds(); if (doubleClickWord != null) @@ -1224,7 +1243,7 @@ protected override void OnDrag(DragEvent e) selectionEnd = getCharacterClosestTo(e.MousePosition); if (hasSelection) - GetContainingInputManager().ChangeFocus(this); + GetContainingFocusManager().AsNonNull().ChangeFocus(this); } cursorAndLayout.Invalidate(); @@ -1232,15 +1251,6 @@ protected override void OnDrag(DragEvent e) onTextSelectionChanged(doubleClickWord != null ? TextSelectionType.Word : TextSelectionType.Character, lastSelectionBounds); } - protected override bool OnDragStart(DragStartEvent e) - { - if (HasFocus) return true; - - Vector2 posDiff = e.MouseDownPosition - e.MousePosition; - - return Math.Abs(posDiff.X) > Math.Abs(posDiff.Y); - } - protected override bool OnDoubleClick(DoubleClickEvent e) { FinalizeImeComposition(true); @@ -1316,7 +1326,7 @@ protected override void OnFocusLost(FocusLostEvent e) // let's say that a focus loss is not a user event as focus is commonly indirectly lost. FinalizeImeComposition(false); - unbindInput(e.NextFocused is TextBox); + unbindInput(e.NextFocused as TextBox); updateCaretVisibility(); @@ -1336,7 +1346,7 @@ protected override bool OnClick(ClickEvent e) protected override void OnFocus(FocusEvent e) { - bindInput(e.PreviouslyFocused is TextBox); + bindInput(e.PreviouslyFocused as TextBox); updateCaretVisibility(); } @@ -1350,8 +1360,10 @@ protected override void OnFocus(FocusEvent e) /// private bool textInputBound; - private void bindInput(bool previousFocusWasTextBox) + private void bindInput([CanBeNull] TextBox previous) { + Debug.Assert(textInput != null); + if (textInputBound) { textInput.EnsureActivated(AllowIme); @@ -1361,7 +1373,7 @@ private void bindInput(bool previousFocusWasTextBox) // TextBox has special handling of text input activation when focus is changed directly from one TextBox to another. // We don't deactivate and activate, but instead keep text input active during the focus handoff, so that virtual keyboards on phones don't flicker. - if (previousFocusWasTextBox) + if (previous?.textInput == textInput) textInput.EnsureActivated(AllowIme, ScreenSpaceDrawQuad.AABBFloat); else textInput.Activate(AllowIme, ScreenSpaceDrawQuad.AABBFloat); @@ -1373,20 +1385,23 @@ private void bindInput(bool previousFocusWasTextBox) textInputBound = true; } - private void unbindInput(bool nextFocusIsTextBox) + private void unbindInput([CanBeNull] TextBox next) { if (!textInputBound) return; textInputBound = false; - // see the comment above, in `bindInput(bool)`. - if (!nextFocusIsTextBox) - textInput.Deactivate(); + if (textInput != null) + { + // see the comment above, in `bindInput(bool)`. + if (next?.textInput != textInput) + textInput.Deactivate(); - textInput.OnTextInput -= handleTextInput; - textInput.OnImeComposition -= handleImeComposition; - textInput.OnImeResult -= handleImeResult; + textInput.OnTextInput -= handleTextInput; + textInput.OnImeComposition -= handleImeComposition; + textInput.OnImeResult -= handleImeResult; + } // in case keys are held and we lose focus, we should no longer block key events textInputBlocking = false; @@ -1409,7 +1424,7 @@ private void handleTextInput(string text) => textInputScheduler.Add(t => /// Reverts the flag to false if no keys are pressed. /// private void revertBlockingStateIfRequired() => - textInputBlocking &= GetContainingInputManager().CurrentState.Keyboard.Keys.HasAnyButtonPressed; + textInputBlocking &= GetContainingInputManager()?.CurrentState.Keyboard.Keys.HasAnyButtonPressed == true; private void handleImeComposition(string composition, int selectionStart, int selectionLength) { diff --git a/osu.Framework/Graphics/Veldrid/Batches/VeldridVertexBatch.cs b/osu.Framework/Graphics/Veldrid/Batches/VeldridVertexBatch.cs index dc483145d3..674aa93c62 100644 --- a/osu.Framework/Graphics/Veldrid/Batches/VeldridVertexBatch.cs +++ b/osu.Framework/Graphics/Veldrid/Batches/VeldridVertexBatch.cs @@ -17,7 +17,7 @@ internal abstract class VeldridVertexBatch : IVertexBatch /// /// Most documentation recommends that three buffers are used to avoid contention. /// - /// We already have a triple buffer (see ) governing draw nodes. + /// We already have a triple buffer governing draw nodes. /// In theory we could set this to two, but there's also a global usage of a vertex batch in (see ). /// /// So this is for now an unfortunate memory overhead. Further work could be done to provide diff --git a/osu.Framework/Graphics/Veldrid/Buffers/VeldridFrameBuffer.cs b/osu.Framework/Graphics/Veldrid/Buffers/VeldridFrameBuffer.cs index 5481734ae9..d14c0d4822 100644 --- a/osu.Framework/Graphics/Veldrid/Buffers/VeldridFrameBuffer.cs +++ b/osu.Framework/Graphics/Veldrid/Buffers/VeldridFrameBuffer.cs @@ -47,7 +47,7 @@ public Vector2 Size } } - public VeldridFrameBuffer(VeldridRenderer renderer, PixelFormat[]? formats = null, SamplerFilter filteringMode = SamplerFilter.MinLinear_MagLinear_MipLinear) + public VeldridFrameBuffer(VeldridRenderer renderer, PixelFormat[]? formats = null, SamplerFilter filteringMode = SamplerFilter.MinLinearMagLinearMipLinear) { // todo: we probably want the arguments separated to "PixelFormat[] colorFormats, PixelFormat depthFormat". if (formats?.Length > 1) @@ -147,7 +147,7 @@ private class FrameBufferTexture : VeldridTexture { protected override TextureUsage Usages => base.Usages | TextureUsage.RenderTarget; - public FrameBufferTexture(VeldridRenderer renderer, SamplerFilter filteringMode = SamplerFilter.MinLinear_MagLinear_MipLinear) + public FrameBufferTexture(VeldridRenderer renderer, SamplerFilter filteringMode = SamplerFilter.MinLinearMagLinearMipLinear) : base(renderer, 1, 1, true, filteringMode) { BypassTextureUploadQueueing = true; diff --git a/osu.Framework/Graphics/Veldrid/Buffers/VeldridShaderStorageBufferObject.cs b/osu.Framework/Graphics/Veldrid/Buffers/VeldridShaderStorageBufferObject.cs index 8f5949ce11..25caaa4474 100644 --- a/osu.Framework/Graphics/Veldrid/Buffers/VeldridShaderStorageBufferObject.cs +++ b/osu.Framework/Graphics/Veldrid/Buffers/VeldridShaderStorageBufferObject.cs @@ -2,7 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; using System.Runtime.InteropServices; +using osu.Framework.Development; using osu.Framework.Graphics.Rendering; using Veldrid; @@ -20,6 +22,8 @@ internal class VeldridShaderStorageBufferObject : IShaderStorageBufferObj public VeldridShaderStorageBufferObject(VeldridRenderer renderer, int uboSize, int ssboSize) { + Trace.Assert(ThreadSafety.IsDrawThread); + this.renderer = renderer; elementSize = (uint)Marshal.SizeOf(default(TData)); diff --git a/osu.Framework/Graphics/Veldrid/Buffers/VeldridUniformBuffer.cs b/osu.Framework/Graphics/Veldrid/Buffers/VeldridUniformBuffer.cs index 2bd4cccf7f..2d72dd26b5 100644 --- a/osu.Framework/Graphics/Veldrid/Buffers/VeldridUniformBuffer.cs +++ b/osu.Framework/Graphics/Veldrid/Buffers/VeldridUniformBuffer.cs @@ -3,6 +3,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using osu.Framework.Development; using osu.Framework.Graphics.Rendering; using osu.Framework.Statistics; using Veldrid; @@ -37,6 +39,8 @@ internal class VeldridUniformBuffer : IUniformBuffer, IVeldridUnif public VeldridUniformBuffer(VeldridRenderer renderer) { + Trace.Assert(ThreadSafety.IsDrawThread); + this.renderer = renderer; storages.Add(new VeldridUniformBufferStorage(this.renderer)); } diff --git a/osu.Framework/Graphics/Veldrid/Pipelines/GraphicsPipeline.cs b/osu.Framework/Graphics/Veldrid/Pipelines/GraphicsPipeline.cs index c2be61aef9..76ff428298 100644 --- a/osu.Framework/Graphics/Veldrid/Pipelines/GraphicsPipeline.cs +++ b/osu.Framework/Graphics/Veldrid/Pipelines/GraphicsPipeline.cs @@ -27,8 +27,8 @@ internal class GraphicsPipeline : BasicPipeline private GraphicsPipelineDescription pipelineDesc = new GraphicsPipelineDescription { - RasterizerState = RasterizerStateDescription.CullNone, - BlendState = BlendStateDescription.SingleOverrideBlend, + RasterizerState = RasterizerStateDescription.CULL_NONE, + BlendState = BlendStateDescription.SINGLE_OVERRIDE_BLEND, ShaderSet = { VertexLayouts = new VertexLayoutDescription[1] } }; diff --git a/osu.Framework/Graphics/Veldrid/Textures/VeldridTexture.cs b/osu.Framework/Graphics/Veldrid/Textures/VeldridTexture.cs index f5b570bb4d..8e50c8f5cb 100644 --- a/osu.Framework/Graphics/Veldrid/Textures/VeldridTexture.cs +++ b/osu.Framework/Graphics/Veldrid/Textures/VeldridTexture.cs @@ -85,7 +85,7 @@ protected virtual TextureUsage Usages /// Whether manual mipmaps will be uploaded to the texture. If false, the texture will compute mipmaps automatically. /// The filtering mode. /// The colour to initialise texture levels with (in the case of sub region initial uploads). If null, no initialisation is provided out-of-the-box. - public VeldridTexture(IVeldridRenderer renderer, int width, int height, bool manualMipmaps = false, SamplerFilter filteringMode = SamplerFilter.MinLinear_MagLinear_MipLinear, + public VeldridTexture(IVeldridRenderer renderer, int width, int height, bool manualMipmaps = false, SamplerFilter filteringMode = SamplerFilter.MinLinearMagLinearMipLinear, Color4? initialisationColour = null) { this.manualMipmaps = manualMipmaps; @@ -458,7 +458,7 @@ protected virtual void DoUpload(ITextureUpload upload) { texture?.Dispose(); - var textureDescription = TextureDescription.Texture2D((uint)Width, (uint)Height, (uint)CalculateMipmapLevels(Width, Height), 1, PixelFormat.R8_G8_B8_A8_UNorm, Usages); + var textureDescription = TextureDescription.Texture2D((uint)Width, (uint)Height, (uint)CalculateMipmapLevels(Width, Height), 1, PixelFormat.R8G8B8A8UNorm, Usages); texture = Renderer.Factory.CreateTexture(ref textureDescription); // todo: we may want to look into not having to allocate chunks of zero byte region for initialising textures diff --git a/osu.Framework/Graphics/Veldrid/Textures/VeldridVideoTexture.cs b/osu.Framework/Graphics/Veldrid/Textures/VeldridVideoTexture.cs index d2d3d4b076..0243cdb675 100644 --- a/osu.Framework/Graphics/Veldrid/Textures/VeldridVideoTexture.cs +++ b/osu.Framework/Graphics/Veldrid/Textures/VeldridVideoTexture.cs @@ -48,13 +48,13 @@ protected override void DoUpload(ITextureUpload upload) resourceList[i] = new VeldridTextureResources ( - Renderer.Factory.CreateTexture(TextureDescription.Texture2D((uint)width, (uint)height, 1, 1, PixelFormat.R8_UNorm, Usages)), + Renderer.Factory.CreateTexture(TextureDescription.Texture2D((uint)width, (uint)height, 1, 1, PixelFormat.R8UNorm, Usages)), Renderer.Factory.CreateSampler(new SamplerDescription { AddressModeU = SamplerAddressMode.Clamp, AddressModeV = SamplerAddressMode.Clamp, AddressModeW = SamplerAddressMode.Clamp, - Filter = SamplerFilter.MinLinear_MagLinear_MipLinear, + Filter = SamplerFilter.MinLinearMagLinearMipLinear, MinimumLod = 0, MaximumLod = IRenderer.MAX_MIPMAP_LEVELS, MaximumAnisotropy = 0, diff --git a/osu.Framework/Graphics/Veldrid/VeldridDevice.cs b/osu.Framework/Graphics/Veldrid/VeldridDevice.cs index 1fda23746c..bbb5d1de48 100644 --- a/osu.Framework/Graphics/Veldrid/VeldridDevice.cs +++ b/osu.Framework/Graphics/Veldrid/VeldridDevice.cs @@ -14,7 +14,7 @@ using SixLabors.ImageSharp.Processing; using Veldrid; using Veldrid.OpenGL; -using Veldrid.OpenGLBinding; +using Veldrid.OpenGLBindings; namespace osu.Framework.Graphics.Veldrid { @@ -106,7 +106,7 @@ public VeldridDevice(IGraphicsSurface graphicsSurface) var options = new GraphicsDeviceOptions { HasMainSwapchain = true, - SwapchainDepthFormat = PixelFormat.R16_UNorm, + SwapchainDepthFormat = PixelFormat.R16UNorm, SyncToVerticalBlank = true, ResourceBindingModel = ResourceBindingModel.Improved, }; @@ -162,8 +162,15 @@ public VeldridDevice(IGraphicsSurface graphicsSurface) { var linuxGraphics = (ILinuxGraphicsSurface)this.graphicsSurface; swapchain.Source = linuxGraphics.IsWayland - ? SwapchainSource.CreateWayland(this.graphicsSurface.DisplayHandle, this.graphicsSurface.WindowHandle) - : SwapchainSource.CreateXlib(this.graphicsSurface.DisplayHandle, this.graphicsSurface.WindowHandle); + ? SwapchainSource.CreateWayland(linuxGraphics.DisplayHandle, this.graphicsSurface.WindowHandle) + : SwapchainSource.CreateXlib(linuxGraphics.DisplayHandle, this.graphicsSurface.WindowHandle); + break; + } + + case RuntimeInfo.Platform.Android: + { + var androidGraphics = (IAndroidGraphicsSurface)this.graphicsSurface; + swapchain.Source = SwapchainSource.CreateAndroidSurface(androidGraphics.SurfaceHandle, androidGraphics.JniEnvHandle); break; } } diff --git a/osu.Framework/Graphics/Veldrid/VeldridExtensions.cs b/osu.Framework/Graphics/Veldrid/VeldridExtensions.cs index 0ef17a07da..272452a14d 100644 --- a/osu.Framework/Graphics/Veldrid/VeldridExtensions.cs +++ b/osu.Framework/Graphics/Veldrid/VeldridExtensions.cs @@ -14,15 +14,13 @@ using SharpGen.Runtime; using Veldrid; using Veldrid.MetalBindings; -using Veldrid.OpenGLBinding; +using Veldrid.OpenGLBindings; using Vortice.Direct3D11; using Vortice.DXGI; using Vulkan; -using GetPName = Veldrid.OpenGLBinding.GetPName; using GraphicsBackend = Veldrid.GraphicsBackend; using PrimitiveTopology = Veldrid.PrimitiveTopology; using StencilOperation = Veldrid.StencilOperation; -using StringName = Veldrid.OpenGLBinding.StringName; using VertexAttribPointerType = osuTK.Graphics.ES30.VertexAttribPointerType; namespace osu.Framework.Graphics.Veldrid @@ -125,19 +123,19 @@ public static PixelFormat[] ToPixelFormats(this RenderBufferFormat[] renderBuffe switch (renderBufferFormats[i]) { case RenderBufferFormat.D16: - pixelFormats[i] = PixelFormat.R16_UNorm; + pixelFormats[i] = PixelFormat.R16UNorm; break; case RenderBufferFormat.D32: - pixelFormats[i] = PixelFormat.R32_Float; + pixelFormats[i] = PixelFormat.R32Float; break; case RenderBufferFormat.D24S8: - pixelFormats[i] = PixelFormat.D24_UNorm_S8_UInt; + pixelFormats[i] = PixelFormat.D24UNormS8UInt; break; case RenderBufferFormat.D32S8: - pixelFormats[i] = PixelFormat.D32_Float_S8_UInt; + pixelFormats[i] = PixelFormat.D32FloatS8UInt; break; default: @@ -153,10 +151,10 @@ public static SamplerFilter ToSamplerFilter(this TextureFilteringMode mode) switch (mode) { case TextureFilteringMode.Linear: - return SamplerFilter.MinLinear_MagLinear_MipLinear; + return SamplerFilter.MinLinearMagLinearMipLinear; case TextureFilteringMode.Nearest: - return SamplerFilter.MinPoint_MagPoint_MipPoint; + return SamplerFilter.MinPointMagPointMipPoint; default: throw new ArgumentOutOfRangeException(nameof(mode)); diff --git a/osu.Framework/Graphics/Veldrid/VeldridStagingResourcePool.cs b/osu.Framework/Graphics/Veldrid/VeldridStagingResourcePool.cs index f36486a10a..afe09382b1 100644 --- a/osu.Framework/Graphics/Veldrid/VeldridStagingResourcePool.cs +++ b/osu.Framework/Graphics/Veldrid/VeldridStagingResourcePool.cs @@ -32,7 +32,10 @@ protected VeldridStagingResourcePool(GraphicsPipeline pipeline, string name) pipeline.ExecutionFinished += executionFinished; } - protected bool TryGet(Predicate match, [NotNullWhen(true)] out T? resource) + protected bool TryGet([NotNullWhen(true)] out T? resource) + => TryGet(static (_, _) => true, null, out resource); + + protected bool TryGet(Func match, TState? state, [NotNullWhen(true)] out T? resource) { // Reverse iteration is important to prefer reusing recently returned textures. // This avoids the case of a large pool being constantly cycled and therefore never @@ -41,7 +44,7 @@ protected bool TryGet(Predicate match, [NotNullWhen(true)] out T? resource) { var existing = available[i]; - if (match(existing.Resource)) + if (match(existing.Resource, state)) { existing.FrameUsageIndex = currentExecutionIndex; diff --git a/osu.Framework/Graphics/Veldrid/VeldridStagingTexturePool.cs b/osu.Framework/Graphics/Veldrid/VeldridStagingTexturePool.cs index 889e16ba73..90cbd28f59 100644 --- a/osu.Framework/Graphics/Veldrid/VeldridStagingTexturePool.cs +++ b/osu.Framework/Graphics/Veldrid/VeldridStagingTexturePool.cs @@ -15,12 +15,17 @@ public VeldridStagingTexturePool(GraphicsPipeline pipeline) public Texture Get(int width, int height, PixelFormat format) { - if (TryGet(t => t.Width >= width && t.Height >= height && t.Format == format, out var texture)) + if (TryGet(match, new TextureLookup(width, height, format), out var texture)) return texture; texture = Pipeline.Factory.CreateTexture(TextureDescription.Texture2D((uint)width, (uint)height, 1, 1, format, TextureUsage.Staging)); AddNewResource(texture); return texture; } + + private static bool match(Texture texture, TextureLookup lookup) + => texture.Width >= lookup.Width && texture.Height >= lookup.Height && texture.Format == lookup.Format; + + private readonly record struct TextureLookup(int Width, int Height, PixelFormat Format); } } diff --git a/osu.Framework/Graphics/Video/FFmpegFuncs.cs b/osu.Framework/Graphics/Video/FFmpegFuncs.cs index 91821e44bb..ccdc8de6ad 100644 --- a/osu.Framework/Graphics/Video/FFmpegFuncs.cs +++ b/osu.Framework/Graphics/Video/FFmpegFuncs.cs @@ -5,6 +5,7 @@ using System.Runtime.InteropServices; using FFmpeg.AutoGen; +using JetBrains.Annotations; // ReSharper disable InconsistentNaming #pragma warning disable IDE1006 // Naming style @@ -15,6 +16,10 @@ public unsafe class FFmpegFuncs { #region Delegates + public delegate int AvDictSetDelegate(AVDictionary** pm, [MarshalAs(UnmanagedType.LPUTF8Str)] string key, [MarshalAs(UnmanagedType.LPUTF8Str)] string value, int flags); + + public delegate void AvDictFreeDelegate(AVDictionary** m); + public delegate AVFrame* AvFrameAllocDelegate(); public delegate void AvFrameFreeDelegate(AVFrame** frame); @@ -89,6 +94,12 @@ public unsafe class FFmpegFuncs #endregion + [CanBeNull] + public AvDictSetDelegate av_dict_set; + + [CanBeNull] + public AvDictFreeDelegate av_dict_free; + public AvFrameAllocDelegate av_frame_alloc; public AvFrameFreeDelegate av_frame_free; public AvFrameUnrefDelegate av_frame_unref; diff --git a/osu.Framework/Graphics/Video/VideoDecoder.cs b/osu.Framework/Graphics/Video/VideoDecoder.cs index 60fd958fb8..62c87389ff 100644 --- a/osu.Framework/Graphics/Video/VideoDecoder.cs +++ b/osu.Framework/Graphics/Video/VideoDecoder.cs @@ -349,7 +349,12 @@ private void prepareDecoding() formatContext->pb = ioContext; formatContext->flags |= FFmpegFuncs.AVFMT_FLAG_GENPTS; // required for most HW decoders as they only read `pts` - int openInputResult = ffmpeg.avformat_open_input(&fcPtr, "pipe:", null, null); + AVDictionary* options = null; + // see https://github.com/ppy/osu/issues/13696 for reasoning + ffmpeg.av_dict_set?.Invoke(&options, "ignore_editlist", "1", 0); + int openInputResult = ffmpeg.avformat_open_input(&fcPtr, "pipe:", null, &options); + ffmpeg.av_dict_free?.Invoke(&options); + inputOpened = openInputResult >= 0; if (!inputOpened) throw new InvalidOperationException($"Error opening file or stream: {getErrorMessage(openInputResult)}"); @@ -842,6 +847,8 @@ protected virtual FFmpegFuncs CreateFuncs() return new FFmpegFuncs { + av_dict_set = FFmpeg.AutoGen.ffmpeg.av_dict_set, + av_dict_free = FFmpeg.AutoGen.ffmpeg.av_dict_free, av_frame_alloc = FFmpeg.AutoGen.ffmpeg.av_frame_alloc, av_frame_free = FFmpeg.AutoGen.ffmpeg.av_frame_free, av_frame_unref = FFmpeg.AutoGen.ffmpeg.av_frame_unref, diff --git a/osu.Framework/Graphics/Visualisation/DrawVisualiser.cs b/osu.Framework/Graphics/Visualisation/DrawVisualiser.cs index c66615f740..df0a4810fb 100644 --- a/osu.Framework/Graphics/Visualisation/DrawVisualiser.cs +++ b/osu.Framework/Graphics/Visualisation/DrawVisualiser.cs @@ -207,6 +207,8 @@ protected override void Update() overlay.Target = Searching ? cursorTarget : inputManager.HoveredDrawables.OfType().FirstOrDefault()?.Target; } + private static readonly Dictionary is_type_valid_target_cache = new Dictionary(); + private void updateCursorTarget() { Drawable drawableTarget = null; @@ -220,6 +222,14 @@ private void updateCursorTarget() // Finds the targeted drawable and composite drawable. The search stops if a drawable is targeted. void findTarget(Drawable drawable) { + // Ignore proxied drawables (they may be at a different visual layer). + if (drawable.HasProxy) + return; + + // When a proxy is encountered, restore the original drawable for target testing. + while (drawable.IsProxy) + drawable = drawable.Original; + if (drawable == this || drawable is Component) return; @@ -268,30 +278,32 @@ void findTarget(Drawable drawable) if (!validForTarget(drawable)) return; - // Special case for full-screen overlays that act as input receptors, but don't display anything - if (!hasCustomDrawNode(drawable)) - return; - drawableTarget = drawable; } } // Valid if the drawable contains the mouse position and the position wouldn't be masked by the parent bool validForTarget(Drawable drawable) - => drawable.ScreenSpaceDrawQuad.Contains(inputManager.CurrentState.Mouse.Position) - && maskingQuad?.Contains(inputManager.CurrentState.Mouse.Position) != false; - } + { + if (!drawable.ScreenSpaceDrawQuad.Contains(inputManager.CurrentState.Mouse.Position) + || maskingQuad?.Contains(inputManager.CurrentState.Mouse.Position) == false) + { + return false; + } - private static readonly Dictionary has_custom_drawnode_cache = new Dictionary(); + Type type = drawable.GetType(); - private bool hasCustomDrawNode(Drawable drawable) - { - var type = drawable.GetType(); + if (is_type_valid_target_cache.TryGetValue(type, out bool valid)) + return valid; - if (has_custom_drawnode_cache.TryGetValue(type, out bool existing)) - return existing; + // Exclude "overlay" objects (Component/etc) that don't draw anything and don't override CreateDrawNode(). + valid = type.GetMethod(nameof(CreateDrawNode), BindingFlags.Instance | BindingFlags.NonPublic)?.DeclaringType != typeof(Drawable); + + // Exclude objects that specify they should be hidden anyway. + valid &= !type.GetCustomAttributes(true).Any(); - return has_custom_drawnode_cache[type] = type.GetMethod(nameof(CreateDrawNode), BindingFlags.Instance | BindingFlags.NonPublic)?.DeclaringType != typeof(Drawable); + return is_type_valid_target_cache[type] = valid; + } } public bool Searching { get; private set; } diff --git a/osu.Framework/Graphics/Visualisation/DrawVisualiserHiddenAttribute.cs b/osu.Framework/Graphics/Visualisation/DrawVisualiserHiddenAttribute.cs new file mode 100644 index 0000000000..c5c62d683c --- /dev/null +++ b/osu.Framework/Graphics/Visualisation/DrawVisualiserHiddenAttribute.cs @@ -0,0 +1,13 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +namespace osu.Framework.Graphics.Visualisation +{ + /// + /// Indicates that instances of this type or any subtype should not be valid targets for the draw visualiser. + /// + [AttributeUsage(AttributeTargets.Class)] + public class DrawVisualiserHiddenAttribute : Attribute; +} diff --git a/osu.Framework/HostOptions.cs b/osu.Framework/HostOptions.cs index 6637713bbb..c6e7d4dc2f 100644 --- a/osu.Framework/HostOptions.cs +++ b/osu.Framework/HostOptions.cs @@ -33,5 +33,14 @@ public class HostOptions /// If the SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR environment variable is set, this property will have no effect. /// public bool BypassCompositor { get; set; } = true; + + /// + /// The friendly name of the game to be hosted. This is used to display the name to the user, + /// for example in the window title bar or in OS windows and prompts. + /// + /// + /// If empty, GameHost will choose a default name based on the gameName. + /// + public string FriendlyGameName { get; set; } = string.Empty; } } diff --git a/osu.Framework/IO/Network/WebRequest.cs b/osu.Framework/IO/Network/WebRequest.cs index dccca3ee38..e3d5a91b48 100644 --- a/osu.Framework/IO/Network/WebRequest.cs +++ b/osu.Framework/IO/Network/WebRequest.cs @@ -23,13 +23,15 @@ namespace osu.Framework.IO.Network { public class WebRequest : IDisposable { + public const int DEFAULT_TIMEOUT = 10000; + internal const int MAX_RETRIES = 1; - /// - /// Whether non-SSL requests should be allowed. Defaults to disabled. - /// In the default state, http:// requests will be automatically converted to https://. - /// - public bool AllowInsecureRequests; + private const int buffer_size = 32768; + private const string form_boundary = "-----------------------------28947758029299"; + private const string form_content_type = "multipart/form-data; boundary=" + form_boundary; + + private static readonly Logger logger = Logger.GetLogger(LoggingTarget.Network); /// /// Invoked when a response has been received, but not data has been received. @@ -61,79 +63,68 @@ public class WebRequest : IDisposable /// public bool Aborted { get; private set; } - private bool completed; - /// - /// Whether the has been run. + /// The response stream. /// - public bool Completed - { - get => completed; - private set - { - completed = value; - if (!completed) return; + public Stream ResponseStream { get; private set; } - // WebRequests can only be used once - no need to keep events bound - // This helps with disposal in PerformAsync usages - Started = null; - Finished = null; - Failed = null; - DownloadProgress = null; - UploadProgress = null; - } - } + public HttpResponseHeaders ResponseHeaders => response.Headers; /// /// The URL of this request. /// public string Url; + public HttpMethod Method = HttpMethod.Get; + /// - /// Query string parameters. + /// The amount of time from last sent or received data to trigger a timeout and abort the request. /// - private readonly Dictionary queryParameters = new Dictionary(); + public int Timeout = DEFAULT_TIMEOUT; /// - /// Form parameters. + /// Whether this request should internally retry (up to times) on a timeout before throwing an exception. /// - private readonly Dictionary formParameters = new Dictionary(); + public bool AllowRetryOnTimeout = true; /// - /// FILE parameters. + /// The content type when POST content is provided. /// - private readonly IDictionary files = new Dictionary(); + public string ContentType; /// - /// The request headers. + /// The type of content expected by this web request. /// - private readonly IDictionary headers = new Dictionary(); + protected virtual string Accept => string.Empty; - public const int DEFAULT_TIMEOUT = 10000; + /// + /// The value of the User-agent HTTP header. + /// + protected virtual string UserAgent => "osu-framework"; - public HttpMethod Method = HttpMethod.Get; + internal int RetryCount { get; private set; } + + private long contentLength => requestStream?.Length ?? 0; /// - /// The amount of time from last sent or received data to trigger a timeout and abort the request. + /// Query string parameters. /// - public int Timeout = DEFAULT_TIMEOUT; + private readonly Dictionary queryParameters = new Dictionary(); /// - /// The type of content expected by this web request. + /// Form parameters. /// - protected virtual string Accept => string.Empty; + private readonly Dictionary formParameters = new Dictionary(); /// - /// The value of the User-agent HTTP header. + /// FILE parameters. /// - protected virtual string UserAgent => "osu-framework"; - - internal int RetryCount { get; private set; } + private readonly List files = new List(); /// - /// Whether this request should internally retry (up to times) on a timeout before throwing an exception. + /// The request headers. /// - public bool AllowRetryOnTimeout { get; set; } = true; + private readonly IDictionary headers = new Dictionary(); private CancellationToken? userToken; private CancellationTokenSource abortToken; @@ -142,11 +133,11 @@ private set private LengthTrackingStream requestStream; private HttpResponseMessage response; - private long contentLength => requestStream?.Length ?? 0; - - private const string form_boundary = "-----------------------------28947758029299"; - - private const string form_content_type = "multipart/form-data; boundary=" + form_boundary; + private MemoryStream rawContent; + private int responseBytesRead; + private byte[] buffer; + private bool? allowInsecureRequests; + private bool completed; private static readonly HttpClient client = new HttpClient( // SocketsHttpHandler causes crash in Android Debug, and seems to have compatibility issue on SSL @@ -171,26 +162,45 @@ private set Timeout = System.Threading.Timeout.InfiniteTimeSpan }; - private static readonly Logger logger = Logger.GetLogger(LoggingTarget.Network); - public WebRequest(string url = null, params object[] args) { if (!string.IsNullOrEmpty(url)) Url = args.Length == 0 ? url : string.Format(url, args); } - private int responseBytesRead; - - private const int buffer_size = 32768; - private byte[] buffer; - - private MemoryStream rawContent; - - public string ContentType; + /// + /// Whether non-SSL requests should be allowed. Defaults to disabled. + /// In the default state, http:// requests will be automatically converted to https://. + /// + /// + /// Setting this overrides the OSU_INSECURE_REQUESTS environment variable. + /// + public bool AllowInsecureRequests + { + get => allowInsecureRequests ?? FrameworkEnvironment.AllowInsecureRequests; + set => allowInsecureRequests = value; + } - protected virtual Stream CreateOutputStream() => new MemoryStream(); + /// + /// Whether the has been run. + /// + public bool Completed + { + get => completed; + private set + { + completed = value; + if (!completed) return; - public Stream ResponseStream; + // WebRequests can only be used once - no need to keep events bound + // This helps with disposal in PerformAsync usages + Started = null; + Finished = null; + Failed = null; + DownloadProgress = null; + UploadProgress = null; + } + } /// /// Retrieve the full response body as a UTF8 encoded string. @@ -242,7 +252,7 @@ public byte[] GetResponseData() } } - public HttpResponseHeaders ResponseHeaders => response.Headers; + protected virtual Stream CreateOutputStream() => new MemoryStream(); /// /// Performs the request asynchronously. @@ -275,7 +285,7 @@ private async Task internalPerform(CancellationToken cancellationToken = default if (!AllowInsecureRequests && !url.StartsWith(@"https://", StringComparison.Ordinal)) { logger.Add($"Insecure request was automatically converted to https ({Url})"); - url = @"https://" + url.Replace(@"http://", @""); + url = "https://" + url.Replace("http://", string.Empty); } // If a user token already exists, keep it. Otherwise, take on the previous user token, as this could be a retry of the request. @@ -339,9 +349,9 @@ private async Task internalPerform(CancellationToken cancellationToken = default foreach (var p in files) { - var byteContent = new ByteArrayContent(p.Value); + var byteContent = new ByteArrayContent(p.Content); byteContent.Headers.Add("Content-Type", "application/octet-stream"); - formData.Add(byteContent, p.Key, p.Key); + formData.Add(byteContent, p.ParamName, p.Filename); } postContent = await formData.ReadAsStreamAsync(linkedToken.Token).ConfigureAwait(false); @@ -652,17 +662,21 @@ public void AddRaw(Stream stream) } /// - /// Add a new FILE parameter to this request. Replaces any existing file with the same name. + /// Add a new FILE parameter to this request. /// This may not be used in conjunction with . GET requests may not contain files. /// - /// The name of the file. This becomes the name of the file in a multi-part form POST content. + /// The name of the form parameter of the request that the file relates to. /// The file data. - public void AddFile(string name, byte[] data) + /// + /// The filename of the file to be sent to be reported to the server in the Content-Disposition header. + /// blob is used by default if omitted, to mirror browser behaviour. + /// + public void AddFile(string paramName, byte[] data, string filename = "blob") { - ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(paramName); ArgumentNullException.ThrowIfNull(data); - files[name] = data; + files.Add(new FormFile(paramName, data, filename)); } /// @@ -921,5 +935,7 @@ protected override void Dispose(bool disposing) baseStream.Dispose(); } } + + private record struct FormFile(string ParamName, byte[] Content, string Filename); } } diff --git a/osu.Framework/IO/Stores/OnlineStore.cs b/osu.Framework/IO/Stores/OnlineStore.cs index b1421acef5..757e14ce01 100644 --- a/osu.Framework/IO/Stores/OnlineStore.cs +++ b/osu.Framework/IO/Stores/OnlineStore.cs @@ -17,6 +17,9 @@ public class OnlineStore : IResourceStore { public async Task GetAsync(string url, CancellationToken cancellationToken = default) { + if (!validateScheme(url)) + return null; + this.LogIfNonBackgroundThread(url); try @@ -35,7 +38,7 @@ public async Task GetAsync(string url, CancellationToken cancellationTok public virtual byte[] Get(string url) { - if (!url.StartsWith(@"https://", StringComparison.Ordinal)) + if (!validateScheme(url)) return null; this.LogIfNonBackgroundThread(url); @@ -65,6 +68,14 @@ public Stream GetStream(string url) public IEnumerable GetAvailableResources() => Enumerable.Empty(); + private bool validateScheme(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out Uri uri)) + return false; + + return uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps; + } + #region IDisposable Support public void Dispose() diff --git a/osu.Framework/Input/Bindings/KeyBindingContainer.cs b/osu.Framework/Input/Bindings/KeyBindingContainer.cs index 39c1a0fe7c..5f0149ded2 100644 --- a/osu.Framework/Input/Bindings/KeyBindingContainer.cs +++ b/osu.Framework/Input/Bindings/KeyBindingContainer.cs @@ -224,7 +224,7 @@ private bool handleNewPressed(InputState state, InputKey newKey, Vector2? scroll if (pressedBindings.Contains(binding)) continue; - if (binding.KeyCombination.IsPressed(pressedCombination, matchingMode)) + if (binding.KeyCombination.IsPressed(pressedCombination, state, matchingMode)) newlyPressed.Add(binding); } } @@ -251,7 +251,7 @@ private bool handleNewPressed(InputState state, InputKey newKey, Vector2? scroll if (simultaneousMode == SimultaneousBindingMode.None && (matchingMode == KeyCombinationMatchingMode.Exact || matchingMode == KeyCombinationMatchingMode.Modifiers)) { // only want to release pressed actions if no existing bindings would still remain pressed - if (pressedBindings.Count > 0 && !pressedBindings.Any(m => m.KeyCombination.IsPressed(pressedCombination, matchingMode))) + if (pressedBindings.Count > 0 && !pressedBindings.Any(m => m.KeyCombination.IsPressed(pressedCombination, state, matchingMode))) releasePressedActions(state); } @@ -365,7 +365,7 @@ private void handleNewReleased(InputState state, InputKey releasedKey) { var binding = pressedBindings[i]; - if (pressedInputKeys.Count == 0 || !binding.KeyCombination.IsPressed(pressedCombination, KeyCombinationMatchingMode.Any)) + if (pressedInputKeys.Count == 0 || !binding.KeyCombination.IsPressed(pressedCombination, state, KeyCombinationMatchingMode.Any)) { pressedBindings.RemoveAt(i--); PropagateReleased(getInputQueue(binding).Where(d => d.IsRootedAt(this)), state, binding.GetAction()); diff --git a/osu.Framework/Input/Bindings/KeyCombination.cs b/osu.Framework/Input/Bindings/KeyCombination.cs index 489ca886ab..f2024456f9 100644 --- a/osu.Framework/Input/Bindings/KeyCombination.cs +++ b/osu.Framework/Input/Bindings/KeyCombination.cs @@ -7,6 +7,7 @@ using System.Diagnostics; using System.Linq; using System.Runtime.CompilerServices; +using osu.Framework.Extensions; using osu.Framework.Input.States; using osuTK; using osuTK.Input; @@ -91,54 +92,84 @@ private KeyCombination(ImmutableArray keys) /// Check whether the provided pressed keys are valid for this . /// /// The potential pressed keys for this . + /// The current input state. /// The method for handling exact key matches. /// Whether the pressedKeys keys are valid. - public bool IsPressed(KeyCombination pressedKeys, KeyCombinationMatchingMode matchingMode) + public bool IsPressed(KeyCombination pressedKeys, InputState inputState, KeyCombinationMatchingMode matchingMode) { Debug.Assert(!pressedKeys.Keys.Contains(InputKey.None)); // Having None in pressed keys will break IsPressed if (Keys == pressedKeys.Keys) // Fast test for reference equality of underlying array return true; + if (Keys.SequenceEqual(none)) + return false; + return ContainsAll(Keys, pressedKeys.Keys, matchingMode); } + private static InputKey? getVirtualKey(InputKey key) + { + switch (key) + { + case InputKey.LShift: + case InputKey.RShift: + return InputKey.Shift; + + case InputKey.LControl: + case InputKey.RControl: + return InputKey.Control; + + case InputKey.LAlt: + case InputKey.RAlt: + return InputKey.Alt; + + case InputKey.LSuper: + case InputKey.RSuper: + return InputKey.Super; + } + + return null; + } + /// /// Check whether the provided set of pressed keys matches the candidate binding. /// - /// The candidate key binding to match against. - /// The keys which have been pressed by a user. + /// The candidate key binding to match against. + /// The keys which have been pressed by a user. /// The matching mode to be used when checking. /// Whether this is a match. [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static bool ContainsAll(ImmutableArray candidateKey, ImmutableArray pressedKey, KeyCombinationMatchingMode matchingMode) + internal static bool ContainsAll(ImmutableArray candidateKeyBinding, ImmutableArray pressedPhysicalKeys, KeyCombinationMatchingMode matchingMode) { + Debug.Assert(pressedPhysicalKeys.All(k => k.IsPhysical())); + // first, check that all the candidate keys are contained in the provided pressed keys. // regardless of the matching mode, every key needs to at least be present (matching modes only change // the behaviour of excess keys). - foreach (var key in candidateKey) + foreach (var key in candidateKeyBinding) { - if (!ContainsKey(pressedKey, key)) + if (!IsPressed(pressedPhysicalKeys, key)) return false; } switch (matchingMode) { case KeyCombinationMatchingMode.Exact: - foreach (var key in pressedKey) + foreach (var key in pressedPhysicalKeys) { // in exact matching mode, every pressed key needs to be in the candidate. - if (!ContainsKeyPermissive(candidateKey, key)) + if (!KeyBindingContains(candidateKeyBinding, key)) return false; } break; case KeyCombinationMatchingMode.Modifiers: - foreach (var key in pressedKey) + foreach (var key in pressedPhysicalKeys) { // in modifiers match mode, the same check applies as exact but only for modifier keys. - if (IsModifierKey(key) && !ContainsKeyPermissive(candidateKey, key)) + if (IsModifierKey(key) && !KeyBindingContains(candidateKeyBinding, key)) return false; } @@ -154,84 +185,36 @@ internal static bool ContainsAll(ImmutableArray candidateKey, Immutabl /// /// Check whether the provided key is part of the candidate binding. - /// This will match bidirectionally for modifier keys (LShift and Shift being present in both of the two parameters in either order will return true). /// - /// The candidate key binding to match against. - /// The key which has been pressed by a user. + /// The candidate key binding to match against. + /// The physical key that has been pressed. /// Whether this is a match. - internal static bool ContainsKeyPermissive(ImmutableArray candidate, InputKey key) + internal static bool KeyBindingContains(ImmutableArray candidateKeyBinding, InputKey physicalKey) { - switch (key) - { - case InputKey.LControl: - case InputKey.RControl: - if (candidate.Contains(InputKey.Control)) - return true; - - break; - - case InputKey.LShift: - case InputKey.RShift: - if (candidate.Contains(InputKey.Shift)) - return true; - - break; - - case InputKey.RAlt: - case InputKey.LAlt: - if (candidate.Contains(InputKey.Alt)) - return true; - - break; - - case InputKey.LSuper: - case InputKey.RSuper: - if (candidate.Contains(InputKey.Super)) - return true; - - break; - } - - return ContainsKey(candidate, key); + return candidateKeyBinding.Contains(physicalKey) || + (getVirtualKey(physicalKey) is InputKey vKey && candidateKeyBinding.Contains(vKey)); } /// - /// Check whether a single key from a candidate binding is relevant to the currently pressed keys. - /// If the contains a left/right specific modifier, the must also for this to match. + /// Check whether a single physical or virtual key from a candidate binding is relevant to the currently pressed keys. /// - /// The candidate key binding to match against. - /// The key which has been pressed by a user. + /// The currently pressed keys to match against. + /// The candidate key to check. /// Whether this is a match. - internal static bool ContainsKey(ImmutableArray candidate, InputKey key) + internal static bool IsPressed(ImmutableArray pressedPhysicalKeys, InputKey candidateKey) { - switch (key) - { - case InputKey.Control: - if (candidate.Contains(InputKey.LControl) || candidate.Contains(InputKey.RControl)) - return true; + if (candidateKey.IsPhysical()) + return pressedPhysicalKeys.Contains(candidateKey); - break; - - case InputKey.Shift: - if (candidate.Contains(InputKey.LShift) || candidate.Contains(InputKey.RShift)) - return true; - - break; - - case InputKey.Alt: - if (candidate.Contains(InputKey.LAlt) || candidate.Contains(InputKey.RAlt)) - return true; + Debug.Assert(candidateKey.IsVirtual()); - break; - - case InputKey.Super: - if (candidate.Contains(InputKey.LSuper) || candidate.Contains(InputKey.RSuper)) - return true; - - break; + foreach (var pk in pressedPhysicalKeys) + { + if (getVirtualKey(pk) == candidateKey) + return true; } - return candidate.Contains(key); + return false; } public bool Equals(KeyCombination other) => Keys.SequenceEqual(other.Keys); diff --git a/osu.Framework/Input/Handlers/Joystick/JoystickHandler.cs b/osu.Framework/Input/Handlers/Joystick/JoystickHandler.cs index 0a0ca30b13..2547ec475f 100644 --- a/osu.Framework/Input/Handlers/Joystick/JoystickHandler.cs +++ b/osu.Framework/Input/Handlers/Joystick/JoystickHandler.cs @@ -29,7 +29,7 @@ public override bool Initialize(GameHost host) if (!base.Initialize(host)) return false; - if (!(host.Window is SDL3Window window)) + if (!(host.Window is ISDLWindow window)) return false; Enabled.BindValueChanged(e => diff --git a/osu.Framework/Input/Handlers/Keyboard/KeyboardHandler.cs b/osu.Framework/Input/Handlers/Keyboard/KeyboardHandler.cs index afe149b698..7b8afb4da2 100644 --- a/osu.Framework/Input/Handlers/Keyboard/KeyboardHandler.cs +++ b/osu.Framework/Input/Handlers/Keyboard/KeyboardHandler.cs @@ -21,7 +21,7 @@ public override bool Initialize(GameHost host) if (!base.Initialize(host)) return false; - if (!(host.Window is SDL3Window window)) + if (!(host.Window is ISDLWindow window)) return false; Enabled.BindValueChanged(e => diff --git a/osu.Framework/Input/Handlers/Mouse/MouseHandler.cs b/osu.Framework/Input/Handlers/Mouse/MouseHandler.cs index 7150404e0d..aa480152c3 100644 --- a/osu.Framework/Input/Handlers/Mouse/MouseHandler.cs +++ b/osu.Framework/Input/Handlers/Mouse/MouseHandler.cs @@ -15,7 +15,7 @@ namespace osu.Framework.Input.Handlers.Mouse { /// - /// Handles mouse events from an . + /// Handles mouse events from an . /// Will use relative mouse mode where possible. /// public class MouseHandler : InputHandler, IHasCursorSensitivity, INeedsMousePositionFeedback @@ -41,7 +41,7 @@ public class MouseHandler : InputHandler, IHasCursorSensitivity, INeedsMousePosi public override bool IsActive => true; - private SDL3Window window; + private ISDLWindow window; private Vector2? lastPosition; @@ -76,7 +76,7 @@ public override bool Initialize(GameHost host) if (!base.Initialize(host)) return false; - if (!(host.Window is SDL3Window desktopWindow)) + if (!(host.Window is ISDLWindow desktopWindow)) return false; window = desktopWindow; diff --git a/osu.Framework/Input/Handlers/Touch/TouchHandler.cs b/osu.Framework/Input/Handlers/Touch/TouchHandler.cs index 372f802729..ff0377b8c8 100644 --- a/osu.Framework/Input/Handlers/Touch/TouchHandler.cs +++ b/osu.Framework/Input/Handlers/Touch/TouchHandler.cs @@ -18,7 +18,7 @@ public override bool Initialize(GameHost host) if (!base.Initialize(host)) return false; - if (!(host.Window is SDL3Window window)) + if (!(host.Window is ISDLWindow window)) return false; Enabled.BindValueChanged(enabled => diff --git a/osu.Framework/Input/IFocusManager.cs b/osu.Framework/Input/IFocusManager.cs new file mode 100644 index 0000000000..f10df4e38d --- /dev/null +++ b/osu.Framework/Input/IFocusManager.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; + +namespace osu.Framework.Input +{ + public interface IFocusManager : IDrawable + { + /// + /// Reset current focused drawable to the top-most drawable which is . + /// + /// The source which triggered this event. + void TriggerFocusContention(Drawable? triggerSource); + + /// + /// Changes the currently-focused drawable. First checks that is in a valid state to receive focus, + /// then unfocuses the current and focuses . + /// can be null to reset focus. + /// If the given drawable is already focused, nothing happens and no events are fired. + /// + /// The drawable to become focused. + /// True if the given drawable is now focused (or focus is dropped in the case of a null target). + bool ChangeFocus(Drawable? potentialFocusTarget); + } +} diff --git a/osu.Framework/Input/InputManager.cs b/osu.Framework/Input/InputManager.cs index 5fb0c3ac4b..debc7cef14 100644 --- a/osu.Framework/Input/InputManager.cs +++ b/osu.Framework/Input/InputManager.cs @@ -31,7 +31,7 @@ namespace osu.Framework.Input { - public abstract partial class InputManager : Container, IInputStateChangeHandler + public abstract partial class InputManager : Container, IInputStateChangeHandler, IFocusManager { /// /// The initial delay before key repeat begins. @@ -51,6 +51,12 @@ public abstract partial class InputManager : Container, IInputStateChangeHandler /// public Drawable FocusedDrawable { get; internal set; } + /// + /// Any drawable that was focused directly via during the handling of a click, + /// and not as a result of the automatic post-process change of focus from the click. + /// + internal Drawable FocusedDrawableThisClick; + protected abstract ImmutableArray InputHandlers { get; } private double keyboardRepeatTime; @@ -366,18 +372,18 @@ public JoystickAxisEventManager GetJoystickAxisEventManagerFor(JoystickAxisSourc return joystickAxisEventManagers[source] = manager; } - /// - /// Reset current focused drawable to the top-most drawable which is . - /// - /// The source which triggered this event. + [Obsolete("This method does not allow trapping focus. Use GetContainingFocusManager().TriggerFocusContention instead.")] // Can be removed 20241118 public void TriggerFocusContention(Drawable triggerSource) { if (FocusedDrawable == null) return; Logger.Log($"Focus contention triggered by {triggerSource}."); - ChangeFocus(null); + changeFocus(null); } + [Obsolete("This method does not allow trapping focus. Use GetContainingFocusManager().ChangeFocus() instead.")] // Can be removed 20241118 + public bool ChangeFocus(Drawable potentialFocusTarget) => changeFocus(potentialFocusTarget); + /// /// Changes the currently-focused drawable. First checks that is in a valid state to receive focus, /// then unfocuses the current and focuses . @@ -386,7 +392,7 @@ public void TriggerFocusContention(Drawable triggerSource) /// /// The drawable to become focused. /// True if the given drawable is now focused (or focus is dropped in the case of a null target). - public bool ChangeFocus(Drawable potentialFocusTarget) => ChangeFocus(potentialFocusTarget, CurrentState); + private bool changeFocus(Drawable potentialFocusTarget) => ChangeFocus(potentialFocusTarget, CurrentState); /// /// Changes the currently-focused drawable. First checks that is in a valid state to receive focus, @@ -400,7 +406,10 @@ public void TriggerFocusContention(Drawable triggerSource) protected bool ChangeFocus(Drawable potentialFocusTarget, InputState state) { if (potentialFocusTarget == FocusedDrawable) + { + FocusedDrawableThisClick = FocusedDrawable; return true; + } if (potentialFocusTarget != null && (!isDrawableValidForFocus(potentialFocusTarget) || !potentialFocusTarget.AcceptsFocus)) return false; @@ -427,6 +436,8 @@ protected bool ChangeFocus(Drawable potentialFocusTarget, InputState state) FocusedDrawable.TriggerEvent(new FocusEvent(state, previousFocus)); } + FocusedDrawableThisClick = FocusedDrawable; + return true; } @@ -1031,7 +1042,7 @@ private bool unfocusIfNoLongerValid() return false; Logger.Log($"Focus on \"{FocusedDrawable}\" no longer valid as a result of {nameof(unfocusIfNoLongerValid)}.", LoggingTarget.Runtime, LogLevel.Debug); - ChangeFocus(null); + changeFocus(null); return true; } @@ -1090,7 +1101,7 @@ protected internal virtual void ChangeFocusFromClick(Drawable clickedDrawable) } } - ChangeFocus(focusTarget); + changeFocus(focusTarget); } private void focusTopMostRequestingDrawable() @@ -1100,12 +1111,12 @@ private void focusTopMostRequestingDrawable() { if (d.RequestsFocus) { - ChangeFocus(d); + changeFocus(d); return; } } - ChangeFocus(null); + changeFocus(null); } private class MouseLeftButtonEventManager : MouseButtonEventManager diff --git a/osu.Framework/Input/MouseButtonEventManager.cs b/osu.Framework/Input/MouseButtonEventManager.cs index bbfe01614d..988f69e8bf 100644 --- a/osu.Framework/Input/MouseButtonEventManager.cs +++ b/osu.Framework/Input/MouseButtonEventManager.cs @@ -155,12 +155,19 @@ private void handleClick(InputState state, List? targets) var drawables = targets.Intersect(InputQueue) .Where(t => t.IsAlive && t.IsPresent && t.ReceivePositionalInputAt(state.Mouse.Position)); - Drawable? clicked = PropagateButtonEvent(drawables, new ClickEvent(state, Button, MouseDownPosition)); + InputManager.FocusedDrawableThisClick = null; + Drawable? clicked = PropagateButtonEvent(drawables, new ClickEvent(state, Button, MouseDownPosition)); ClickedDrawable.SetTarget(clicked!); - if (ChangeFocusOnClick) - InputManager.ChangeFocusFromClick(clicked); + // Focus shall only change if it wasn't explicitly changed during the click (for example, using a button to open a menu). + if (InputManager.FocusedDrawableThisClick == null) + { + if (ChangeFocusOnClick && clicked?.ChangeFocusOnClick != false) + InputManager.ChangeFocusFromClick(clicked); + } + + InputManager.FocusedDrawableThisClick = null; if (clicked != null) Logger.Log($"MouseClick handled by {clicked}.", LoggingTarget.Runtime, LogLevel.Debug); diff --git a/osu.Framework/Input/PassThroughInputManager.cs b/osu.Framework/Input/PassThroughInputManager.cs index 5856141d71..82cce921ea 100644 --- a/osu.Framework/Input/PassThroughInputManager.cs +++ b/osu.Framework/Input/PassThroughInputManager.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -29,6 +27,8 @@ namespace osu.Framework.Input /// public partial class PassThroughInputManager : CustomInputManager, IRequireHighFrequencyMousePosition { + private bool useParentInput = true; + /// /// If there's an InputManager above us, decide whether we should use their available state. /// @@ -42,13 +42,22 @@ public virtual bool UseParentInput useParentInput = value; if (UseParentInput) - Sync(); + { + syncReleasedInputs(); + syncJoystickAxes(); + } } } - private bool useParentInput = true; + private InputManager? parentInputManager; + + protected override void LoadComplete() + { + base.LoadComplete(); + parentInputManager = GetContainingInputManager(); + } - public override bool HandleHoverEvents => UseParentInput ? parentInputManager.HandleHoverEvents : base.HandleHoverEvents; + public override bool HandleHoverEvents => parentInputManager != null && UseParentInput ? parentInputManager.HandleHoverEvents : base.HandleHoverEvents; internal override bool BuildNonPositionalInputQueue(List queue, bool allowBlocking = true) { @@ -76,9 +85,7 @@ protected override List GetPendingInputs() var pendingInputs = base.GetPendingInputs(); if (UseParentInput) - { pendingInputs.Clear(); - } return pendingInputs; } @@ -94,137 +101,143 @@ protected override bool Handle(UIEvent e) switch (e) { + case MouseDownEvent mouseDown: + new MouseButtonInput(mouseDown.Button, true).Apply(CurrentState, this); + break; + + case MouseUpEvent mouseUp: + new MouseButtonInput(mouseUp.Button, false).Apply(CurrentState, this); + break; + case MouseMoveEvent mouseMove: if (mouseMove.ScreenSpaceMousePosition != CurrentState.Mouse.Position) new MousePositionAbsoluteInput { Position = mouseMove.ScreenSpaceMousePosition }.Apply(CurrentState, this); break; - case MouseDownEvent mouseDown: - // safe-guard for edge cases. - if (!CurrentState.Mouse.IsPressed(mouseDown.Button)) - new MouseButtonInput(mouseDown.Button, true).Apply(CurrentState, this); + case ScrollEvent scroll: + new MouseScrollRelativeInput { Delta = scroll.ScrollDelta, IsPrecise = scroll.IsPrecise }.Apply(CurrentState, this); break; - case MouseUpEvent mouseUp: - // safe-guard for edge cases. - if (CurrentState.Mouse.IsPressed(mouseUp.Button)) - new MouseButtonInput(mouseUp.Button, false).Apply(CurrentState, this); + case KeyDownEvent keyDown: + if (keyDown.Repeat) + return false; + + new KeyboardKeyInput(keyDown.Key, true).Apply(CurrentState, this); break; - case ScrollEvent scroll: - new MouseScrollRelativeInput { Delta = scroll.ScrollDelta, IsPrecise = scroll.IsPrecise }.Apply(CurrentState, this); + case KeyUpEvent keyUp: + new KeyboardKeyInput(keyUp.Key, false).Apply(CurrentState, this); break; case TouchEvent touch: new TouchInput(touch.ScreenSpaceTouch, touch.IsActive(touch.ScreenSpaceTouch)).Apply(CurrentState, this); break; - case MidiEvent midi: - new MidiKeyInput(midi.Key, midi.Velocity, midi.IsPressed(midi.Key)).Apply(CurrentState, this); + case JoystickPressEvent joystickPress: + new JoystickButtonInput(joystickPress.Button, true).Apply(CurrentState, this); break; - case KeyboardEvent: - case JoystickButtonEvent: - case JoystickAxisMoveEvent: - case TabletPenButtonEvent: - case TabletAuxiliaryButtonEvent: - SyncInputState(e.CurrentState); + case JoystickReleaseEvent joystickRelease: + new JoystickButtonInput(joystickRelease.Button, false).Apply(CurrentState, this); break; - } - return false; - } + case JoystickAxisMoveEvent joystickAxisMove: + new JoystickAxisInput(joystickAxisMove.Axis).Apply(CurrentState, this); + break; - private InputManager parentInputManager; + case MidiDownEvent midiDown: + new MidiKeyInput(midiDown.Key, midiDown.Velocity, true).Apply(CurrentState, this); + break; - protected override void LoadComplete() - { - base.LoadComplete(); - Sync(); + case MidiUpEvent midiUp: + new MidiKeyInput(midiUp.Key, midiUp.Velocity, false).Apply(CurrentState, this); + break; + + case TabletPenButtonPressEvent tabletPenButtonPress: + new TabletPenButtonInput(tabletPenButtonPress.Button, true).Apply(CurrentState, this); + break; + + case TabletPenButtonReleaseEvent tabletPenButtonRelease: + new TabletPenButtonInput(tabletPenButtonRelease.Button, false).Apply(CurrentState, this); + break; + + case TabletAuxiliaryButtonPressEvent tabletAuxiliaryButtonPress: + new TabletAuxiliaryButtonInput(tabletAuxiliaryButtonPress.Button, true).Apply(CurrentState, this); + break; + + case TabletAuxiliaryButtonReleaseEvent tabletAuxiliaryButtonRelease: + new TabletAuxiliaryButtonInput(tabletAuxiliaryButtonRelease.Button, false).Apply(CurrentState, this); + break; + } + + return false; } protected override void Update() { base.Update(); - // Some non-positional events are blocked. Sync every frame. - if (UseParentInput) Sync(true); + // There are scenarios wherein we cannot receive the release events of pressed inputs. For simplicity, sync every frame. + if (UseParentInput) + { + syncReleasedInputs(); + syncJoystickAxes(); + } } /// - /// Sync input state to parent 's . - /// Call this when parent changed somehow. + /// Updates state of any buttons that have been released by parent while was disabled. /// - /// If this is false, assume parent input manager is unchanged from before. - public void Sync(bool useCachedParentInputManager = false) + private void syncReleasedInputs() { - if (!UseParentInput) return; + if (parentInputManager == null) + return; + + var parentState = parentInputManager.CurrentState; + var mouseDiff = (parentState?.Mouse?.Buttons ?? new ButtonStates()).EnumerateDifference(CurrentState.Mouse.Buttons); + var keyDiff = (parentState?.Keyboard.Keys ?? new ButtonStates()).EnumerateDifference(CurrentState.Keyboard.Keys); + var touchDiff = (parentState?.Touch ?? new TouchState()).EnumerateDifference(CurrentState.Touch); + var joyButtonDiff = (parentState?.Joystick?.Buttons ?? new ButtonStates()).EnumerateDifference(CurrentState.Joystick.Buttons); + var midiDiff = (parentState?.Midi?.Keys ?? new ButtonStates()).EnumerateDifference(CurrentState.Midi.Keys); + var tabletPenDiff = (parentState?.Tablet?.PenButtons ?? new ButtonStates()).EnumerateDifference(CurrentState.Tablet.PenButtons); + var tabletAuxiliaryDiff = (parentState?.Tablet?.AuxiliaryButtons ?? new ButtonStates()).EnumerateDifference(CurrentState.Tablet.AuxiliaryButtons); - if (!useCachedParentInputManager) - parentInputManager = GetContainingInputManager(); - - SyncInputState(parentInputManager?.CurrentState); - } - - /// - /// Sync current state to a certain state. - /// - /// The state to synchronise current with. If this is null, it is regarded as an empty state. - protected virtual void SyncInputState(InputState state) - { - // invariant: if mouse button is currently pressed, then it has been pressed in parent (but not the converse) - // therefore, mouse up events are always synced from parent - // mouse down events are not synced to prevent false clicks - var mouseDiff = (state?.Mouse?.Buttons ?? new ButtonStates()).EnumerateDifference(CurrentState.Mouse.Buttons); if (mouseDiff.Released.Length > 0) new MouseButtonInput(mouseDiff.Released.Select(button => new ButtonInputEntry(button, false))).Apply(CurrentState, this); - - var keyDiff = (state?.Keyboard.Keys ?? new ButtonStates()).EnumerateDifference(CurrentState.Keyboard.Keys); foreach (var key in keyDiff.Released) new KeyboardKeyInput(key, false).Apply(CurrentState, this); - foreach (var key in keyDiff.Pressed) - new KeyboardKeyInput(key, true).Apply(CurrentState, this); - - var touchDiff = (state?.Touch ?? new TouchState()).EnumerateDifference(CurrentState.Touch); if (touchDiff.deactivated.Length > 0) new TouchInput(touchDiff.deactivated, false).Apply(CurrentState, this); - if (touchDiff.activated.Length > 0) - new TouchInput(touchDiff.activated, true).Apply(CurrentState, this); - - var joyButtonDiff = (state?.Joystick?.Buttons ?? new ButtonStates()).EnumerateDifference(CurrentState.Joystick.Buttons); foreach (var button in joyButtonDiff.Released) new JoystickButtonInput(button, false).Apply(CurrentState, this); - foreach (var button in joyButtonDiff.Pressed) - new JoystickButtonInput(button, true).Apply(CurrentState, this); + foreach (var key in midiDiff.Released) + new MidiKeyInput(key, parentState?.Midi?.Velocities.GetValueOrDefault(key) ?? 0, false).Apply(CurrentState, this); + foreach (var button in tabletPenDiff.Released) + new TabletPenButtonInput(button, false).Apply(CurrentState, this); + foreach (var button in tabletAuxiliaryDiff.Released) + new TabletAuxiliaryButtonInput(button, false).Apply(CurrentState, this); + } + + /// + /// Updates state of joystick axes that have changed values while was disabled. + /// + private void syncJoystickAxes() + { + if (parentInputManager == null) + return; + + var parentState = parentInputManager.CurrentState; // Basically only perform the full state diff if we have found that any axis changed. // This avoids unnecessary alloc overhead. for (int i = 0; i < JoystickState.MAX_AXES; i++) { - if (state?.Joystick?.AxesValues[i] != CurrentState.Joystick.AxesValues[i]) + if (parentState?.Joystick?.AxesValues[i] != CurrentState.Joystick.AxesValues[i]) { - new JoystickAxisInput(state?.Joystick?.GetAxes() ?? Array.Empty()).Apply(CurrentState, this); + new JoystickAxisInput(parentState?.Joystick?.GetAxes() ?? Array.Empty()).Apply(CurrentState, this); break; } } - - var midiDiff = (state?.Midi?.Keys ?? new ButtonStates()).EnumerateDifference(CurrentState.Midi.Keys); - foreach (var key in midiDiff.Released) - new MidiKeyInput(key, state?.Midi?.Velocities.GetValueOrDefault(key) ?? 0, false).Apply(CurrentState, this); - foreach (var key in midiDiff.Pressed) - new MidiKeyInput(key, state?.Midi?.Velocities.GetValueOrDefault(key) ?? 0, true).Apply(CurrentState, this); - - var tabletPenDiff = (state?.Tablet?.PenButtons ?? new ButtonStates()).EnumerateDifference(CurrentState.Tablet.PenButtons); - foreach (var button in tabletPenDiff.Released) - new TabletPenButtonInput(button, false).Apply(CurrentState, this); - foreach (var button in tabletPenDiff.Pressed) - new TabletPenButtonInput(button, true).Apply(CurrentState, this); - - var tabletAuxiliaryDiff = (state?.Tablet?.AuxiliaryButtons ?? new ButtonStates()).EnumerateDifference(CurrentState.Tablet.AuxiliaryButtons); - foreach (var button in tabletAuxiliaryDiff.Released) - new TabletAuxiliaryButtonInput(button, false).Apply(CurrentState, this); - foreach (var button in tabletAuxiliaryDiff.Pressed) - new TabletAuxiliaryButtonInput(button, true).Apply(CurrentState, this); } } } diff --git a/osu.Framework/Input/SDL3WindowTextInput.cs b/osu.Framework/Input/SDLWindowTextInput.cs similarity index 92% rename from osu.Framework/Input/SDL3WindowTextInput.cs rename to osu.Framework/Input/SDLWindowTextInput.cs index 641724f114..c0fd539f44 100644 --- a/osu.Framework/Input/SDL3WindowTextInput.cs +++ b/osu.Framework/Input/SDLWindowTextInput.cs @@ -6,11 +6,11 @@ namespace osu.Framework.Input { - internal class SDL3WindowTextInput : TextInputSource + internal class SDLWindowTextInput : TextInputSource { - private readonly SDL3Window window; + private readonly ISDLWindow window; - public SDL3WindowTextInput(SDL3Window window) + public SDLWindowTextInput(ISDLWindow window) { this.window = window; } diff --git a/osu.Framework/Input/UserInputManager.cs b/osu.Framework/Input/UserInputManager.cs index a066de1d25..c08f0b2f63 100644 --- a/osu.Framework/Input/UserInputManager.cs +++ b/osu.Framework/Input/UserInputManager.cs @@ -90,7 +90,7 @@ private bool mouseOutsideAllDisplays(Vector2 mousePosition) switch (Host.Window.WindowMode.Value) { case WindowMode.Windowed: - windowLocation = Host.Window is SDL3Window sdlWindow ? sdlWindow.Position : Point.Empty; + windowLocation = Host.Window is ISDLWindow sdlWindow ? sdlWindow.Position : Point.Empty; break; default: diff --git a/osu.Framework/Localisation/LocalisableStringEqualityComparer.cs b/osu.Framework/Localisation/LocalisableStringEqualityComparer.cs index ba08885144..f68bb94260 100644 --- a/osu.Framework/Localisation/LocalisableStringEqualityComparer.cs +++ b/osu.Framework/Localisation/LocalisableStringEqualityComparer.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; @@ -17,14 +15,14 @@ public class LocalisableStringEqualityComparer : IEqualityComparer.Default.Equals(xData, yData); diff --git a/osu.Framework/Localisation/LocalisationManager.cs b/osu.Framework/Localisation/LocalisationManager.cs index 68e1a902b9..39ab3e1065 100644 --- a/osu.Framework/Localisation/LocalisationManager.cs +++ b/osu.Framework/Localisation/LocalisationManager.cs @@ -93,7 +93,7 @@ public void AddLanguage(string language, ILocalisationStore storage) /// To facilitate tracking changes to the localised value across changes, use /// and subscribe to its instead. /// - internal string GetLocalisedString(LocalisableString text) + public string GetLocalisedString(LocalisableString text) { switch (text.Data) { diff --git a/osu.Framework/Logging/Logger.cs b/osu.Framework/Logging/Logger.cs index 16ebdfc377..64b281d9b7 100644 --- a/osu.Framework/Logging/Logger.cs +++ b/osu.Framework/Logging/Logger.cs @@ -46,11 +46,6 @@ public class Logger /// public static LogLevel Level = DebugUtils.IsDebugBuild ? LogLevel.Debug : LogLevel.Verbose; - /// - /// An identifier used in log file headers to figure where the log file came from. - /// - public static string UserIdentifier = Environment.UserName; - /// /// An identifier for the game written to log file headers to indicate where the log file came from. /// @@ -395,7 +390,7 @@ private void writePendingLines() if (!headerAdded) { writer.WriteLine("----------------------------------------------------------"); - writer.WriteLine($"{Name} Log for {UserIdentifier} (LogLevel: {Level})"); + writer.WriteLine($"{Name} Log (LogLevel: {Level})"); writer.WriteLine($"Running {GameIdentifier} {VersionIdentifier} on .NET {Environment.Version}"); writer.WriteLine($"Environment: {RuntimeInfo.OS} ({Environment.OSVersion}), {Environment.ProcessorCount} cores "); writer.WriteLine("----------------------------------------------------------"); diff --git a/osu.Framework/Platform/CursorState.cs b/osu.Framework/Platform/CursorState.cs new file mode 100644 index 0000000000..7f02171f05 --- /dev/null +++ b/osu.Framework/Platform/CursorState.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +namespace osu.Framework.Platform +{ + /// + /// Describes our supported states of the OS cursor. + /// + [Flags] + public enum CursorState + { + /// + /// The OS cursor is always visible and can move anywhere. + /// + Default = 0, + + /// + /// The OS cursor is hidden while hovering the , but can still move anywhere. + /// + Hidden = 1, + + /// + /// The OS cursor is confined to the while the window is in focus. + /// + Confined = 2, + + /// + /// The OS cursor is hidden while hovering the . + /// It is confined to the while the window is in focus and can move freely otherwise. + /// + HiddenAndConfined = Hidden | Confined, + } +} diff --git a/osu.Framework/Platform/DesktopGameHost.cs b/osu.Framework/Platform/DesktopGameHost.cs index 7eb308c2fc..40346e88e0 100644 --- a/osu.Framework/Platform/DesktopGameHost.cs +++ b/osu.Framework/Platform/DesktopGameHost.cs @@ -13,7 +13,7 @@ namespace osu.Framework.Platform { - public abstract class DesktopGameHost : SDL3GameHost + public abstract class DesktopGameHost : SDLGameHost { private TcpIpcProvider ipcProvider; private readonly int? ipcPort; diff --git a/osu.Framework/Platform/GameHost.cs b/osu.Framework/Platform/GameHost.cs index 43d12550c1..4eab59a416 100644 --- a/osu.Framework/Platform/GameHost.cs +++ b/osu.Framework/Platform/GameHost.cs @@ -122,7 +122,8 @@ public abstract class GameHost : IIpcHost, IDisposable public event Func MessageReceived; /// - /// Whether the on screen keyboard covers a portion of the game window when presented to the user. + /// Whether the on-screen keyboard covers a portion of the game window when presented to the user. + /// This is usually true on mobile platforms, but may change to false if a hardware keyboard is connected. /// public virtual bool OnScreenKeyboardOverlapsGameWindow => false; @@ -145,10 +146,10 @@ public abstract class GameHost : IIpcHost, IDisposable public virtual Task SendMessageAsync(IpcMessage message) => throw new NotSupportedException("This platform does not implement IPC."); /// - /// Requests that a file be opened externally with an associated application, if available. + /// Requests that a file or folder be opened externally with an associated application, if available. /// /// - /// Some platforms do not support interacting with files externally (ie. mobile or sandboxed platforms), check the return value as to whether it succeeded. + /// Some platforms do not support interacting with files externally (ie. mobile or sandboxed platforms), check the return value to discern whether it succeeded. /// /// The absolute path to the file which should be opened. /// Whether the file was successfully opened. @@ -159,12 +160,14 @@ public abstract class GameHost : IIpcHost, IDisposable /// /// /// This will open the parent folder and, (if available) highlight the file. - /// Some platforms do not support interacting with files externally (ie. mobile or sandboxed platforms), check the return value as to whether it succeeded. + /// Some platforms do not support interacting with files externally (ie. mobile or sandboxed platforms), check the return value to discern whether it succeeded. + /// + /// If a folder path is provided to this method, it will prefer highlighting the folder in the parent folder, rather than showing the contents. + /// To display the contents of a folder, use instead. /// /// /// "C:\Windows\explorer.exe" -> opens 'C:\Windows' and highlights 'explorer.exe' in the window. /// "C:\Windows\System32" -> opens 'C:\Windows' and highlights 'System32' in the window. - /// "C:\Windows\System32\" -> opens 'C:\Windows\System32' and highlights nothing. /// /// The absolute path to the file/folder to be shown in its parent folder. /// Whether the file was successfully presented. @@ -336,6 +339,11 @@ protected GameHost([NotNull] string gameName, [CanBeNull] HostOptions options = { Options = options ?? new HostOptions(); + if (string.IsNullOrEmpty(Options.FriendlyGameName)) + { + Options.FriendlyGameName = $@"osu!framework (running ""{gameName}"")"; + } + Name = gameName; JsonConvert.DefaultSettings = () => new JsonSerializerSettings @@ -446,9 +454,9 @@ protected virtual void OnExited() Exited?.Invoke(); } - protected TripleBuffer DrawRoots = new TripleBuffer(); + private readonly TripleBuffer drawRoots = new TripleBuffer(); - protected Container Root; + internal Container Root { get; private set; } private ulong frameCount; @@ -472,9 +480,9 @@ protected virtual void UpdateFrame() TypePerformanceMonitor.NewFrame(); Root.UpdateSubTree(); - Root.UpdateSubTreeMasking(Root, Root.ScreenSpaceDrawQuad.AABBFloat); + Root.UpdateSubTreeMasking(); - using (var buffer = DrawRoots.GetForWrite()) + using (var buffer = drawRoots.GetForWrite()) buffer.Object = Root.GenerateDrawNodeSubtree(frameCount, buffer.Index, false); } @@ -495,7 +503,7 @@ protected virtual void DrawFrame() Renderer.AllowTearing = windowMode.Value == WindowMode.Fullscreen; - ObjectUsage buffer; + TripleBuffer.Buffer buffer; using (drawMonitor.BeginCollecting(PerformanceCollectionType.Sleep)) { @@ -506,7 +514,7 @@ protected virtual void DrawFrame() Renderer.WaitUntilNextFrameReady(); didRenderFrame = false; - buffer = DrawRoots.GetForRead(); + buffer = drawRoots.GetForRead(); } if (buffer == null) @@ -778,17 +786,7 @@ public void Run(Game game) { if (Window != null) { - switch (Window) - { - case SDL3Window window: - window.Update += windowUpdate; - break; - - case OsuTKWindow tkWindow: - tkWindow.UpdateFrame += (_, _) => windowUpdate(); - break; - } - + Window.Update += windowUpdate; Window.Suspended += Suspend; Window.Resumed += Resume; Window.LowOnMemory += Collect; @@ -1026,7 +1024,7 @@ protected void SetupRendererAndWindow(IRenderer renderer, GraphicsSurfaceType su Window.SetupWindow(Config); Window.Create(); - Window.Title = $@"osu!framework (running ""{Name}"")"; + Window.Title = Options.FriendlyGameName; Renderer.Initialise(Window.GraphicsSurface); @@ -1059,6 +1057,14 @@ protected void SetupRendererAndWindow(IRenderer renderer, GraphicsSurfaceType su }, true); IsActive.BindTo(Window.IsActive); + + AllowScreenSuspension.Result.BindValueChanged(e => + { + if (e.NewValue) + Window.EnableScreenSuspension(); + else + Window.DisableScreenSuspension(); + }, true); } /// diff --git a/osu.Framework/Platform/HeadlessGameHost.cs b/osu.Framework/Platform/HeadlessGameHost.cs index 21bed6de1b..2c7d591d09 100644 --- a/osu.Framework/Platform/HeadlessGameHost.cs +++ b/osu.Framework/Platform/HeadlessGameHost.cs @@ -1,14 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; using osu.Framework.Configuration; using osu.Framework.Graphics.Rendering.Dummy; using osu.Framework.Input.Handlers; using osu.Framework.Logging; +using osu.Framework.Threading; using osu.Framework.Timing; namespace osu.Framework.Platform @@ -21,7 +22,7 @@ public class HeadlessGameHost : DesktopGameHost public const double CLOCK_RATE = 1000.0 / 30; private readonly bool realtime; - private IFrameBasedClock customClock; + private IFrameBasedClock? customClock; protected override IFrameBasedClock SceneGraphClock => customClock ?? base.SceneGraphClock; @@ -41,7 +42,7 @@ public override bool PresentFileExternally(string filename) public override IEnumerable UserStoragePaths => new[] { "./headless/" }; - public HeadlessGameHost(string gameName = null, HostOptions options = null, bool realtime = true) + public HeadlessGameHost(string? gameName = null, HostOptions? options = null, bool realtime = true) : base(gameName ?? Guid.NewGuid().ToString(), options) { this.realtime = realtime; @@ -49,7 +50,7 @@ public HeadlessGameHost(string gameName = null, HostOptions options = null, bool protected override bool RequireWindowExists => false; - protected override IWindow CreateWindow(GraphicsSurfaceType preferredSurface) => null; + protected override IWindow CreateWindow(GraphicsSurfaceType preferredSurface) => null!; protected override Clipboard CreateClipboard() => new HeadlessClipboard(); @@ -78,7 +79,7 @@ protected override void SetupForRun() if (!realtime) { - customClock = new FramedClock(new FastClock(CLOCK_RATE)); + customClock = new FramedClock(new FastClock(CLOCK_RATE, Threads.ToArray())); // time is incremented per frame, rather than based on the real-world time. // therefore our goal is to run frames as fast as possible. @@ -108,6 +109,12 @@ protected override void UpdateFrame() private class FastClock : IClock { private readonly double increment; + + private readonly GameThread[] gameThreads; + private readonly ulong[] gameThreadLastFrames; + + private readonly Stopwatch stopwatch = new Stopwatch(); + private double time; /// @@ -115,12 +122,49 @@ private class FastClock : IClock /// Run fast. Run consistent. /// /// Milliseconds we should increment the clock by each time the time is requested. - public FastClock(double increment) + /// The game threads. + public FastClock(double increment, GameThread[] gameThreads) { this.increment = increment; + this.gameThreads = gameThreads; + gameThreadLastFrames = new ulong[gameThreads.Length]; + } + + public double CurrentTime + { + get + { + double realElapsedTime = stopwatch.Elapsed.TotalMilliseconds; + stopwatch.Restart(); + + if (allThreadsHaveProgressed) + { + for (int i = 0; i < gameThreads.Length; i++) + gameThreadLastFrames[i] = gameThreads[i].FrameIndex; + + // Increment time at the expedited rate. + return time += increment; + } + + // Fall back to real time to ensure we don't break random tests that expect threads to be running. + return time += realElapsedTime; + } + } + + private bool allThreadsHaveProgressed + { + get + { + for (int i = 0; i < gameThreads.Length; i++) + { + if (gameThreads[i].FrameIndex == gameThreadLastFrames[i]) + return false; + } + + return true; + } } - public double CurrentTime => time += increment; public double Rate => 1; public bool IsRunning => true; } diff --git a/osu.Framework/Platform/IAndroidGraphicsSurface.cs b/osu.Framework/Platform/IAndroidGraphicsSurface.cs new file mode 100644 index 0000000000..66ceb4358e --- /dev/null +++ b/osu.Framework/Platform/IAndroidGraphicsSurface.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +namespace osu.Framework.Platform +{ + public interface IAndroidGraphicsSurface + { + /// + /// Returns JNI environment handle. + /// + IntPtr JniEnvHandle { get; } + + /// + /// Android Surface handle. + /// + /// https://developer.android.com/reference/android/view/Surface.html + IntPtr SurfaceHandle { get; } + } +} diff --git a/osu.Framework/Platform/IGraphicsSurface.cs b/osu.Framework/Platform/IGraphicsSurface.cs index cc4c31562d..4345d698a8 100644 --- a/osu.Framework/Platform/IGraphicsSurface.cs +++ b/osu.Framework/Platform/IGraphicsSurface.cs @@ -16,12 +16,6 @@ public interface IGraphicsSurface /// IntPtr WindowHandle { get; } - /// - /// A pointer representing a handle to the display containing this window, provided by the operating system. - /// This is specific to X11/Wayland subsystems. - /// - IntPtr DisplayHandle { get; } - /// /// The type of surface. /// diff --git a/osu.Framework/Platform/ILinuxGraphicsSurface.cs b/osu.Framework/Platform/ILinuxGraphicsSurface.cs index 2f02a06ef2..9b1c970612 100644 --- a/osu.Framework/Platform/ILinuxGraphicsSurface.cs +++ b/osu.Framework/Platform/ILinuxGraphicsSurface.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; + namespace osu.Framework.Platform { public interface ILinuxGraphicsSurface @@ -9,5 +11,11 @@ public interface ILinuxGraphicsSurface /// Whether the current display server is Wayland. /// bool IsWayland { get; } + + /// + /// A pointer representing a handle to the display containing this window, provided by the operating system. + /// This is specific to X11/Wayland subsystems. + /// + IntPtr DisplayHandle { get; } } } diff --git a/osu.Framework/Platform/ISDLWindow.cs b/osu.Framework/Platform/ISDLWindow.cs new file mode 100644 index 0000000000..640cd8b099 --- /dev/null +++ b/osu.Framework/Platform/ISDLWindow.cs @@ -0,0 +1,59 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Drawing; +using osu.Framework.Bindables; +using osu.Framework.Input; +using osuTK; +using osuTK.Input; +using RectangleF = osu.Framework.Graphics.Primitives.RectangleF; + +namespace osu.Framework.Platform +{ + internal interface ISDLWindow : IWindow + { + event Action JoystickButtonDown; + event Action JoystickButtonUp; + event Action JoystickAxisChanged; + event Action TouchDown; + event Action TouchUp; + event Action MouseMove; + event Action MouseMoveRelative; + event Action MouseDown; + event Action MouseUp; + event Action MouseWheel; + event Action KeyDown; + event Action KeyUp; + event Action TextInput; + event TextEditingDelegate TextEditing; + event Action WindowStateChanged; + + Bindable CursorStateBindable { get; } + + Point Position { get; } + Size Size { get; } + float Scale { get; } + bool Resizable { get; set; } + + bool MouseAutoCapture { set; } + bool RelativeMouseMode { get; set; } + bool CapsLockPressed { get; } + bool KeyboardAttached { get; } + + void UpdateMousePosition(Vector2 position); + + void StartTextInput(bool allowIme); + void StopTextInput(); + void SetTextInputRect(RectangleF rectangle); + void ResetIme(); + } + + /// + /// Fired when text is edited, usually via IME composition. + /// + /// The composition text. + /// The index of the selection start. + /// The length of the selection. + public delegate void TextEditingDelegate(string text, int start, int length); +} diff --git a/osu.Framework/Platform/IWindow.cs b/osu.Framework/Platform/IWindow.cs index e54b3fe3d0..d98278435b 100644 --- a/osu.Framework/Platform/IWindow.cs +++ b/osu.Framework/Platform/IWindow.cs @@ -38,6 +38,11 @@ public interface IWindow : IDisposable /// void Create(); + /// + /// Invoked once every window event loop. + /// + event Action? Update; + /// /// Invoked when the window close (X) button or another platform-native exit action has been pressed. /// @@ -196,6 +201,16 @@ public interface IWindow : IDisposable /// void CancelFlash(); + /// + /// Enable any system level timers that might dim or turn off the screen. + /// + void EnableScreenSuspension(); + + /// + /// Disable any system level timers that might dim or turn off the screen. + /// + void DisableScreenSuspension(); + /// /// Start the window's run loop. /// Is a blocking call on desktop platforms, and a non-blocking call on mobile platforms. diff --git a/osu.Framework/Platform/Linux/LinuxGameHost.cs b/osu.Framework/Platform/Linux/LinuxGameHost.cs index 070c13de75..eb8278e6b6 100644 --- a/osu.Framework/Platform/Linux/LinuxGameHost.cs +++ b/osu.Framework/Platform/Linux/LinuxGameHost.cs @@ -6,7 +6,6 @@ using osu.Framework.Input; using osu.Framework.Input.Handlers; using osu.Framework.Input.Handlers.Mouse; -using SDL; namespace osu.Framework.Platform.Linux { @@ -27,13 +26,10 @@ internal LinuxGameHost(string gameName, HostOptions? options) BypassCompositor = Options.BypassCompositor; } - protected override void SetupForRun() - { - SDL3.SDL_SetHint(SDL3.SDL_HINT_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR, BypassCompositor ? "1"u8 : "0"u8); - base.SetupForRun(); - } - - protected override IWindow CreateWindow(GraphicsSurfaceType preferredSurface) => new SDL3DesktopWindow(preferredSurface); + protected override IWindow CreateWindow(GraphicsSurfaceType preferredSurface) + => FrameworkEnvironment.UseSDL3 + ? new SDL3LinuxWindow(preferredSurface, Options.FriendlyGameName, BypassCompositor) + : new SDL2LinuxWindow(preferredSurface, Options.FriendlyGameName, BypassCompositor); protected override ReadableKeyCombinationProvider CreateReadableKeyCombinationProvider() => new LinuxReadableKeyCombinationProvider(); diff --git a/osu.Framework/Platform/Linux/LinuxReadableKeyCombinationProvider.cs b/osu.Framework/Platform/Linux/LinuxReadableKeyCombinationProvider.cs index 36cb71d42c..cc1103ed8e 100644 --- a/osu.Framework/Platform/Linux/LinuxReadableKeyCombinationProvider.cs +++ b/osu.Framework/Platform/Linux/LinuxReadableKeyCombinationProvider.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Input.Bindings; -using osu.Framework.Platform.SDL; +using osu.Framework.Platform.SDL3; using SDL; namespace osu.Framework.Platform.Linux diff --git a/osu.Framework/Platform/Linux/SDL2LinuxWindow.cs b/osu.Framework/Platform/Linux/SDL2LinuxWindow.cs new file mode 100644 index 0000000000..c28f85a70a --- /dev/null +++ b/osu.Framework/Platform/Linux/SDL2LinuxWindow.cs @@ -0,0 +1,17 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Platform.SDL2; +using static SDL2.SDL; + +namespace osu.Framework.Platform.Linux +{ + internal class SDL2LinuxWindow : SDL2DesktopWindow + { + public SDL2LinuxWindow(GraphicsSurfaceType surfaceType, string appName, bool bypassCompositor) + : base(surfaceType, appName) + { + SDL_SetHint(SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR, bypassCompositor ? "1" : "0"); + } + } +} diff --git a/osu.Framework/Platform/Linux/SDL3LinuxWindow.cs b/osu.Framework/Platform/Linux/SDL3LinuxWindow.cs new file mode 100644 index 0000000000..2195f38fe3 --- /dev/null +++ b/osu.Framework/Platform/Linux/SDL3LinuxWindow.cs @@ -0,0 +1,17 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Platform.SDL3; +using static SDL.SDL3; + +namespace osu.Framework.Platform.Linux +{ + internal class SDL3LinuxWindow : SDL3DesktopWindow + { + public SDL3LinuxWindow(GraphicsSurfaceType surfaceType, string appName, bool bypassCompositor) + : base(surfaceType, appName) + { + SDL_SetHint(SDL_HINT_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR, bypassCompositor ? "1"u8 : "0"u8); + } + } +} diff --git a/osu.Framework/Platform/MacOS/MacOSGameHost.cs b/osu.Framework/Platform/MacOS/MacOSGameHost.cs index 0cb6f23628..67100d46b3 100644 --- a/osu.Framework/Platform/MacOS/MacOSGameHost.cs +++ b/osu.Framework/Platform/MacOS/MacOSGameHost.cs @@ -11,6 +11,8 @@ using osu.Framework.Input.Bindings; using osu.Framework.Input.Handlers; using osu.Framework.Input.Handlers.Mouse; +using osu.Framework.Logging; +using osu.Framework.Platform.MacOS.Native; namespace osu.Framework.Platform.MacOS { @@ -21,7 +23,10 @@ internal MacOSGameHost(string gameName, HostOptions options) { } - protected override IWindow CreateWindow(GraphicsSurfaceType preferredSurface) => new MacOSWindow(preferredSurface); + protected override IWindow CreateWindow(GraphicsSurfaceType preferredSurface) + => FrameworkEnvironment.UseSDL3 + ? new SDL3MacOSWindow(preferredSurface, Options.FriendlyGameName) + : new SDL2MacOSWindow(preferredSurface, Options.FriendlyGameName); public override IEnumerable UserStoragePaths { @@ -48,6 +53,29 @@ protected override void Swap() Renderer.WaitUntilIdle(); } + public override bool PresentFileExternally(string filename) + { + string folderPath = Path.GetDirectoryName(filename); + + if (folderPath == null) + { + Logger.Log($"Failed to get directory for {filename}", level: LogLevel.Debug); + return false; + } + + if (!File.Exists(filename) && !Directory.Exists(filename)) + { + Logger.Log($"Cannot find file for '{filename}'", level: LogLevel.Debug); + + // Open the folder without the file selected if we can't find the file + OpenFileExternally(folderPath); + return true; + } + + Finder.OpenFolderAndSelectItem(filename); + return true; + } + protected override IEnumerable CreateAvailableInputHandlers() { var handlers = base.CreateAvailableInputHandlers(); diff --git a/osu.Framework/Platform/MacOS/MacOSReadableKeyCombinationProvider.cs b/osu.Framework/Platform/MacOS/MacOSReadableKeyCombinationProvider.cs index de321c54c4..66cc4083a1 100644 --- a/osu.Framework/Platform/MacOS/MacOSReadableKeyCombinationProvider.cs +++ b/osu.Framework/Platform/MacOS/MacOSReadableKeyCombinationProvider.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Input.Bindings; -using osu.Framework.Platform.SDL; +using osu.Framework.Platform.SDL3; using SDL; namespace osu.Framework.Platform.MacOS diff --git a/osu.Framework/Platform/MacOS/Native/Finder.cs b/osu.Framework/Platform/MacOS/Native/Finder.cs new file mode 100644 index 0000000000..a8cf39fd8e --- /dev/null +++ b/osu.Framework/Platform/MacOS/Native/Finder.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; + +namespace osu.Framework.Platform.MacOS.Native +{ + internal static class Finder + { + private static readonly NSWorkspace shared_workspace = NSWorkspace.SharedWorkspace(); + + internal static void OpenFolderAndSelectItem(string filename) + { + Task.Run(() => + { + shared_workspace.SelectFile(filename); + }); + } + } +} diff --git a/osu.Framework/Platform/MacOS/Native/NSWorkspace.cs b/osu.Framework/Platform/MacOS/Native/NSWorkspace.cs new file mode 100644 index 0000000000..e01fd174a1 --- /dev/null +++ b/osu.Framework/Platform/MacOS/Native/NSWorkspace.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +namespace osu.Framework.Platform.MacOS.Native +{ + internal readonly struct NSWorkspace + { + internal IntPtr Handle { get; } + + private static readonly IntPtr class_pointer = Class.Get("NSWorkspace"); + private static readonly IntPtr sel_shared_workspace = Selector.Get("sharedWorkspace"); + private static readonly IntPtr sel_select_file = Selector.Get("selectFile:inFileViewerRootedAtPath:"); + + internal NSWorkspace(IntPtr handle) + { + Handle = handle; + } + + internal static NSWorkspace SharedWorkspace() => new NSWorkspace(Cocoa.SendIntPtr(class_pointer, sel_shared_workspace)); + + internal bool SelectFile(string file) => Cocoa.SendBool(Handle, sel_select_file, Cocoa.ToNSString(file)); + } +} diff --git a/osu.Framework/Platform/MacOS/SDL2MacOSWindow.cs b/osu.Framework/Platform/MacOS/SDL2MacOSWindow.cs new file mode 100644 index 0000000000..eedf6a5625 --- /dev/null +++ b/osu.Framework/Platform/MacOS/SDL2MacOSWindow.cs @@ -0,0 +1,71 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable + +using System; +using osu.Framework.Platform.MacOS.Native; +using osu.Framework.Platform.SDL2; +using osuTK; + +namespace osu.Framework.Platform.MacOS +{ + /// + /// macOS-specific subclass of . + /// + internal class SDL2MacOSWindow : SDL2DesktopWindow + { + private static readonly IntPtr sel_hasprecisescrollingdeltas = Selector.Get("hasPreciseScrollingDeltas"); + private static readonly IntPtr sel_scrollingdeltax = Selector.Get("scrollingDeltaX"); + private static readonly IntPtr sel_scrollingdeltay = Selector.Get("scrollingDeltaY"); + private static readonly IntPtr sel_respondstoselector_ = Selector.Get("respondsToSelector:"); + + private delegate void ScrollWheelDelegate(IntPtr handle, IntPtr selector, IntPtr theEvent); // v@:@ + + private IntPtr originalScrollWheel; + private ScrollWheelDelegate scrollWheelHandler; + + public SDL2MacOSWindow(GraphicsSurfaceType surfaceType, string appName) + : base(surfaceType, appName) + { + } + + public override void Create() + { + base.Create(); + + // replace [SDLView scrollWheel:(NSEvent *)] with our own version + IntPtr viewClass = Class.Get("SDLView"); + scrollWheelHandler = scrollWheel; + originalScrollWheel = Class.SwizzleMethod(viewClass, "scrollWheel:", "v@:@", scrollWheelHandler); + } + + /// + /// Swizzled replacement of [SDLView scrollWheel:(NSEvent *)] that checks for precise scrolling deltas. + /// + private void scrollWheel(IntPtr receiver, IntPtr selector, IntPtr theEvent) + { + bool hasPrecise = Cocoa.SendBool(theEvent, sel_respondstoselector_, sel_hasprecisescrollingdeltas) && + Cocoa.SendBool(theEvent, sel_hasprecisescrollingdeltas); + + if (!hasPrecise) + { + // calls the unswizzled [SDLView scrollWheel:(NSEvent *)] method if this is a regular scroll wheel event + // the receiver may sometimes not be SDLView, ensure it has a scroll wheel selector implemented before attempting to call. + if (Cocoa.SendBool(receiver, sel_respondstoselector_, originalScrollWheel)) + Cocoa.SendVoid(receiver, originalScrollWheel, theEvent); + + return; + } + + // according to osuTK, 0.1f is the scaling factor expected to be returned by CGEventSourceGetPixelsPerLine + // this is additionally scaled down by a factor of 8 so that a precise scroll of 1.0 is roughly equivalent to one notch on a traditional scroll wheel. + const float scale_factor = 0.1f / 8; + + float scrollingDeltaX = Cocoa.SendFloat(theEvent, sel_scrollingdeltax); + float scrollingDeltaY = Cocoa.SendFloat(theEvent, sel_scrollingdeltay); + + ScheduleEvent(() => TriggerMouseWheel(new Vector2(scrollingDeltaX * scale_factor, scrollingDeltaY * scale_factor), true)); + } + } +} diff --git a/osu.Framework/Platform/MacOS/MacOSWindow.cs b/osu.Framework/Platform/MacOS/SDL3MacOSWindow.cs similarity index 91% rename from osu.Framework/Platform/MacOS/MacOSWindow.cs rename to osu.Framework/Platform/MacOS/SDL3MacOSWindow.cs index d441d4bbc3..d3f8f2a619 100644 --- a/osu.Framework/Platform/MacOS/MacOSWindow.cs +++ b/osu.Framework/Platform/MacOS/SDL3MacOSWindow.cs @@ -5,6 +5,7 @@ using System; using osu.Framework.Platform.MacOS.Native; +using osu.Framework.Platform.SDL3; using osuTK; namespace osu.Framework.Platform.MacOS @@ -12,7 +13,7 @@ namespace osu.Framework.Platform.MacOS /// /// macOS-specific subclass of . /// - internal class MacOSWindow : SDL3DesktopWindow + internal class SDL3MacOSWindow : SDL3DesktopWindow { private static readonly IntPtr sel_hasprecisescrollingdeltas = Selector.Get("hasPreciseScrollingDeltas"); private static readonly IntPtr sel_scrollingdeltax = Selector.Get("scrollingDeltaX"); @@ -24,8 +25,8 @@ internal class MacOSWindow : SDL3DesktopWindow private IntPtr originalScrollWheel; private ScrollWheelDelegate scrollWheelHandler; - public MacOSWindow(GraphicsSurfaceType surfaceType) - : base(surfaceType) + public SDL3MacOSWindow(GraphicsSurfaceType surfaceType, string appName) + : base(surfaceType, appName) { } @@ -34,7 +35,7 @@ public override void Create() base.Create(); // replace [SDLView scrollWheel:(NSEvent *)] with our own version - IntPtr viewClass = Class.Get("SDLView"); + IntPtr viewClass = Class.Get("SDL3View"); scrollWheelHandler = scrollWheel; originalScrollWheel = Class.SwizzleMethod(viewClass, "scrollWheel:", "v@:@", scrollWheelHandler); } diff --git a/osu.Framework/Platform/OsuTKGameHost.cs b/osu.Framework/Platform/OsuTKGameHost.cs deleted file mode 100644 index d0a599ff4c..0000000000 --- a/osu.Framework/Platform/OsuTKGameHost.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Extensions.ObjectExtensions; -using osu.Framework.Graphics.OpenGL; -using osu.Framework.Graphics.Rendering; -using osuTK; - -namespace osu.Framework.Platform -{ - public abstract class OsuTKGameHost : GameHost - { - private readonly Toolkit toolkit; - - protected OsuTKGameHost() - : base(string.Empty) - { - toolkit = Toolkit.Init(); - } - - protected override IRenderer CreateGLRenderer() => new GLRenderer(); - - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - - if (toolkit.IsNotNull()) - toolkit.Dispose(); - } - } -} diff --git a/osu.Framework/Platform/OsuTKGraphicsSurface.cs b/osu.Framework/Platform/OsuTKGraphicsSurface.cs deleted file mode 100644 index 52d9822644..0000000000 --- a/osu.Framework/Platform/OsuTKGraphicsSurface.cs +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable -using System; -using System.Drawing; -using System.Linq; -using osu.Framework.Logging; -using osuTK.Graphics; -using osuTK.Graphics.ES30; - -namespace osu.Framework.Platform -{ - internal class OsuTKGraphicsSurface : IGraphicsSurface, IOpenGLGraphicsSurface - { - private readonly OsuTKWindow window; - - public GraphicsSurfaceType Type => GraphicsSurfaceType.OpenGL; - - public IntPtr WindowHandle => window.WindowInfo.Handle; - public IntPtr DisplayHandle => throw new NotSupportedException($@"{nameof(DisplayHandle)} is not supported."); - - public int? BackbufferFramebuffer => null; - - public bool VerticalSync { get; set; } - - public IntPtr WindowContext { get; private set; } - public IntPtr CurrentContext => GraphicsContext.CurrentContextHandle.Handle; - - internal Version GLVersion; - internal Version GLSLVersion; - internal bool IsEmbedded; - - public OsuTKGraphicsSurface(OsuTKWindow window) - { - this.window = window; - } - - public void Initialise() - { - window.MakeCurrent(); - - // there's no sane way to retrieve the GL context of the window so just use the current context after making window current. - WindowContext = CurrentContext; - - string version = GL.GetString(StringName.Version); - string versionNumberSubstring = getVersionNumberSubstring(version); - - GLVersion = new Version(versionNumberSubstring); - - // As defined by https://www.khronos.org/registry/OpenGL-Refpages/es2.0/xhtml/glGetString.xml - IsEmbedded = version.Contains("OpenGL ES"); - - version = GL.GetString(StringName.ShadingLanguageVersion); - - if (!string.IsNullOrEmpty(version)) - { - try - { - GLSLVersion = new Version(versionNumberSubstring); - } - catch (Exception e) - { - Logger.Error(e, $@"couldn't set GLSL version using string '{version}'"); - } - } - - if (GLSLVersion == null) - GLSLVersion = new Version(); - } - - private string getVersionNumberSubstring(string version) - { - string result = version.Split(' ').FirstOrDefault(s => char.IsDigit(s, 0)); - if (result != null) return result; - - throw new ArgumentException($"Cannot get version number from {version}!", nameof(version)); - } - - public Size GetDrawableSize() => window.ClientSize; - public void MakeCurrent(IntPtr context) => window.MakeCurrent(); - public void ClearCurrent() => window.ClearCurrent(); - public void SwapBuffers() => window.SwapBuffers(); - - public void CreateContext() => throw new NotSupportedException($@"{nameof(CreateContext)} is not supported."); - public void DeleteContext(IntPtr context) => throw new NotSupportedException($@"{nameof(DeleteContext)} is not supported."); - public IntPtr GetProcAddress(string symbol) => throw new NotSupportedException($@"{nameof(GetProcAddress)} is not supported."); - } -} diff --git a/osu.Framework/Platform/OsuTKWindow.cs b/osu.Framework/Platform/OsuTKWindow.cs deleted file mode 100644 index 08ae7915f2..0000000000 --- a/osu.Framework/Platform/OsuTKWindow.cs +++ /dev/null @@ -1,623 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using osu.Framework.Configuration; -using osuTK; -using osuTK.Graphics; -using osuTK.Platform; -using osuTK.Input; -using System.ComponentModel; -using System.Drawing; -using System.IO; -using JetBrains.Annotations; -using osu.Framework.Bindables; -using osu.Framework.Extensions; -using osu.Framework.Extensions.EnumExtensions; -using osu.Framework.Threading; -using Icon = osuTK.Icon; -using RectangleF = osu.Framework.Graphics.Primitives.RectangleF; - -namespace osu.Framework.Platform -{ - internal abstract class OsuTKWindow : IWindow, IGameWindow - { - private readonly IGraphicsSurface graphicsSurface; - IGraphicsSurface IWindow.GraphicsSurface => graphicsSurface; - - /// - /// The associated with this . - /// - [NotNull] - public abstract IGraphicsContext Context { get; } - - /// - /// Invoked when the window close (X) button or another platform-native exit action has been pressed. - /// - public event Action ExitRequested; - - /// - /// Invoked when the has closed. - /// - [CanBeNull] - public event Action Exited; - - /// - /// Invoked when the has resized. - /// - public event Action Resized; - - /// - public event Action Suspended { add { } remove { } } - - /// - public event Action Resumed { add { } remove { } } - - /// - public event Action LowOnMemory { add { } remove { } } - - /// - public event Action KeymapChanged { add { } remove { } } - - /// - public event Action DragDrop { add { } remove { } } - - /// - /// Invoked when any key has been pressed. - /// - [CanBeNull] - public event EventHandler KeyDown; - - protected readonly IGameWindow OsuTKGameWindow; - - protected readonly Scheduler UpdateFrameScheduler = new Scheduler(); - - private readonly BindableBool cursorInWindow = new BindableBool(true); - - public IBindable CursorInWindow => cursorInWindow; - - /// - /// Available resolutions for full-screen display. - /// - public virtual IEnumerable AvailableResolutions => Enumerable.Empty(); - - public Bindable WindowMode { get; } = new Bindable(); - - public void OnDraw() - { - } - - public void Raise() - { - } - - public void Hide() - { - } - - public void Show() - { - } - - public void Flash(bool _) - { - } - - public void CancelFlash() - { - } - - public abstract bool Focused { get; } - - public abstract IBindable IsActive { get; } - - public virtual ImmutableArray Displays => ImmutableArray.Create(DisplayDevice.GetDisplay(DisplayIndex.Primary).ToDisplay()); - -#pragma warning disable CS0067 - public event Action> DisplaysChanged; -#pragma warning restore CS0067 - - public virtual Display PrimaryDisplay => Displays.FirstOrDefault(d => d.Index == (int)DisplayDevice.Default.GetIndex()); - - public Bindable CurrentDisplayBindable { get; } = new Bindable(); - - /// - /// osuTK's reference to the current instance is private. - /// Instead we construct a based on the metrics of , - /// as it defers to the current resolution. Note that we round the refresh rate, as osuTK can sometimes - /// report refresh rates such as 59.992863 where SDL3 will report 60. - /// - public virtual IBindable CurrentDisplayMode - { - get - { - var display = CurrentDisplayDevice; - return new Bindable(new DisplayMode(null, new Size(display.Width, display.Height), display.BitsPerPixel, display.RefreshRate, 0)); - } - } - - /// - /// Creates a with a given implementation. - /// - protected OsuTKWindow([NotNull] IGameWindow osuTKGameWindow) - { - OsuTKGameWindow = osuTKGameWindow; - OsuTKGameWindow.KeyDown += OnKeyDown; - - CurrentDisplayBindable.Value = PrimaryDisplay; - - // Moving or resizing the window needs to check to see if we've moved to a different display. - // This will update the CurrentDisplay bindable. - Move += (_, _) => checkCurrentDisplay(); - Resize += (_, _) => - { - checkCurrentDisplay(); - Resized?.Invoke(); - }; - - Closing += (_, e) => - { - // always block a graceful exit as it's treated as a regular window event. - // the host will force-close the window if the game decides not to block the exit. - ExitRequested?.Invoke(); - e.Cancel = true; - }; - Closed += (_, _) => Exited?.Invoke(); - - MouseEnter += (_, _) => cursorInWindow.Value = true; - MouseLeave += (_, _) => cursorInWindow.Value = false; - - UpdateFrame += (_, _) => UpdateFrameScheduler.Update(); - - graphicsSurface = new OsuTKGraphicsSurface(this); - graphicsSurface.Initialise(); - } - - /// - /// Creates a with given dimensions. - /// Note that this will use the default implementation, which is not compatible with every platform. - /// - protected OsuTKWindow(int width, int height) - : this(new GameWindow(width, height, - new GraphicsMode(GraphicsMode.Default.ColorFormat, GraphicsMode.Default.Depth, GraphicsMode.Default.Stencil, GraphicsMode.Default.Samples, GraphicsMode.Default.AccumulatorFormat, 3))) - { - } - - public void Create() - { - Context.MakeCurrent(null); - } - - private CursorState cursorState = CursorState.Default; - - /// - /// Controls the state of the OS cursor. - /// - public virtual CursorState CursorState - { - get => cursorState; - set - { - cursorState = value; - - OsuTKGameWindow.Cursor = cursorState.HasFlagFast(CursorState.Hidden) ? MouseCursor.Empty : MouseCursor.Default; - - try - { - OsuTKGameWindow.CursorGrabbed = cursorState.HasFlagFast(CursorState.Confined); - } - catch - { - // may not be supported by platform. - } - } - } - - public RectangleF? CursorConfineRect { get; set; } - - /// - /// We do not support directly using . - /// It is controlled internally. Use instead. - /// - public MouseCursor Cursor - { - get => throw new InvalidOperationException($@"{nameof(Cursor)} is not supported. Use {nameof(CursorState)}."); - set => throw new InvalidOperationException($@"{nameof(Cursor)} is not supported. Use {nameof(CursorState)}."); - } - - /// - /// We do not support directly using . - /// It is controlled internally. Use instead. - /// - public bool CursorVisible - { - get => throw new InvalidOperationException($@"{nameof(CursorVisible)} is not supported. Use {nameof(CursorState)}."); - set => throw new InvalidOperationException($@"{nameof(CursorVisible)} is not supported. Use {nameof(CursorState)}."); - } - - /// - /// We do not support directly using . - /// It is controlled internally. Use instead. - /// - public bool CursorGrabbed - { - get => throw new InvalidOperationException($@"{nameof(CursorGrabbed)} is not supported. Use {nameof(CursorState)}."); - set => throw new InvalidOperationException($@"{nameof(CursorGrabbed)} is not supported. Use {nameof(CursorState)}."); - } - - /// - /// Gets the that this window is currently on. - /// - protected virtual DisplayDevice CurrentDisplayDevice - { - get => DisplayDevice.FromRectangle(Bounds) ?? DisplayDevice.Default; - set => throw new InvalidOperationException($@"{GetType().Name}.{nameof(CurrentDisplayDevice)} cannot be set."); - } - - private void checkCurrentDisplay() - { - int index = (int)CurrentDisplayDevice.GetIndex(); - if (index != CurrentDisplayBindable.Value?.Index) - CurrentDisplayBindable.Value = Displays.ElementAtOrDefault(index); - } - - public abstract void SetupWindow(FrameworkConfigManager config); - - protected virtual void OnKeyDown(object sender, KeyboardKeyEventArgs e) => KeyDown?.Invoke(sender, e); - - /// - /// Provides a that can be used to keep track of the "safe area" insets on mobile - /// devices. This usually corresponds to areas of the screen hidden under notches and rounded corners. - /// The safe area insets are provided by the operating system and dynamically change as the user rotates the device. - /// - public virtual BindableSafeArea SafeAreaPadding { get; } = new BindableSafeArea(); - - public abstract IEnumerable SupportedWindowModes { get; } - - public virtual WindowMode DefaultWindowMode => SupportedWindowModes.First(); - - public virtual VSyncMode VSync { get; set; } - - public bool VerticalSync - { - get => VSync == VSyncMode.On; - set => VSync = value ? VSyncMode.On : VSyncMode.Off; - } - - public virtual void CycleMode() - { - var currentValue = WindowMode.Value; - - do - { - switch (currentValue) - { - case Configuration.WindowMode.Windowed: - currentValue = Configuration.WindowMode.Borderless; - break; - - case Configuration.WindowMode.Borderless: - currentValue = Configuration.WindowMode.Fullscreen; - break; - - case Configuration.WindowMode.Fullscreen: - currentValue = Configuration.WindowMode.Windowed; - break; - } - } while (!SupportedWindowModes.Contains(currentValue) && currentValue != WindowMode.Value); - - WindowMode.Value = currentValue; - } - - public void ClearCurrent() => GraphicsContext.CurrentContext?.MakeCurrent(null); - - #region Autogenerated IGameWindow implementation - - public virtual void Run() => OsuTKGameWindow.Run(); - - public void Run(double updateRate) - { - OsuTKGameWindow.Run(updateRate); - } - - public void MakeCurrent() => OsuTKGameWindow.MakeCurrent(); - public void SwapBuffers() => OsuTKGameWindow.SwapBuffers(); - - public string Title - { - get => OsuTKGameWindow.Title; - set => OsuTKGameWindow.Title = $"{value} (legacy osuTK)"; - } - - bool INativeWindow.Focused => OsuTKGameWindow.Focused; - - public bool Visible - { - get => OsuTKGameWindow.Visible; - set => OsuTKGameWindow.Visible = value; - } - - public bool Exists => OsuTKGameWindow.Exists; - public IWindowInfo WindowInfo => OsuTKGameWindow.WindowInfo; - - osuTK.WindowState INativeWindow.WindowState - { - get => OsuTKGameWindow.WindowState; - set => OsuTKGameWindow.WindowState = value; - } - - public virtual WindowState WindowState - { - get => OsuTKGameWindow.WindowState.ToFramework(); - set => OsuTKGameWindow.WindowState = value.ToOsuTK(); - } - - public WindowBorder WindowBorder - { - get => OsuTKGameWindow.WindowBorder; - set => OsuTKGameWindow.WindowBorder = value; - } - - public Rectangle Bounds - { - get => OsuTKGameWindow.Bounds; - set => OsuTKGameWindow.Bounds = value; - } - - public Point Location - { - get => OsuTKGameWindow.Location; - set => OsuTKGameWindow.Location = value; - } - - public Size Size - { - get => OsuTKGameWindow.Size; - set => OsuTKGameWindow.Size = value; - } - - public int X - { - get => OsuTKGameWindow.X; - set => OsuTKGameWindow.X = value; - } - - public int Y - { - get => OsuTKGameWindow.Y; - set => OsuTKGameWindow.Y = value; - } - - public int Width - { - get => OsuTKGameWindow.Width; - set => OsuTKGameWindow.Width = value; - } - - public int Height - { - get => OsuTKGameWindow.Height; - set => OsuTKGameWindow.Height = value; - } - - public Rectangle ClientRectangle - { - get => OsuTKGameWindow.ClientRectangle; - set => OsuTKGameWindow.ClientRectangle = value; - } - - public Size ClientSize - { - get => OsuTKGameWindow.ClientSize; - set => OsuTKGameWindow.ClientSize = value; - } - - public Size MinSize - { - get => throw new InvalidOperationException($@"{nameof(MinSize)} is not supported."); - set => throw new InvalidOperationException($@"{nameof(MinSize)} is not supported."); - } - - public Size MaxSize - { - get => throw new InvalidOperationException($@"{nameof(MaxSize)} is not supported."); - set => throw new InvalidOperationException($@"{nameof(MaxSize)} is not supported."); - } - - public void Close() => OsuTKGameWindow.Close(); - - public void ProcessEvents() => OsuTKGameWindow.ProcessEvents(); - - public void SetIconFromStream(Stream imageStream) => throw new NotSupportedException($@"{nameof(SetIconFromStream)} is not supported."); - - public Point PointToClient(Point point) => OsuTKGameWindow.PointToClient(point); - public Point PointToScreen(Point point) => OsuTKGameWindow.PointToScreen(point); - - public Icon Icon - { - get => OsuTKGameWindow.Icon; - set => OsuTKGameWindow.Icon = value; - } - - public void Dispose() => OsuTKGameWindow.Dispose(); - - public event EventHandler Load - { - add => OsuTKGameWindow.Load += value; - remove => OsuTKGameWindow.Load -= value; - } - - public event EventHandler Unload - { - add => OsuTKGameWindow.Unload += value; - remove => OsuTKGameWindow.Unload -= value; - } - - public event EventHandler UpdateFrame - { - add => OsuTKGameWindow.UpdateFrame += value; - remove => OsuTKGameWindow.UpdateFrame -= value; - } - - public event EventHandler RenderFrame - { - add => OsuTKGameWindow.RenderFrame += value; - remove => OsuTKGameWindow.RenderFrame -= value; - } - - public event EventHandler Move - { - add => OsuTKGameWindow.Move += value; - remove => OsuTKGameWindow.Move -= value; - } - - public event EventHandler Resize - { - add => OsuTKGameWindow.Resize += value; - remove => OsuTKGameWindow.Resize -= value; - } - - public event EventHandler Closing - { - add => OsuTKGameWindow.Closing += value; - remove => OsuTKGameWindow.Closing -= value; - } - - public event EventHandler Closed - { - add => OsuTKGameWindow.Closed += value; - remove => OsuTKGameWindow.Closed -= value; - } - - public event EventHandler Disposed - { - add => OsuTKGameWindow.Disposed += value; - remove => OsuTKGameWindow.Disposed -= value; - } - - public event EventHandler IconChanged - { - add => OsuTKGameWindow.IconChanged += value; - remove => OsuTKGameWindow.IconChanged -= value; - } - - public event EventHandler TitleChanged - { - add => OsuTKGameWindow.TitleChanged += value; - remove => OsuTKGameWindow.TitleChanged -= value; - } - - public event EventHandler VisibleChanged - { - add => OsuTKGameWindow.VisibleChanged += value; - remove => OsuTKGameWindow.VisibleChanged -= value; - } - - public event EventHandler FocusedChanged - { - add => OsuTKGameWindow.FocusedChanged += value; - remove => OsuTKGameWindow.FocusedChanged -= value; - } - - public event EventHandler WindowBorderChanged - { - add => OsuTKGameWindow.WindowBorderChanged += value; - remove => OsuTKGameWindow.WindowBorderChanged -= value; - } - - public event EventHandler WindowStateChanged - { - add => OsuTKGameWindow.WindowStateChanged += value; - remove => OsuTKGameWindow.WindowStateChanged -= value; - } - - public event EventHandler KeyPress - { - add => OsuTKGameWindow.KeyPress += value; - remove => OsuTKGameWindow.KeyPress -= value; - } - - public event EventHandler KeyUp - { - add => OsuTKGameWindow.KeyUp += value; - remove => OsuTKGameWindow.KeyUp -= value; - } - - public event EventHandler MouseLeave - { - add => OsuTKGameWindow.MouseLeave += value; - remove => OsuTKGameWindow.MouseLeave -= value; - } - - public event EventHandler MouseEnter - { - add => OsuTKGameWindow.MouseEnter += value; - remove => OsuTKGameWindow.MouseEnter -= value; - } - - public event EventHandler MouseDown - { - add => OsuTKGameWindow.MouseDown += value; - remove => OsuTKGameWindow.MouseDown -= value; - } - - public event EventHandler MouseUp - { - add => OsuTKGameWindow.MouseUp += value; - remove => OsuTKGameWindow.MouseUp -= value; - } - - public event EventHandler MouseMove - { - add => OsuTKGameWindow.MouseMove += value; - remove => OsuTKGameWindow.MouseMove -= value; - } - - public event EventHandler MouseWheel - { - add => OsuTKGameWindow.MouseWheel += value; - remove => OsuTKGameWindow.MouseWheel -= value; - } - - public event EventHandler FileDrop - { - add => OsuTKGameWindow.FileDrop += value; - remove => OsuTKGameWindow.FileDrop -= value; - } - - #endregion - } - - /// - /// Describes our supported states of the OS cursor. - /// - [Flags] - public enum CursorState - { - /// - /// The OS cursor is always visible and can move anywhere. - /// - Default = 0, - - /// - /// The OS cursor is hidden while hovering the , but can still move anywhere. - /// - Hidden = 1, - - /// - /// The OS cursor is confined to the while the window is in focus. - /// - Confined = 2, - - /// - /// The OS cursor is hidden while hovering the . - /// It is confined to the while the window is in focus and can move freely otherwise. - /// - HiddenAndConfined = Hidden | Confined, - } -} diff --git a/osu.Framework/Platform/SDL/SDL3Clipboard.cs b/osu.Framework/Platform/SDL2/SDL2Clipboard.cs similarity index 67% rename from osu.Framework/Platform/SDL/SDL3Clipboard.cs rename to osu.Framework/Platform/SDL2/SDL2Clipboard.cs index e7eea7a816..f75a4091b5 100644 --- a/osu.Framework/Platform/SDL/SDL3Clipboard.cs +++ b/osu.Framework/Platform/SDL2/SDL2Clipboard.cs @@ -1,19 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using SDL; using SixLabors.ImageSharp; +using static SDL2.SDL; -namespace osu.Framework.Platform.SDL +namespace osu.Framework.Platform.SDL2 { - public class SDL3Clipboard : Clipboard + public class SDL2Clipboard : Clipboard { // SDL cannot differentiate between string.Empty and no text (eg. empty clipboard or an image) // doesn't matter as text editors don't really allow copying empty strings. // assume that empty text means no text. - public override string? GetText() => SDL3.SDL_HasClipboardText() == SDL_bool.SDL_TRUE ? SDL3.SDL_GetClipboardText() : null; + public override string? GetText() => SDL_HasClipboardText() == SDL_bool.SDL_TRUE ? SDL_GetClipboardText() : null; - public override void SetText(string text) => SDL3.SDL_SetClipboardText(text); + public override void SetText(string text) => SDL_SetClipboardText(text); public override Image? GetImage() { diff --git a/osu.Framework/Platform/SDL2/SDL2ControllerBindings.cs b/osu.Framework/Platform/SDL2/SDL2ControllerBindings.cs new file mode 100644 index 0000000000..fbf06b7cb3 --- /dev/null +++ b/osu.Framework/Platform/SDL2/SDL2ControllerBindings.cs @@ -0,0 +1,79 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable + +using System; +using System.Linq; +using static SDL2.SDL; + +namespace osu.Framework.Platform.SDL2 +{ + /// + /// Maintain a copy of the SDL-provided bindings for the given controller. + /// Used to determine whether a given event's joystick button or axis is unmapped. + /// + internal class SDL2ControllerBindings + { + public readonly IntPtr JoystickHandle; + public readonly IntPtr ControllerHandle; + + /// + /// Bindings returned from , indexed by . + /// Empty if the joystick does not have a corresponding ControllerHandle. + /// + public SDL_GameControllerButtonBind[] ButtonBindings; + + /// + /// Bindings returned from , indexed by . + /// Empty if the joystick does not have a corresponding ControllerHandle. + /// + public SDL_GameControllerButtonBind[] AxisBindings; + + public SDL2ControllerBindings(IntPtr joystickHandle, IntPtr controllerHandle) + { + JoystickHandle = joystickHandle; + ControllerHandle = controllerHandle; + + PopulateBindings(); + } + + public void PopulateBindings() + { + if (ControllerHandle == IntPtr.Zero) + { + ButtonBindings = Array.Empty(); + AxisBindings = Array.Empty(); + return; + } + + ButtonBindings = Enumerable.Range(0, (int)SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_MAX) + .Select(i => SDL_GameControllerGetBindForButton(ControllerHandle, (SDL_GameControllerButton)i)).ToArray(); + + AxisBindings = Enumerable.Range(0, (int)SDL_GameControllerAxis.SDL_CONTROLLER_AXIS_MAX) + .Select(i => SDL_GameControllerGetBindForAxis(ControllerHandle, (SDL_GameControllerAxis)i)).ToArray(); + } + + public bool IsJoystickButtonBound(byte buttonIndex) + { + for (int i = 0; i < ButtonBindings.Length; i++) + { + if (ButtonBindings[i].bindType != SDL_GameControllerBindType.SDL_CONTROLLER_BINDTYPE_NONE && ButtonBindings[i].value.button == buttonIndex) + return true; + } + + return false; + } + + public bool IsJoystickAxisBound(byte axisIndex) + { + for (int i = 0; i < AxisBindings.Length; i++) + { + if (AxisBindings[i].bindType != SDL_GameControllerBindType.SDL_CONTROLLER_BINDTYPE_NONE && AxisBindings[i].value.axis == axisIndex) + return true; + } + + return false; + } + } +} diff --git a/osu.Framework/Platform/SDL2/SDL2DesktopWindow.cs b/osu.Framework/Platform/SDL2/SDL2DesktopWindow.cs new file mode 100644 index 0000000000..2ead150f3c --- /dev/null +++ b/osu.Framework/Platform/SDL2/SDL2DesktopWindow.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using static SDL2.SDL; + +namespace osu.Framework.Platform.SDL2 +{ + internal class SDL2DesktopWindow : SDL2Window + { + public SDL2DesktopWindow(GraphicsSurfaceType surfaceType, string appName) + : base(surfaceType, appName) + { + } + + protected override void UpdateWindowStateAndSize(WindowState state, Display display, DisplayMode displayMode) + { + // this reset is required even on changing from one fullscreen resolution to another. + // if it is not included, the GL context will not get the correct size. + // this is mentioned by multiple sources as an SDL issue, which seems to resolve by similar means (see https://discourse.libsdl.org/t/sdl-setwindowsize-does-not-work-in-fullscreen/20711/4). + SDL_SetWindowBordered(SDLWindowHandle, SDL_bool.SDL_TRUE); + SDL_SetWindowFullscreen(SDLWindowHandle, (uint)SDL_bool.SDL_FALSE); + SDL_RestoreWindow(SDLWindowHandle); + + base.UpdateWindowStateAndSize(state, display, displayMode); + } + } +} diff --git a/osu.Framework/Platform/SDL2/SDL2Extensions.cs b/osu.Framework/Platform/SDL2/SDL2Extensions.cs new file mode 100644 index 0000000000..edfd63b6f8 --- /dev/null +++ b/osu.Framework/Platform/SDL2/SDL2Extensions.cs @@ -0,0 +1,1161 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable + +using System; +using System.Drawing; +using System.Runtime.InteropServices; +using osu.Framework.Extensions.EnumExtensions; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; +using osuTK.Input; +using static SDL2.SDL; + +namespace osu.Framework.Platform.SDL2 +{ + public static class SDL2Extensions + { + public static Key ToKey(this SDL_Keysym sdlKeysym) + { + // Apple devices don't have the notion of NumLock (they have a Clear key instead). + // treat them as if they always have NumLock on (the numpad always performs its primary actions). + bool numLockOn = sdlKeysym.mod.HasFlagFast(SDL_Keymod.KMOD_NUM) || RuntimeInfo.IsApple; + + switch (sdlKeysym.scancode) + { + default: + case SDL_Scancode.SDL_SCANCODE_UNKNOWN: + return Key.Unknown; + + case SDL_Scancode.SDL_SCANCODE_KP_COMMA: + return Key.Comma; + + case SDL_Scancode.SDL_SCANCODE_KP_TAB: + return Key.Tab; + + case SDL_Scancode.SDL_SCANCODE_KP_BACKSPACE: + return Key.BackSpace; + + case SDL_Scancode.SDL_SCANCODE_KP_A: + return Key.A; + + case SDL_Scancode.SDL_SCANCODE_KP_B: + return Key.B; + + case SDL_Scancode.SDL_SCANCODE_KP_C: + return Key.C; + + case SDL_Scancode.SDL_SCANCODE_KP_D: + return Key.D; + + case SDL_Scancode.SDL_SCANCODE_KP_E: + return Key.E; + + case SDL_Scancode.SDL_SCANCODE_KP_F: + return Key.F; + + case SDL_Scancode.SDL_SCANCODE_KP_SPACE: + return Key.Space; + + case SDL_Scancode.SDL_SCANCODE_KP_CLEAR: + return Key.Clear; + + case SDL_Scancode.SDL_SCANCODE_RETURN: + return Key.Enter; + + case SDL_Scancode.SDL_SCANCODE_ESCAPE: + return Key.Escape; + + case SDL_Scancode.SDL_SCANCODE_BACKSPACE: + return Key.BackSpace; + + case SDL_Scancode.SDL_SCANCODE_TAB: + return Key.Tab; + + case SDL_Scancode.SDL_SCANCODE_SPACE: + return Key.Space; + + case SDL_Scancode.SDL_SCANCODE_APOSTROPHE: + return Key.Quote; + + case SDL_Scancode.SDL_SCANCODE_COMMA: + return Key.Comma; + + case SDL_Scancode.SDL_SCANCODE_MINUS: + return Key.Minus; + + case SDL_Scancode.SDL_SCANCODE_PERIOD: + return Key.Period; + + case SDL_Scancode.SDL_SCANCODE_SLASH: + return Key.Slash; + + case SDL_Scancode.SDL_SCANCODE_0: + return Key.Number0; + + case SDL_Scancode.SDL_SCANCODE_1: + return Key.Number1; + + case SDL_Scancode.SDL_SCANCODE_2: + return Key.Number2; + + case SDL_Scancode.SDL_SCANCODE_3: + return Key.Number3; + + case SDL_Scancode.SDL_SCANCODE_4: + return Key.Number4; + + case SDL_Scancode.SDL_SCANCODE_5: + return Key.Number5; + + case SDL_Scancode.SDL_SCANCODE_6: + return Key.Number6; + + case SDL_Scancode.SDL_SCANCODE_7: + return Key.Number7; + + case SDL_Scancode.SDL_SCANCODE_8: + return Key.Number8; + + case SDL_Scancode.SDL_SCANCODE_9: + return Key.Number9; + + case SDL_Scancode.SDL_SCANCODE_SEMICOLON: + return Key.Semicolon; + + case SDL_Scancode.SDL_SCANCODE_EQUALS: + return Key.Plus; + + case SDL_Scancode.SDL_SCANCODE_LEFTBRACKET: + return Key.BracketLeft; + + case SDL_Scancode.SDL_SCANCODE_BACKSLASH: + return Key.BackSlash; + + case SDL_Scancode.SDL_SCANCODE_RIGHTBRACKET: + return Key.BracketRight; + + case SDL_Scancode.SDL_SCANCODE_GRAVE: + return Key.Tilde; + + case SDL_Scancode.SDL_SCANCODE_A: + return Key.A; + + case SDL_Scancode.SDL_SCANCODE_B: + return Key.B; + + case SDL_Scancode.SDL_SCANCODE_C: + return Key.C; + + case SDL_Scancode.SDL_SCANCODE_D: + return Key.D; + + case SDL_Scancode.SDL_SCANCODE_E: + return Key.E; + + case SDL_Scancode.SDL_SCANCODE_F: + return Key.F; + + case SDL_Scancode.SDL_SCANCODE_G: + return Key.G; + + case SDL_Scancode.SDL_SCANCODE_H: + return Key.H; + + case SDL_Scancode.SDL_SCANCODE_I: + return Key.I; + + case SDL_Scancode.SDL_SCANCODE_J: + return Key.J; + + case SDL_Scancode.SDL_SCANCODE_K: + return Key.K; + + case SDL_Scancode.SDL_SCANCODE_L: + return Key.L; + + case SDL_Scancode.SDL_SCANCODE_M: + return Key.M; + + case SDL_Scancode.SDL_SCANCODE_N: + return Key.N; + + case SDL_Scancode.SDL_SCANCODE_O: + return Key.O; + + case SDL_Scancode.SDL_SCANCODE_P: + return Key.P; + + case SDL_Scancode.SDL_SCANCODE_Q: + return Key.Q; + + case SDL_Scancode.SDL_SCANCODE_R: + return Key.R; + + case SDL_Scancode.SDL_SCANCODE_S: + return Key.S; + + case SDL_Scancode.SDL_SCANCODE_T: + return Key.T; + + case SDL_Scancode.SDL_SCANCODE_U: + return Key.U; + + case SDL_Scancode.SDL_SCANCODE_V: + return Key.V; + + case SDL_Scancode.SDL_SCANCODE_W: + return Key.W; + + case SDL_Scancode.SDL_SCANCODE_X: + return Key.X; + + case SDL_Scancode.SDL_SCANCODE_Y: + return Key.Y; + + case SDL_Scancode.SDL_SCANCODE_Z: + return Key.Z; + + case SDL_Scancode.SDL_SCANCODE_CAPSLOCK: + return Key.CapsLock; + + case SDL_Scancode.SDL_SCANCODE_F1: + return Key.F1; + + case SDL_Scancode.SDL_SCANCODE_F2: + return Key.F2; + + case SDL_Scancode.SDL_SCANCODE_F3: + return Key.F3; + + case SDL_Scancode.SDL_SCANCODE_F4: + return Key.F4; + + case SDL_Scancode.SDL_SCANCODE_F5: + return Key.F5; + + case SDL_Scancode.SDL_SCANCODE_F6: + return Key.F6; + + case SDL_Scancode.SDL_SCANCODE_F7: + return Key.F7; + + case SDL_Scancode.SDL_SCANCODE_F8: + return Key.F8; + + case SDL_Scancode.SDL_SCANCODE_F9: + return Key.F9; + + case SDL_Scancode.SDL_SCANCODE_F10: + return Key.F10; + + case SDL_Scancode.SDL_SCANCODE_F11: + return Key.F11; + + case SDL_Scancode.SDL_SCANCODE_F12: + return Key.F12; + + case SDL_Scancode.SDL_SCANCODE_PRINTSCREEN: + return Key.PrintScreen; + + case SDL_Scancode.SDL_SCANCODE_SCROLLLOCK: + return Key.ScrollLock; + + case SDL_Scancode.SDL_SCANCODE_PAUSE: + return Key.Pause; + + case SDL_Scancode.SDL_SCANCODE_INSERT: + return Key.Insert; + + case SDL_Scancode.SDL_SCANCODE_HOME: + return Key.Home; + + case SDL_Scancode.SDL_SCANCODE_PAGEUP: + return Key.PageUp; + + case SDL_Scancode.SDL_SCANCODE_DELETE: + return Key.Delete; + + case SDL_Scancode.SDL_SCANCODE_END: + return Key.End; + + case SDL_Scancode.SDL_SCANCODE_PAGEDOWN: + return Key.PageDown; + + case SDL_Scancode.SDL_SCANCODE_RIGHT: + return Key.Right; + + case SDL_Scancode.SDL_SCANCODE_LEFT: + return Key.Left; + + case SDL_Scancode.SDL_SCANCODE_DOWN: + return Key.Down; + + case SDL_Scancode.SDL_SCANCODE_UP: + return Key.Up; + + case SDL_Scancode.SDL_SCANCODE_NUMLOCKCLEAR: + return Key.NumLock; + + case SDL_Scancode.SDL_SCANCODE_KP_DIVIDE: + return Key.KeypadDivide; + + case SDL_Scancode.SDL_SCANCODE_KP_MULTIPLY: + return Key.KeypadMultiply; + + case SDL_Scancode.SDL_SCANCODE_KP_MINUS: + return Key.KeypadMinus; + + case SDL_Scancode.SDL_SCANCODE_KP_PLUS: + return Key.KeypadPlus; + + case SDL_Scancode.SDL_SCANCODE_KP_ENTER: + return Key.KeypadEnter; + + case SDL_Scancode.SDL_SCANCODE_KP_1: + return numLockOn ? Key.Keypad1 : Key.End; + + case SDL_Scancode.SDL_SCANCODE_KP_2: + return numLockOn ? Key.Keypad2 : Key.Down; + + case SDL_Scancode.SDL_SCANCODE_KP_3: + return numLockOn ? Key.Keypad3 : Key.PageDown; + + case SDL_Scancode.SDL_SCANCODE_KP_4: + return numLockOn ? Key.Keypad4 : Key.Left; + + case SDL_Scancode.SDL_SCANCODE_KP_5: + return numLockOn ? Key.Keypad5 : Key.Clear; + + case SDL_Scancode.SDL_SCANCODE_KP_6: + return numLockOn ? Key.Keypad6 : Key.Right; + + case SDL_Scancode.SDL_SCANCODE_KP_7: + return numLockOn ? Key.Keypad7 : Key.Home; + + case SDL_Scancode.SDL_SCANCODE_KP_8: + return numLockOn ? Key.Keypad8 : Key.Up; + + case SDL_Scancode.SDL_SCANCODE_KP_9: + return numLockOn ? Key.Keypad9 : Key.PageUp; + + case SDL_Scancode.SDL_SCANCODE_KP_0: + return numLockOn ? Key.Keypad0 : Key.Insert; + + case SDL_Scancode.SDL_SCANCODE_KP_PERIOD: + return numLockOn ? Key.KeypadPeriod : Key.Delete; + + case SDL_Scancode.SDL_SCANCODE_NONUSBACKSLASH: + return Key.NonUSBackSlash; + + case SDL_Scancode.SDL_SCANCODE_F13: + return Key.F13; + + case SDL_Scancode.SDL_SCANCODE_F14: + return Key.F14; + + case SDL_Scancode.SDL_SCANCODE_F15: + return Key.F15; + + case SDL_Scancode.SDL_SCANCODE_F16: + return Key.F16; + + case SDL_Scancode.SDL_SCANCODE_F17: + return Key.F17; + + case SDL_Scancode.SDL_SCANCODE_F18: + return Key.F18; + + case SDL_Scancode.SDL_SCANCODE_F19: + return Key.F19; + + case SDL_Scancode.SDL_SCANCODE_F20: + return Key.F20; + + case SDL_Scancode.SDL_SCANCODE_F21: + return Key.F21; + + case SDL_Scancode.SDL_SCANCODE_F22: + return Key.F22; + + case SDL_Scancode.SDL_SCANCODE_F23: + return Key.F23; + + case SDL_Scancode.SDL_SCANCODE_F24: + return Key.F24; + + case SDL_Scancode.SDL_SCANCODE_MENU: + case SDL_Scancode.SDL_SCANCODE_APPLICATION: + return Key.Menu; + + case SDL_Scancode.SDL_SCANCODE_STOP: + return Key.Stop; + + case SDL_Scancode.SDL_SCANCODE_MUTE: + return Key.Mute; + + case SDL_Scancode.SDL_SCANCODE_VOLUMEUP: + return Key.VolumeUp; + + case SDL_Scancode.SDL_SCANCODE_VOLUMEDOWN: + return Key.VolumeDown; + + case SDL_Scancode.SDL_SCANCODE_CLEAR: + return Key.Clear; + + case SDL_Scancode.SDL_SCANCODE_DECIMALSEPARATOR: + return Key.KeypadDecimal; + + case SDL_Scancode.SDL_SCANCODE_LCTRL: + return Key.ControlLeft; + + case SDL_Scancode.SDL_SCANCODE_LSHIFT: + return Key.ShiftLeft; + + case SDL_Scancode.SDL_SCANCODE_LALT: + return Key.AltLeft; + + case SDL_Scancode.SDL_SCANCODE_LGUI: + return Key.WinLeft; + + case SDL_Scancode.SDL_SCANCODE_RCTRL: + return Key.ControlRight; + + case SDL_Scancode.SDL_SCANCODE_RSHIFT: + return Key.ShiftRight; + + case SDL_Scancode.SDL_SCANCODE_RALT: + return Key.AltRight; + + case SDL_Scancode.SDL_SCANCODE_RGUI: + return Key.WinRight; + + case SDL_Scancode.SDL_SCANCODE_AUDIONEXT: + return Key.TrackNext; + + case SDL_Scancode.SDL_SCANCODE_AUDIOPREV: + return Key.TrackPrevious; + + case SDL_Scancode.SDL_SCANCODE_AUDIOSTOP: + return Key.Stop; + + case SDL_Scancode.SDL_SCANCODE_AUDIOPLAY: + return Key.PlayPause; + + case SDL_Scancode.SDL_SCANCODE_AUDIOMUTE: + return Key.Mute; + + case SDL_Scancode.SDL_SCANCODE_SLEEP: + return Key.Sleep; + } + } + + /// + /// Returns the corresponding for a given . + /// + /// + /// Should be a keyboard key. + /// + /// + /// The corresponding if the is valid. + /// otherwise. + /// + public static SDL_Scancode ToScancode(this InputKey inputKey) + { + switch (inputKey) + { + default: + case InputKey.Shift: + case InputKey.Control: + case InputKey.Alt: + case InputKey.Super: + case InputKey.F25: + case InputKey.F26: + case InputKey.F27: + case InputKey.F28: + case InputKey.F29: + case InputKey.F30: + case InputKey.F31: + case InputKey.F32: + case InputKey.F33: + case InputKey.F34: + case InputKey.F35: + case InputKey.Clear: + return SDL_Scancode.SDL_SCANCODE_UNKNOWN; + + case InputKey.Menu: + return SDL_Scancode.SDL_SCANCODE_MENU; + + case InputKey.F1: + return SDL_Scancode.SDL_SCANCODE_F1; + + case InputKey.F2: + return SDL_Scancode.SDL_SCANCODE_F2; + + case InputKey.F3: + return SDL_Scancode.SDL_SCANCODE_F3; + + case InputKey.F4: + return SDL_Scancode.SDL_SCANCODE_F4; + + case InputKey.F5: + return SDL_Scancode.SDL_SCANCODE_F5; + + case InputKey.F6: + return SDL_Scancode.SDL_SCANCODE_F6; + + case InputKey.F7: + return SDL_Scancode.SDL_SCANCODE_F7; + + case InputKey.F8: + return SDL_Scancode.SDL_SCANCODE_F8; + + case InputKey.F9: + return SDL_Scancode.SDL_SCANCODE_F9; + + case InputKey.F10: + return SDL_Scancode.SDL_SCANCODE_F10; + + case InputKey.F11: + return SDL_Scancode.SDL_SCANCODE_F11; + + case InputKey.F12: + return SDL_Scancode.SDL_SCANCODE_F12; + + case InputKey.F13: + return SDL_Scancode.SDL_SCANCODE_F13; + + case InputKey.F14: + return SDL_Scancode.SDL_SCANCODE_F14; + + case InputKey.F15: + return SDL_Scancode.SDL_SCANCODE_F15; + + case InputKey.F16: + return SDL_Scancode.SDL_SCANCODE_F16; + + case InputKey.F17: + return SDL_Scancode.SDL_SCANCODE_F17; + + case InputKey.F18: + return SDL_Scancode.SDL_SCANCODE_F18; + + case InputKey.F19: + return SDL_Scancode.SDL_SCANCODE_F19; + + case InputKey.F20: + return SDL_Scancode.SDL_SCANCODE_F20; + + case InputKey.F21: + return SDL_Scancode.SDL_SCANCODE_F21; + + case InputKey.F22: + return SDL_Scancode.SDL_SCANCODE_F22; + + case InputKey.F23: + return SDL_Scancode.SDL_SCANCODE_F23; + + case InputKey.F24: + return SDL_Scancode.SDL_SCANCODE_F24; + + case InputKey.Up: + return SDL_Scancode.SDL_SCANCODE_UP; + + case InputKey.Down: + return SDL_Scancode.SDL_SCANCODE_DOWN; + + case InputKey.Left: + return SDL_Scancode.SDL_SCANCODE_LEFT; + + case InputKey.Right: + return SDL_Scancode.SDL_SCANCODE_RIGHT; + + case InputKey.Enter: + return SDL_Scancode.SDL_SCANCODE_RETURN; + + case InputKey.Escape: + return SDL_Scancode.SDL_SCANCODE_ESCAPE; + + case InputKey.Space: + return SDL_Scancode.SDL_SCANCODE_SPACE; + + case InputKey.Tab: + return SDL_Scancode.SDL_SCANCODE_TAB; + + case InputKey.BackSpace: + return SDL_Scancode.SDL_SCANCODE_BACKSPACE; + + case InputKey.Insert: + return SDL_Scancode.SDL_SCANCODE_INSERT; + + case InputKey.Delete: + return SDL_Scancode.SDL_SCANCODE_DELETE; + + case InputKey.PageUp: + return SDL_Scancode.SDL_SCANCODE_PAGEUP; + + case InputKey.PageDown: + return SDL_Scancode.SDL_SCANCODE_PAGEDOWN; + + case InputKey.Home: + return SDL_Scancode.SDL_SCANCODE_HOME; + + case InputKey.End: + return SDL_Scancode.SDL_SCANCODE_END; + + case InputKey.CapsLock: + return SDL_Scancode.SDL_SCANCODE_CAPSLOCK; + + case InputKey.ScrollLock: + return SDL_Scancode.SDL_SCANCODE_SCROLLLOCK; + + case InputKey.PrintScreen: + return SDL_Scancode.SDL_SCANCODE_PRINTSCREEN; + + case InputKey.Pause: + return SDL_Scancode.SDL_SCANCODE_PAUSE; + + case InputKey.NumLock: + return SDL_Scancode.SDL_SCANCODE_NUMLOCKCLEAR; + + case InputKey.Sleep: + return SDL_Scancode.SDL_SCANCODE_SLEEP; + + case InputKey.Keypad0: + return SDL_Scancode.SDL_SCANCODE_KP_0; + + case InputKey.Keypad1: + return SDL_Scancode.SDL_SCANCODE_KP_1; + + case InputKey.Keypad2: + return SDL_Scancode.SDL_SCANCODE_KP_2; + + case InputKey.Keypad3: + return SDL_Scancode.SDL_SCANCODE_KP_3; + + case InputKey.Keypad4: + return SDL_Scancode.SDL_SCANCODE_KP_4; + + case InputKey.Keypad5: + return SDL_Scancode.SDL_SCANCODE_KP_5; + + case InputKey.Keypad6: + return SDL_Scancode.SDL_SCANCODE_KP_6; + + case InputKey.Keypad7: + return SDL_Scancode.SDL_SCANCODE_KP_7; + + case InputKey.Keypad8: + return SDL_Scancode.SDL_SCANCODE_KP_8; + + case InputKey.Keypad9: + return SDL_Scancode.SDL_SCANCODE_KP_9; + + case InputKey.KeypadDivide: + return SDL_Scancode.SDL_SCANCODE_KP_DIVIDE; + + case InputKey.KeypadMultiply: + return SDL_Scancode.SDL_SCANCODE_KP_MULTIPLY; + + case InputKey.KeypadMinus: + return SDL_Scancode.SDL_SCANCODE_KP_MINUS; + + case InputKey.KeypadPlus: + return SDL_Scancode.SDL_SCANCODE_KP_PLUS; + + case InputKey.KeypadPeriod: + return SDL_Scancode.SDL_SCANCODE_KP_PERIOD; + + case InputKey.KeypadEnter: + return SDL_Scancode.SDL_SCANCODE_KP_ENTER; + + case InputKey.A: + return SDL_Scancode.SDL_SCANCODE_A; + + case InputKey.B: + return SDL_Scancode.SDL_SCANCODE_B; + + case InputKey.C: + return SDL_Scancode.SDL_SCANCODE_C; + + case InputKey.D: + return SDL_Scancode.SDL_SCANCODE_D; + + case InputKey.E: + return SDL_Scancode.SDL_SCANCODE_E; + + case InputKey.F: + return SDL_Scancode.SDL_SCANCODE_F; + + case InputKey.G: + return SDL_Scancode.SDL_SCANCODE_G; + + case InputKey.H: + return SDL_Scancode.SDL_SCANCODE_H; + + case InputKey.I: + return SDL_Scancode.SDL_SCANCODE_I; + + case InputKey.J: + return SDL_Scancode.SDL_SCANCODE_J; + + case InputKey.K: + return SDL_Scancode.SDL_SCANCODE_K; + + case InputKey.L: + return SDL_Scancode.SDL_SCANCODE_L; + + case InputKey.M: + return SDL_Scancode.SDL_SCANCODE_M; + + case InputKey.N: + return SDL_Scancode.SDL_SCANCODE_N; + + case InputKey.O: + return SDL_Scancode.SDL_SCANCODE_O; + + case InputKey.P: + return SDL_Scancode.SDL_SCANCODE_P; + + case InputKey.Q: + return SDL_Scancode.SDL_SCANCODE_Q; + + case InputKey.R: + return SDL_Scancode.SDL_SCANCODE_R; + + case InputKey.S: + return SDL_Scancode.SDL_SCANCODE_S; + + case InputKey.T: + return SDL_Scancode.SDL_SCANCODE_T; + + case InputKey.U: + return SDL_Scancode.SDL_SCANCODE_U; + + case InputKey.V: + return SDL_Scancode.SDL_SCANCODE_V; + + case InputKey.W: + return SDL_Scancode.SDL_SCANCODE_W; + + case InputKey.X: + return SDL_Scancode.SDL_SCANCODE_X; + + case InputKey.Y: + return SDL_Scancode.SDL_SCANCODE_Y; + + case InputKey.Z: + return SDL_Scancode.SDL_SCANCODE_Z; + + case InputKey.Number0: + return SDL_Scancode.SDL_SCANCODE_0; + + case InputKey.Number1: + return SDL_Scancode.SDL_SCANCODE_1; + + case InputKey.Number2: + return SDL_Scancode.SDL_SCANCODE_2; + + case InputKey.Number3: + return SDL_Scancode.SDL_SCANCODE_3; + + case InputKey.Number4: + return SDL_Scancode.SDL_SCANCODE_4; + + case InputKey.Number5: + return SDL_Scancode.SDL_SCANCODE_5; + + case InputKey.Number6: + return SDL_Scancode.SDL_SCANCODE_6; + + case InputKey.Number7: + return SDL_Scancode.SDL_SCANCODE_7; + + case InputKey.Number8: + return SDL_Scancode.SDL_SCANCODE_8; + + case InputKey.Number9: + return SDL_Scancode.SDL_SCANCODE_9; + + case InputKey.Grave: + return SDL_Scancode.SDL_SCANCODE_GRAVE; + + case InputKey.Minus: + return SDL_Scancode.SDL_SCANCODE_MINUS; + + case InputKey.Plus: + return SDL_Scancode.SDL_SCANCODE_EQUALS; + + case InputKey.BracketLeft: + return SDL_Scancode.SDL_SCANCODE_LEFTBRACKET; + + case InputKey.BracketRight: + return SDL_Scancode.SDL_SCANCODE_RIGHTBRACKET; + + case InputKey.Semicolon: + return SDL_Scancode.SDL_SCANCODE_SEMICOLON; + + case InputKey.Quote: + return SDL_Scancode.SDL_SCANCODE_APOSTROPHE; + + case InputKey.Comma: + return SDL_Scancode.SDL_SCANCODE_COMMA; + + case InputKey.Period: + return SDL_Scancode.SDL_SCANCODE_PERIOD; + + case InputKey.Slash: + return SDL_Scancode.SDL_SCANCODE_SLASH; + + case InputKey.BackSlash: + return SDL_Scancode.SDL_SCANCODE_BACKSLASH; + + case InputKey.NonUSBackSlash: + return SDL_Scancode.SDL_SCANCODE_NONUSBACKSLASH; + + case InputKey.Mute: + return SDL_Scancode.SDL_SCANCODE_AUDIOMUTE; + + case InputKey.PlayPause: + return SDL_Scancode.SDL_SCANCODE_AUDIOPLAY; + + case InputKey.Stop: + return SDL_Scancode.SDL_SCANCODE_AUDIOSTOP; + + case InputKey.VolumeUp: + return SDL_Scancode.SDL_SCANCODE_VOLUMEUP; + + case InputKey.VolumeDown: + return SDL_Scancode.SDL_SCANCODE_VOLUMEDOWN; + + case InputKey.TrackPrevious: + return SDL_Scancode.SDL_SCANCODE_AUDIOPREV; + + case InputKey.TrackNext: + return SDL_Scancode.SDL_SCANCODE_AUDIONEXT; + + case InputKey.LShift: + return SDL_Scancode.SDL_SCANCODE_LSHIFT; + + case InputKey.RShift: + return SDL_Scancode.SDL_SCANCODE_RSHIFT; + + case InputKey.LControl: + return SDL_Scancode.SDL_SCANCODE_LCTRL; + + case InputKey.RControl: + return SDL_Scancode.SDL_SCANCODE_RCTRL; + + case InputKey.LAlt: + return SDL_Scancode.SDL_SCANCODE_LALT; + + case InputKey.RAlt: + return SDL_Scancode.SDL_SCANCODE_RALT; + + case InputKey.LSuper: + return SDL_Scancode.SDL_SCANCODE_LGUI; + + case InputKey.RSuper: + return SDL_Scancode.SDL_SCANCODE_RGUI; + } + } + + public static WindowState ToWindowState(this SDL_WindowFlags windowFlags) + { + if (windowFlags.HasFlagFast(SDL_WindowFlags.SDL_WINDOW_FULLSCREEN_DESKTOP) || + windowFlags.HasFlagFast(SDL_WindowFlags.SDL_WINDOW_BORDERLESS)) + return WindowState.FullscreenBorderless; + + if (windowFlags.HasFlagFast(SDL_WindowFlags.SDL_WINDOW_MINIMIZED)) + return WindowState.Minimised; + + if (windowFlags.HasFlagFast(SDL_WindowFlags.SDL_WINDOW_FULLSCREEN)) + return WindowState.Fullscreen; + + if (windowFlags.HasFlagFast(SDL_WindowFlags.SDL_WINDOW_MAXIMIZED)) + return WindowState.Maximised; + + return WindowState.Normal; + } + + public static SDL_WindowFlags ToFlags(this WindowState state) + { + switch (state) + { + case WindowState.Normal: + return 0; + + case WindowState.Fullscreen: + return SDL_WindowFlags.SDL_WINDOW_FULLSCREEN; + + case WindowState.Maximised: + return SDL_WindowFlags.SDL_WINDOW_MAXIMIZED; + + case WindowState.Minimised: + return SDL_WindowFlags.SDL_WINDOW_MINIMIZED; + + case WindowState.FullscreenBorderless: + return SDL_WindowFlags.SDL_WINDOW_FULLSCREEN_DESKTOP; + } + + return 0; + } + + public static SDL_WindowFlags ToFlags(this GraphicsSurfaceType surfaceType) + { + switch (surfaceType) + { + case GraphicsSurfaceType.OpenGL: + return SDL_WindowFlags.SDL_WINDOW_OPENGL; + + case GraphicsSurfaceType.Vulkan when !RuntimeInfo.IsApple: + return SDL_WindowFlags.SDL_WINDOW_VULKAN; + + case GraphicsSurfaceType.Metal: + case GraphicsSurfaceType.Vulkan when RuntimeInfo.IsApple: + return SDL_WindowFlags.SDL_WINDOW_METAL; + } + + return 0; + } + + public static JoystickAxisSource ToJoystickAxisSource(this SDL_GameControllerAxis axis) + { + switch (axis) + { + default: + case SDL_GameControllerAxis.SDL_CONTROLLER_AXIS_INVALID: + return 0; + + case SDL_GameControllerAxis.SDL_CONTROLLER_AXIS_LEFTX: + return JoystickAxisSource.GamePadLeftStickX; + + case SDL_GameControllerAxis.SDL_CONTROLLER_AXIS_LEFTY: + return JoystickAxisSource.GamePadLeftStickY; + + case SDL_GameControllerAxis.SDL_CONTROLLER_AXIS_TRIGGERLEFT: + return JoystickAxisSource.GamePadLeftTrigger; + + case SDL_GameControllerAxis.SDL_CONTROLLER_AXIS_RIGHTX: + return JoystickAxisSource.GamePadRightStickX; + + case SDL_GameControllerAxis.SDL_CONTROLLER_AXIS_RIGHTY: + return JoystickAxisSource.GamePadRightStickY; + + case SDL_GameControllerAxis.SDL_CONTROLLER_AXIS_TRIGGERRIGHT: + return JoystickAxisSource.GamePadRightTrigger; + } + } + + public static JoystickButton ToJoystickButton(this SDL_GameControllerButton button) + { + switch (button) + { + default: + case SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_INVALID: + return 0; + + case SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_A: + return JoystickButton.GamePadA; + + case SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_B: + return JoystickButton.GamePadB; + + case SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_X: + return JoystickButton.GamePadX; + + case SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_Y: + return JoystickButton.GamePadY; + + case SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_BACK: + return JoystickButton.GamePadBack; + + case SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_GUIDE: + return JoystickButton.GamePadGuide; + + case SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_START: + return JoystickButton.GamePadStart; + + case SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_LEFTSTICK: + return JoystickButton.GamePadLeftStick; + + case SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_RIGHTSTICK: + return JoystickButton.GamePadRightStick; + + case SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_LEFTSHOULDER: + return JoystickButton.GamePadLeftShoulder; + + case SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_RIGHTSHOULDER: + return JoystickButton.GamePadRightShoulder; + + case SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_DPAD_UP: + return JoystickButton.GamePadDPadUp; + + case SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_DPAD_DOWN: + return JoystickButton.GamePadDPadDown; + + case SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_DPAD_LEFT: + return JoystickButton.GamePadDPadLeft; + + case SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_DPAD_RIGHT: + return JoystickButton.GamePadDPadRight; + } + } + + public static SDL_Rect ToSDLRect(this RectangleI rectangle) => + new SDL_Rect + { + x = rectangle.X, + y = rectangle.Y, + h = rectangle.Height, + w = rectangle.Width, + }; + + /// + /// Converts a UTF-8 byte pointer to a string. + /// + /// Most commonly used with SDL text events. + /// Pointer to UTF-8 encoded byte array. + /// The resulting string + /// true if the was successfully converted to a string. + public static unsafe bool TryGetStringFromBytePointer(byte* bytePointer, out string str) + { + IntPtr ptr = new IntPtr(bytePointer); + + if (ptr == IntPtr.Zero) + { + str = null; + return false; + } + + str = Marshal.PtrToStringUTF8(ptr) ?? string.Empty; + return true; + } + + public static DisplayMode ToDisplayMode(this SDL_DisplayMode mode, int displayIndex) + { + SDL_PixelFormatEnumToMasks(mode.format, out int bpp, out _, out _, out _, out _); + return new DisplayMode(SDL_GetPixelFormatName(mode.format), new Size(mode.w, mode.h), bpp, mode.refresh_rate, displayIndex); + } + + public static string ReadableName(this SDL_LogCategory category) + { + switch (category) + { + case SDL_LogCategory.SDL_LOG_CATEGORY_APPLICATION: + return "application"; + + case SDL_LogCategory.SDL_LOG_CATEGORY_ERROR: + return "error"; + + case SDL_LogCategory.SDL_LOG_CATEGORY_ASSERT: + return "assert"; + + case SDL_LogCategory.SDL_LOG_CATEGORY_SYSTEM: + return "system"; + + case SDL_LogCategory.SDL_LOG_CATEGORY_AUDIO: + return "audio"; + + case SDL_LogCategory.SDL_LOG_CATEGORY_VIDEO: + return "video"; + + case SDL_LogCategory.SDL_LOG_CATEGORY_RENDER: + return "render"; + + case SDL_LogCategory.SDL_LOG_CATEGORY_INPUT: + return "input"; + + case SDL_LogCategory.SDL_LOG_CATEGORY_TEST: + return "test"; + + default: + return "unknown"; + } + } + + public static string ReadableName(this SDL_LogPriority priority) + { + switch (priority) + { + case SDL_LogPriority.SDL_LOG_PRIORITY_VERBOSE: + return "verbose"; + + case SDL_LogPriority.SDL_LOG_PRIORITY_DEBUG: + return "debug"; + + case SDL_LogPriority.SDL_LOG_PRIORITY_INFO: + return "info"; + + case SDL_LogPriority.SDL_LOG_PRIORITY_WARN: + return "warn"; + + case SDL_LogPriority.SDL_LOG_PRIORITY_ERROR: + return "error"; + + case SDL_LogPriority.SDL_LOG_PRIORITY_CRITICAL: + return "critical"; + + default: + return "unknown"; + } + } + + /// + /// Gets the readable string for this . + /// + /// + /// string in the format of 1920x1080@60. + /// + public static string ReadableString(this SDL_DisplayMode mode) => $"{mode.w}x{mode.h}@{mode.refresh_rate}"; + + /// + /// Gets the SDL error, and then clears it. + /// + public static string GetAndClearError() + { + string error = SDL_GetError(); + SDL_ClearError(); + return error; + } + + private static bool tryGetTouchDeviceIndex(long touchId, out int index) + { + int n = SDL_GetNumTouchDevices(); + + for (int i = 0; i < n; i++) + { + long currentTouchId = SDL_GetTouchDevice(i); + + if (touchId == currentTouchId) + { + index = i; + return true; + } + } + + index = -1; + return false; + } + + /// + /// Gets the of the touch device for this . + /// + /// + /// On Windows, this will return "touch" for touchscreen events or "pen" for pen/tablet events. + /// + public static bool TryGetTouchName(this SDL_TouchFingerEvent e, out string name) + { + if (tryGetTouchDeviceIndex(e.touchId, out int index)) + { + name = SDL_GetTouchName(index); + return name != null; + } + + name = null; + return false; + } + } +} diff --git a/osu.Framework/Platform/SDL2/SDL2GraphicsSurface.cs b/osu.Framework/Platform/SDL2/SDL2GraphicsSurface.cs new file mode 100644 index 0000000000..c0f5e0b7d9 --- /dev/null +++ b/osu.Framework/Platform/SDL2/SDL2GraphicsSurface.cs @@ -0,0 +1,216 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using System.Drawing; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using osuTK.Graphics; +using osuTK.Graphics.ES30; +using static SDL2.SDL; + +namespace osu.Framework.Platform.SDL2 +{ + internal class SDL2GraphicsSurface : IGraphicsSurface, IOpenGLGraphicsSurface, IMetalGraphicsSurface, ILinuxGraphicsSurface + { + private readonly SDL2Window window; + + private IntPtr context; + + public IntPtr WindowHandle => window.WindowHandle; + public IntPtr DisplayHandle => window.DisplayHandle; + + public GraphicsSurfaceType Type { get; } + + public SDL2GraphicsSurface(SDL2Window window, GraphicsSurfaceType surfaceType) + { + this.window = window; + Type = surfaceType; + + switch (surfaceType) + { + case GraphicsSurfaceType.OpenGL: + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_RED_SIZE, 8); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_GREEN_SIZE, 8); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_BLUE_SIZE, 8); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_ACCUM_ALPHA_SIZE, 0); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_DEPTH_SIZE, 16); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_STENCIL_SIZE, 8); + break; + + case GraphicsSurfaceType.Vulkan: + case GraphicsSurfaceType.Metal: + case GraphicsSurfaceType.Direct3D11: + break; + + default: + throw new ArgumentException($"Unexpected graphics surface: {Type}.", nameof(surfaceType)); + } + } + + public void Initialise() + { + if (Type == GraphicsSurfaceType.OpenGL) + initialiseOpenGL(); + } + + public Size GetDrawableSize() + { + SDL_GetWindowSizeInPixels(window.SDLWindowHandle, out int width, out int height); + return new Size(width, height); + } + + #region OpenGL-specific implementation + + private void initialiseOpenGL() + { + if (RuntimeInfo.IsMobile) + { + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_CONTEXT_PROFILE_MASK, SDL_GLprofile.SDL_GL_CONTEXT_PROFILE_ES); + + // Minimum OpenGL version for ES profile: + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_CONTEXT_MINOR_VERSION, 0); + } + else + { + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_CONTEXT_PROFILE_MASK, SDL_GLprofile.SDL_GL_CONTEXT_PROFILE_CORE); + + // Minimum OpenGL version for core profile: + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_CONTEXT_MINOR_VERSION, 2); + } + + context = SDL_GL_CreateContext(window.SDLWindowHandle); + + if (context == IntPtr.Zero) + throw new InvalidOperationException($"Failed to create an SDL2 GL context ({SDL_GetError()})"); + + SDL_GL_MakeCurrent(window.SDLWindowHandle, context); + + loadBindings(); + } + + private void loadBindings() + { + loadEntryPoints(new osuTK.Graphics.OpenGL.GL()); + loadEntryPoints(new osuTK.Graphics.OpenGL4.GL()); + loadEntryPoints(new osuTK.Graphics.ES11.GL()); + loadEntryPoints(new osuTK.Graphics.ES20.GL()); + loadEntryPoints(new GL()); + } + + private unsafe void loadEntryPoints(GraphicsBindingsBase bindings) + { + var type = bindings.GetType(); + var pointsInfo = type.GetRuntimeFields().First(x => x.Name == "_EntryPointsInstance"); + var namesInfo = type.GetRuntimeFields().First(x => x.Name == "_EntryPointNamesInstance"); + var offsetsInfo = type.GetRuntimeFields().First(x => x.Name == "_EntryPointNameOffsetsInstance"); + + IntPtr[]? entryPointsInstance = (IntPtr[]?)pointsInfo.GetValue(bindings); + byte[]? entryPointNamesInstance = (byte[]?)namesInfo.GetValue(bindings); + int[]? entryPointNameOffsetsInstance = (int[]?)offsetsInfo.GetValue(bindings); + + Debug.Assert(entryPointsInstance != null); + Debug.Assert(entryPointNameOffsetsInstance != null); + + fixed (byte* name = entryPointNamesInstance) + { + for (int i = 0; i < entryPointsInstance.Length; i++) + { + byte* ptr = name + entryPointNameOffsetsInstance[i]; + string? str = Marshal.PtrToStringAnsi(new IntPtr(ptr)); + + Debug.Assert(str != null); + entryPointsInstance[i] = getProcAddress(str); + } + } + + pointsInfo.SetValue(bindings, entryPointsInstance); + } + + private IntPtr getProcAddress(string symbol) + { + const int error_category = (int)SDL_LogCategory.SDL_LOG_CATEGORY_ERROR; + SDL_LogPriority oldPriority = SDL_LogGetPriority(error_category); + + // Prevent logging calls to SDL_GL_GetProcAddress() that fail on systems which don't have the requested symbol (typically macOS). + SDL_LogSetPriority(error_category, SDL_LogPriority.SDL_LOG_PRIORITY_INFO); + + IntPtr ret = SDL_GL_GetProcAddress(symbol); + + // Reset the logging behaviour. + SDL_LogSetPriority(error_category, oldPriority); + + return ret; + } + + int? IOpenGLGraphicsSurface.BackbufferFramebuffer + { + get + { + if (window.SDLWindowHandle == IntPtr.Zero) + return null; + + var wmInfo = window.GetWindowSystemInformation(); + + switch (wmInfo.subsystem) + { + case SDL_SYSWM_TYPE.SDL_SYSWM_UIKIT: + return (int)wmInfo.info.uikit.framebuffer; + } + + return null; + } + } + + // cache value locally as requesting from SDL is not free. + // it is assumed that we are the only thing changing vsync modes. + private bool? verticalSync; + + bool IOpenGLGraphicsSurface.VerticalSync + { + get + { + if (verticalSync != null) + return verticalSync.Value; + + return (verticalSync = SDL_GL_GetSwapInterval() != 0).Value; + } + set + { + if (RuntimeInfo.IsDesktop) + { + SDL_GL_SetSwapInterval(value ? 1 : 0); + verticalSync = value; + } + } + } + + IntPtr IOpenGLGraphicsSurface.WindowContext => context; + IntPtr IOpenGLGraphicsSurface.CurrentContext => SDL_GL_GetCurrentContext(); + + void IOpenGLGraphicsSurface.SwapBuffers() => SDL_GL_SwapWindow(window.SDLWindowHandle); + void IOpenGLGraphicsSurface.CreateContext() => SDL_GL_CreateContext(window.SDLWindowHandle); + void IOpenGLGraphicsSurface.DeleteContext(IntPtr context) => SDL_GL_DeleteContext(context); + void IOpenGLGraphicsSurface.MakeCurrent(IntPtr context) => SDL_GL_MakeCurrent(window.SDLWindowHandle, context); + void IOpenGLGraphicsSurface.ClearCurrent() => SDL_GL_MakeCurrent(window.SDLWindowHandle, IntPtr.Zero); + IntPtr IOpenGLGraphicsSurface.GetProcAddress(string symbol) => getProcAddress(symbol); + + #endregion + + #region Metal-specific implementation + + IntPtr IMetalGraphicsSurface.CreateMetalView() => SDL_Metal_CreateView(window.SDLWindowHandle); + + #endregion + + #region Linux-specific implementation + + bool ILinuxGraphicsSurface.IsWayland => window.IsWayland; + + #endregion + } +} diff --git a/osu.Framework/Platform/SDL2/SDL2Structs.cs b/osu.Framework/Platform/SDL2/SDL2Structs.cs new file mode 100644 index 0000000000..3e9f4f5b37 --- /dev/null +++ b/osu.Framework/Platform/SDL2/SDL2Structs.cs @@ -0,0 +1,50 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Runtime.InteropServices; +using static SDL2.SDL; + +// ReSharper disable MemberCanBePrivate.Global +// (Some members not currently used) + +// ReSharper disable InconsistentNaming +// ReSharper disable IdentifierTypo +// (Mimics SDL and SDL2-CS naming) + +#pragma warning disable IDE1006 // Naming style + +namespace osu.Framework.Platform.SDL2 +{ + internal static class SDL2Structs + { + [StructLayout(LayoutKind.Sequential)] + internal struct INTERNAL_windows_wmmsg + { + public IntPtr hwnd; + public uint msg; + public ulong wParam; + public long lParam; + } + + [StructLayout(LayoutKind.Explicit)] + internal struct INTERNAL_SysWMmsgUnion + { + [FieldOffset(0)] + public INTERNAL_windows_wmmsg win; + + // could add more native events here if required + } + + /// + /// Member msg of . + /// + [StructLayout(LayoutKind.Sequential)] + public struct SDL_SysWMmsg + { + public SDL_version version; + public SDL_SYSWM_TYPE subsystem; + public INTERNAL_SysWMmsgUnion msg; + } + } +} diff --git a/osu.Framework/Platform/SDL2/SDL2Window.cs b/osu.Framework/Platform/SDL2/SDL2Window.cs new file mode 100644 index 0000000000..1efa878477 --- /dev/null +++ b/osu.Framework/Platform/SDL2/SDL2Window.cs @@ -0,0 +1,689 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.IO; +using System.Runtime.InteropServices; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Configuration; +using osu.Framework.Extensions.EnumExtensions; +using osu.Framework.Extensions.ImageExtensions; +using osu.Framework.Logging; +using osu.Framework.Threading; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using Image = SixLabors.ImageSharp.Image; +using Point = System.Drawing.Point; +using static SDL2.SDL; + +namespace osu.Framework.Platform.SDL2 +{ + /// + /// Default implementation of a window, using SDL for windowing and graphics support. + /// + internal abstract partial class SDL2Window : ISDLWindow + { + internal IntPtr SDLWindowHandle { get; private set; } = IntPtr.Zero; + + private readonly SDL2GraphicsSurface graphicsSurface; + IGraphicsSurface IWindow.GraphicsSurface => graphicsSurface; + + /// + /// Returns true if window has been created. + /// Returns false if the window has not yet been created, or has been closed. + /// + public bool Exists { get; private set; } + + public BindableSafeArea SafeAreaPadding { get; } = new BindableSafeArea(); + + public virtual Point PointToClient(Point point) => point; + + public virtual Point PointToScreen(Point point) => point; + + private const int default_width = 1366; + private const int default_height = 768; + + private const int default_icon_size = 256; + + /// + /// Scheduler for actions to run before the next event loop. + /// + private readonly Scheduler commandScheduler = new Scheduler(); + + /// + /// Scheduler for actions to run at the end of the current event loop. + /// + protected readonly Scheduler EventScheduler = new Scheduler(); + + private string title = string.Empty; + + /// + /// Gets and sets the window title. + /// + public string Title + { + get => title; + set + { + title = value; + ScheduleCommand(() => SDL_SetWindowTitle(SDLWindowHandle, title)); + } + } + + /// + /// Whether the current display server is Wayland. + /// + internal bool IsWayland + { + get + { + if (SDLWindowHandle == IntPtr.Zero) + return false; + + return GetWindowSystemInformation().subsystem == SDL_SYSWM_TYPE.SDL_SYSWM_WAYLAND; + } + } + + /// + /// Gets the native window handle as provided by the operating system. + /// + public IntPtr WindowHandle + { + get + { + if (SDLWindowHandle == IntPtr.Zero) + return IntPtr.Zero; + + var wmInfo = GetWindowSystemInformation(); + + // Window handle is selected per subsystem as defined at: + // https://wiki.libsdl.org/SDL_SysWMinfo + switch (wmInfo.subsystem) + { + case SDL_SYSWM_TYPE.SDL_SYSWM_WINDOWS: + return wmInfo.info.win.window; + + case SDL_SYSWM_TYPE.SDL_SYSWM_X11: + return wmInfo.info.x11.window; + + case SDL_SYSWM_TYPE.SDL_SYSWM_DIRECTFB: + return wmInfo.info.dfb.window; + + case SDL_SYSWM_TYPE.SDL_SYSWM_COCOA: + return wmInfo.info.cocoa.window; + + case SDL_SYSWM_TYPE.SDL_SYSWM_UIKIT: + return wmInfo.info.uikit.window; + + case SDL_SYSWM_TYPE.SDL_SYSWM_WAYLAND: + return wmInfo.info.wl.surface; + + case SDL_SYSWM_TYPE.SDL_SYSWM_ANDROID: + return wmInfo.info.android.window; + + default: + return IntPtr.Zero; + } + } + } + + public IntPtr DisplayHandle + { + get + { + if (SDLWindowHandle == IntPtr.Zero) + return IntPtr.Zero; + + var wmInfo = GetWindowSystemInformation(); + + switch (wmInfo.subsystem) + { + case SDL_SYSWM_TYPE.SDL_SYSWM_X11: + return wmInfo.info.x11.display; + + case SDL_SYSWM_TYPE.SDL_SYSWM_WAYLAND: + return wmInfo.info.wl.display; + + default: + return IntPtr.Zero; + } + } + } + + internal SDL_SysWMinfo GetWindowSystemInformation() + { + if (SDLWindowHandle == IntPtr.Zero) + return default; + + var wmInfo = new SDL_SysWMinfo(); + SDL_GetVersion(out wmInfo.version); + SDL_GetWindowWMInfo(SDLWindowHandle, ref wmInfo); + return wmInfo; + } + + public bool CapsLockPressed => SDL_GetModState().HasFlagFast(SDL_Keymod.KMOD_CAPS); + + public bool KeyboardAttached => true; // SDL2 has no way of knowing whether a keyboard is attached, assume true. + + // references must be kept to avoid GC, see https://stackoverflow.com/a/6193914 + + [UsedImplicitly] + private SDL_LogOutputFunction logOutputDelegate; + + [UsedImplicitly] + private SDL_EventFilter? eventFilterDelegate; + + [UsedImplicitly] + private SDL_EventFilter? eventWatchDelegate; + + /// + /// Represents a handle to this instance, used for unmanaged callbacks. + /// + protected ObjectHandle ObjectHandle { get; private set; } + + protected SDL2Window(GraphicsSurfaceType surfaceType, string appName) + { + ObjectHandle = new ObjectHandle(this, GCHandleType.Normal); + + SDL_SetHint(SDL_HINT_APP_NAME, appName); + + if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) < 0) + { + throw new InvalidOperationException($"Failed to initialise SDL: {SDL_GetError()}"); + } + + SDL_GetVersion(out SDL_version version); + Logger.Log($@"SDL2 Initialized + SDL2 Version: {version.major}.{version.minor}.{version.patch} + SDL2 Revision: {SDL_GetRevision()}"); + + SDL_LogSetPriority((int)SDL_LogCategory.SDL_LOG_CATEGORY_ERROR, SDL_LogPriority.SDL_LOG_PRIORITY_DEBUG); + SDL_LogSetOutputFunction(logOutputDelegate = logOutput, IntPtr.Zero); + + graphicsSurface = new SDL2GraphicsSurface(this, surfaceType); + + CursorStateBindable.ValueChanged += evt => + { + updateCursorVisibility(!evt.NewValue.HasFlagFast(CursorState.Hidden)); + updateCursorConfinement(); + }; + + populateJoysticks(); + } + + [MonoPInvokeCallback(typeof(SDL_LogOutputFunction))] + private static void logOutput(IntPtr _, int categoryInt, SDL_LogPriority priority, IntPtr messagePtr) + { + var category = (SDL_LogCategory)categoryInt; + string? message = Marshal.PtrToStringUTF8(messagePtr); + + Logger.Log($@"SDL {category.ReadableName()} log [{priority.ReadableName()}]: {message}"); + } + + public void SetupWindow(FrameworkConfigManager config) + { + setupWindowing(config); + setupInput(config); + } + + public virtual void Create() + { + SDL_WindowFlags flags = SDL_WindowFlags.SDL_WINDOW_RESIZABLE | + SDL_WindowFlags.SDL_WINDOW_ALLOW_HIGHDPI | + SDL_WindowFlags.SDL_WINDOW_HIDDEN; // shown after first swap to avoid white flash on startup (windows) + + flags |= WindowState.ToFlags(); + flags |= graphicsSurface.Type.ToFlags(); + + SDL_SetHint(SDL_HINT_WINDOWS_NO_CLOSE_ON_ALT_F4, "1"); + SDL_SetHint(SDL_HINT_IME_SHOW_UI, "1"); + SDL_SetHint(SDL_HINT_MOUSE_RELATIVE_MODE_CENTER, "0"); + SDL_SetHint(SDL_HINT_TOUCH_MOUSE_EVENTS, "0"); // disable touch events generating synthetic mouse events on desktop platforms + SDL_SetHint(SDL_HINT_MOUSE_TOUCH_EVENTS, "0"); // disable mouse events generating synthetic touch events on mobile platforms + + // we want text input to only be active when SDL2DesktopWindowTextInput is active. + // SDL activates it by default on some platforms: https://github.com/libsdl-org/SDL/blob/release-2.0.16/src/video/SDL_video.c#L573-L582 + // so we deactivate it on startup. + SDL_StopTextInput(); + + SDLWindowHandle = SDL_CreateWindow(title, Position.X, Position.Y, Size.Width, Size.Height, flags); + + if (SDLWindowHandle == IntPtr.Zero) + throw new InvalidOperationException($"Failed to create SDL window. SDL Error: {SDL_GetError()}"); + + graphicsSurface.Initialise(); + + initialiseWindowingAfterCreation(); + Exists = true; + } + + /// + /// Starts the window's run loop. + /// + public void Run() + { + SDL_SetEventFilter(eventFilterDelegate = eventFilter, ObjectHandle.Handle); + SDL_AddEventWatch(eventWatchDelegate = eventWatch, ObjectHandle.Handle); + + RunMainLoop(); + } + + /// + /// Runs the main window loop. + /// + /// + /// By default this will block and indefinitely call as long as the window . + /// Once the main loop finished running, cleanup logic will run. + /// + /// This may be overridden for special use cases, like mobile platforms which delegate execution of frames to the OS + /// and don't require any kind of exit logic to exist. + /// + protected virtual void RunMainLoop() + { + while (Exists) + RunFrame(); + + Exited?.Invoke(); + Close(); + SDL_Quit(); + } + + /// + /// Run a single frame. + /// + protected void RunFrame() + { + commandScheduler.Update(); + + if (!Exists) + return; + + if (pendingWindowState != null) + updateAndFetchWindowSpecifics(); + + pollSDLEvents(); + + if (!cursorInWindow.Value) + pollMouse(); + + EventScheduler.Update(); + Update?.Invoke(); + } + + /// + /// Handles s fired from the SDL event filter. + /// + /// + /// As per SDL's recommendation, application events should always be handled via the event filter. + /// See: https://wiki.libsdl.org/SDL2/SDL_EventType#android_ios_and_winrt_events + /// + protected virtual void HandleEventFromFilter(SDL_Event evt) + { + switch (evt.type) + { + case SDL_EventType.SDL_APP_TERMINATING: + handleQuitEvent(evt.quit); + break; + + case SDL_EventType.SDL_APP_DIDENTERBACKGROUND: + Suspended?.Invoke(); + break; + + case SDL_EventType.SDL_APP_WILLENTERFOREGROUND: + Resumed?.Invoke(); + break; + + case SDL_EventType.SDL_APP_LOWMEMORY: + LowOnMemory?.Invoke(); + break; + } + } + + protected void HandleEventFromWatch(SDL_Event evt) + { + switch (evt.type) + { + case SDL_EventType.SDL_WINDOWEVENT: + // polling via SDL_PollEvent blocks on resizes (https://stackoverflow.com/a/50858339) + if (evt.window.windowEvent == SDL_WindowEventID.SDL_WINDOWEVENT_RESIZED && !updatingWindowStateAndSize) + fetchWindowSize(); + + break; + } + } + + [MonoPInvokeCallback(typeof(SDL_EventFilter))] + private static int eventFilter(IntPtr userdata, IntPtr eventPtr) + { + var handle = new ObjectHandle(userdata); + if (handle.GetTarget(out SDL2Window window)) + window.HandleEventFromFilter(Marshal.PtrToStructure(eventPtr)); + + return 1; + } + + [MonoPInvokeCallback(typeof(SDL_EventFilter))] + private static int eventWatch(IntPtr userdata, IntPtr eventPtr) + { + var handle = new ObjectHandle(userdata); + if (handle.GetTarget(out SDL2Window window)) + window.HandleEventFromWatch(Marshal.PtrToStructure(eventPtr)); + + return 1; + } + + private bool firstDraw = true; + + public void OnDraw() + { + if (!firstDraw) + return; + + Visible = true; + firstDraw = false; + } + + /// + /// Forcefully closes the window. + /// + public void Close() + { + if (Exists) + { + // Close will be called as part of finishing the Run loop. + ScheduleCommand(() => Exists = false); + } + else + { + if (SDLWindowHandle != IntPtr.Zero) + { + SDL_DestroyWindow(SDLWindowHandle); + SDLWindowHandle = IntPtr.Zero; + } + } + } + + public void Raise() => ScheduleCommand(() => + { + var flags = (SDL_WindowFlags)SDL_GetWindowFlags(SDLWindowHandle); + + if (flags.HasFlagFast(SDL_WindowFlags.SDL_WINDOW_MINIMIZED)) + SDL_RestoreWindow(SDLWindowHandle); + + SDL_RaiseWindow(SDLWindowHandle); + }); + + public void Hide() => ScheduleCommand(() => + { + SDL_HideWindow(SDLWindowHandle); + }); + + public void Show() => ScheduleCommand(() => + { + SDL_ShowWindow(SDLWindowHandle); + }); + + public void Flash(bool flashUntilFocused = false) => ScheduleCommand(() => + { + if (isActive.Value) + return; + + if (!RuntimeInfo.IsDesktop) + return; + + SDL_FlashWindow(SDLWindowHandle, flashUntilFocused + ? SDL_FlashOperation.SDL_FLASH_UNTIL_FOCUSED + : SDL_FlashOperation.SDL_FLASH_BRIEFLY); + }); + + public void CancelFlash() => ScheduleCommand(() => + { + if (!RuntimeInfo.IsDesktop) + return; + + SDL_FlashWindow(SDLWindowHandle, SDL_FlashOperation.SDL_FLASH_CANCEL); + }); + + public void EnableScreenSuspension() => ScheduleCommand(SDL_EnableScreenSaver); + + public void DisableScreenSuspension() => ScheduleCommand(SDL_DisableScreenSaver); + + /// + /// Attempts to set the window's icon to the specified image. + /// + /// An to set as the window icon. + private unsafe void setSDLIcon(Image image) + { + var pixelMemory = image.CreateReadOnlyPixelMemory(); + var imageSize = image.Size; + + ScheduleCommand(() => + { + var pixelSpan = pixelMemory.Span; + + IntPtr surface; + fixed (Rgba32* ptr = pixelSpan) + surface = SDL_CreateRGBSurfaceFrom(new IntPtr(ptr), imageSize.Width, imageSize.Height, 32, imageSize.Width * 4, 0xff, 0xff00, 0xff0000, 0xff000000); + + SDL_SetWindowIcon(SDLWindowHandle, surface); + SDL_FreeSurface(surface); + }); + } + + #region SDL Event Handling + + /// + /// Adds an to the expected to handle event callbacks. + /// + /// The to execute. + protected void ScheduleEvent(Action action) => EventScheduler.Add(action, false); + + protected void ScheduleCommand(Action action) => commandScheduler.Add(action, false); + + private const int events_per_peep = 64; + private readonly SDL_Event[] events = new SDL_Event[events_per_peep]; + + /// + /// Poll for all pending events. + /// + private void pollSDLEvents() + { + SDL_PumpEvents(); + + int eventsRead; + + do + { + eventsRead = SDL_PeepEvents(events, events_per_peep, SDL_eventaction.SDL_GETEVENT, SDL_EventType.SDL_FIRSTEVENT, SDL_EventType.SDL_LASTEVENT); + for (int i = 0; i < eventsRead; i++) + HandleEvent(events[i]); + } while (eventsRead == events_per_peep); + } + + /// + /// Handles s polled on the main thread. + /// + protected virtual void HandleEvent(SDL_Event e) + { + switch (e.type) + { + case SDL_EventType.SDL_QUIT: + handleQuitEvent(e.quit); + break; + + case SDL_EventType.SDL_DISPLAYEVENT: + handleDisplayEvent(e.display); + break; + + case SDL_EventType.SDL_WINDOWEVENT: + handleWindowEvent(e.window); + break; + + case SDL_EventType.SDL_KEYDOWN: + case SDL_EventType.SDL_KEYUP: + handleKeyboardEvent(e.key); + break; + + case SDL_EventType.SDL_TEXTEDITING: + HandleTextEditingEvent(e.edit); + break; + + case SDL_EventType.SDL_TEXTINPUT: + HandleTextInputEvent(e.text); + break; + + case SDL_EventType.SDL_KEYMAPCHANGED: + handleKeymapChangedEvent(); + break; + + case SDL_EventType.SDL_MOUSEMOTION: + handleMouseMotionEvent(e.motion); + break; + + case SDL_EventType.SDL_MOUSEBUTTONDOWN: + case SDL_EventType.SDL_MOUSEBUTTONUP: + handleMouseButtonEvent(e.button); + break; + + case SDL_EventType.SDL_MOUSEWHEEL: + handleMouseWheelEvent(e.wheel); + break; + + case SDL_EventType.SDL_JOYAXISMOTION: + handleJoyAxisEvent(e.jaxis); + break; + + case SDL_EventType.SDL_JOYBALLMOTION: + handleJoyBallEvent(e.jball); + break; + + case SDL_EventType.SDL_JOYHATMOTION: + handleJoyHatEvent(e.jhat); + break; + + case SDL_EventType.SDL_JOYBUTTONDOWN: + case SDL_EventType.SDL_JOYBUTTONUP: + handleJoyButtonEvent(e.jbutton); + break; + + case SDL_EventType.SDL_JOYDEVICEADDED: + case SDL_EventType.SDL_JOYDEVICEREMOVED: + handleJoyDeviceEvent(e.jdevice); + break; + + case SDL_EventType.SDL_CONTROLLERAXISMOTION: + handleControllerAxisEvent(e.caxis); + break; + + case SDL_EventType.SDL_CONTROLLERBUTTONDOWN: + case SDL_EventType.SDL_CONTROLLERBUTTONUP: + handleControllerButtonEvent(e.cbutton); + break; + + case SDL_EventType.SDL_CONTROLLERDEVICEADDED: + case SDL_EventType.SDL_CONTROLLERDEVICEREMOVED: + case SDL_EventType.SDL_CONTROLLERDEVICEREMAPPED: + handleControllerDeviceEvent(e.cdevice); + break; + + case SDL_EventType.SDL_FINGERDOWN: + case SDL_EventType.SDL_FINGERUP: + case SDL_EventType.SDL_FINGERMOTION: + HandleTouchFingerEvent(e.tfinger); + break; + + case SDL_EventType.SDL_DROPFILE: + case SDL_EventType.SDL_DROPTEXT: + case SDL_EventType.SDL_DROPBEGIN: + case SDL_EventType.SDL_DROPCOMPLETE: + handleDropEvent(e.drop); + break; + } + } + + // ReSharper disable once UnusedParameter.Local + private void handleQuitEvent(SDL_QuitEvent evtQuit) => ExitRequested?.Invoke(); + + #endregion + + public void SetIconFromStream(Stream imageStream) + { + using (var ms = new MemoryStream()) + { + imageStream.CopyTo(ms); + ms.Position = 0; + + try + { + SetIconFromImage(Image.Load(ms.GetBuffer())); + } + catch + { + if (IconGroup.TryParse(ms.GetBuffer(), out var iconGroup)) + SetIconFromGroup(iconGroup); + } + } + } + + internal virtual void SetIconFromGroup(IconGroup iconGroup) + { + // LoadRawIcon returns raw PNG data if available, which avoids any Windows-specific pinvokes + byte[]? bytes = iconGroup.LoadRawIcon(default_icon_size, default_icon_size); + if (bytes == null) + return; + + SetIconFromImage(Image.Load(bytes)); + } + + internal virtual void SetIconFromImage(Image iconImage) => setSDLIcon(iconImage); + + #region Events + + /// + /// Invoked once every window event loop. + /// + public event Action? Update; + + /// + /// Invoked when the application associated with this has been suspended. + /// + public event Action? Suspended; + + /// + /// Invoked when the application associated with this has been resumed from suspension. + /// + public event Action? Resumed; + + /// + /// Invoked when the operating system is low on memory, in order for the application to free some. + /// + public event Action? LowOnMemory; + + /// + /// Invoked when the window close (X) button or another platform-native exit action has been pressed. + /// + public event Action? ExitRequested; + + /// + /// Invoked when the window is about to close. + /// + public event Action? Exited; + + /// + /// Invoked when the user drops a file into the window. + /// + public event Action? DragDrop; + + #endregion + + public void Dispose() + { + Close(); + SDL_Quit(); + + ObjectHandle.Dispose(); + } + } +} diff --git a/osu.Framework/Platform/SDL2/SDL2Window_Input.cs b/osu.Framework/Platform/SDL2/SDL2Window_Input.cs new file mode 100644 index 0000000000..43a62dd8ba --- /dev/null +++ b/osu.Framework/Platform/SDL2/SDL2Window_Input.cs @@ -0,0 +1,657 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using osu.Framework.Bindables; +using osu.Framework.Configuration; +using osu.Framework.Extensions.EnumExtensions; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Input; +using osu.Framework.Input.States; +using osu.Framework.Logging; +using osuTK; +using osuTK.Input; +using RectangleF = osu.Framework.Graphics.Primitives.RectangleF; +using static SDL2.SDL; + +namespace osu.Framework.Platform.SDL2 +{ + internal partial class SDL2Window + { + private void setupInput(FrameworkConfigManager config) + { + config.BindWith(FrameworkSetting.ConfineMouseMode, ConfineMouseMode); + + WindowMode.BindValueChanged(_ => updateConfineMode()); + ConfineMouseMode.BindValueChanged(_ => updateConfineMode()); + } + + private bool relativeMouseMode; + + /// + /// Set the state of SDL2's RelativeMouseMode (https://wiki.libsdl.org/SDL_SetRelativeMouseMode). + /// On all platforms, this will lock the mouse to the window (although escaping by setting is still possible via a local implementation). + /// On windows, this will use raw input if available. + /// + public bool RelativeMouseMode + { + get => relativeMouseMode; + set + { + if (relativeMouseMode == value) + return; + + if (value && !CursorState.HasFlagFast(CursorState.Hidden)) + throw new InvalidOperationException($"Cannot set {nameof(RelativeMouseMode)} to true when the cursor is not hidden via {nameof(CursorState)}."); + + relativeMouseMode = value; + ScheduleCommand(() => SDL_SetRelativeMouseMode(value ? SDL_bool.SDL_TRUE : SDL_bool.SDL_FALSE)); + updateCursorConfinement(); + } + } + + /// + /// Controls whether the mouse is automatically captured when buttons are pressed and the cursor is outside the window. + /// Only works with disabled. + /// + /// + /// If the cursor leaves the window while it's captured, is not sent until the button(s) are released. + /// And if the cursor leaves and enters the window while captured, is not sent either. + /// We disable relative mode when the cursor exits window bounds (not on the event), but we only enable it again on . + /// The above culminate in staying off when the cursor leaves and enters the window bounds when any buttons are pressed. + /// This is an invalid state, as the cursor is inside the window, and is off. + /// + public bool MouseAutoCapture + { + set => ScheduleCommand(() => SDL_SetHint(SDL_HINT_MOUSE_AUTO_CAPTURE, value ? "1" : "0")); + } + + /// + /// Provides a bindable that controls the window's . + /// + public Bindable CursorStateBindable { get; } = new Bindable(); + + public CursorState CursorState + { + get => CursorStateBindable.Value; + set => CursorStateBindable.Value = value; + } + + private RectangleF? cursorConfineRect; + + public RectangleF? CursorConfineRect + { + get => cursorConfineRect; + set + { + cursorConfineRect = value; + updateCursorConfinement(); + } + } + + private readonly Dictionary controllers = new Dictionary(); + + private void updateCursorVisibility(bool cursorVisible) => + ScheduleCommand(() => SDL_ShowCursor(cursorVisible ? SDL_ENABLE : SDL_DISABLE)); + + /// + /// Updates OS cursor confinement based on the current , and . + /// + private void updateCursorConfinement() + { + bool confined = CursorState.HasFlagFast(CursorState.Confined); + + ScheduleCommand(() => SDL_SetWindowGrab(SDLWindowHandle, confined ? SDL_bool.SDL_TRUE : SDL_bool.SDL_FALSE)); + + // Don't use SDL_SetWindowMouseRect when relative mode is enabled, as relative mode already confines the OS cursor to the window. + // This is fine for our use case, as UserInputManager will clamp the mouse position. + if (CursorConfineRect != null && confined && !RelativeMouseMode) + { + var rect = ((RectangleI)(CursorConfineRect / Scale)).ToSDLRect(); + ScheduleCommand(() => SDL_SetWindowMouseRect(SDLWindowHandle, ref rect)); + } + else + { + ScheduleCommand(() => SDL_SetWindowMouseRect(SDLWindowHandle, IntPtr.Zero)); + } + } + + /// + /// Bound to . + /// + public readonly Bindable ConfineMouseMode = new Bindable(); + + private void enqueueJoystickAxisInput(JoystickAxisSource axisSource, short axisValue) + { + // SDL reports axis values in the range short.MinValue to short.MaxValue, so we scale and clamp it to the range of -1f to 1f + float clamped = Math.Clamp((float)axisValue / short.MaxValue, -1f, 1f); + JoystickAxisChanged?.Invoke(axisSource, clamped); + } + + private void enqueueJoystickButtonInput(JoystickButton button, bool isPressed) + { + if (isPressed) + JoystickButtonDown?.Invoke(button); + else + JoystickButtonUp?.Invoke(button); + } + + private Point previousPolledPoint = Point.Empty; + + private SDLButtonMask pressedButtons; + + private void pollMouse() + { + SDLButtonMask globalButtons = (SDLButtonMask)SDL_GetGlobalMouseState(out int x, out int y); + + if (previousPolledPoint.X != x || previousPolledPoint.Y != y) + { + previousPolledPoint = new Point(x, y); + + var pos = WindowMode.Value == Configuration.WindowMode.Windowed ? Position : windowDisplayBounds.Location; + int rx = x - pos.X; + int ry = y - pos.Y; + + MouseMove?.Invoke(new Vector2(rx * Scale, ry * Scale)); + } + + // a button should be released if it was pressed and its current global state differs (its bit in globalButtons is set to 0) + SDLButtonMask buttonsToRelease = pressedButtons & (globalButtons ^ pressedButtons); + + // the outer if just optimises for the common case that there are no buttons to release. + if (buttonsToRelease != SDLButtonMask.None) + { + if (buttonsToRelease.HasFlagFast(SDLButtonMask.Left)) MouseUp?.Invoke(MouseButton.Left); + if (buttonsToRelease.HasFlagFast(SDLButtonMask.Middle)) MouseUp?.Invoke(MouseButton.Middle); + if (buttonsToRelease.HasFlagFast(SDLButtonMask.Right)) MouseUp?.Invoke(MouseButton.Right); + if (buttonsToRelease.HasFlagFast(SDLButtonMask.X1)) MouseUp?.Invoke(MouseButton.Button1); + if (buttonsToRelease.HasFlagFast(SDLButtonMask.X2)) MouseUp?.Invoke(MouseButton.Button2); + } + } + + public virtual void StartTextInput(bool allowIme) => ScheduleCommand(SDL_StartTextInput); + + public void StopTextInput() => ScheduleCommand(SDL_StopTextInput); + + /// + /// Resets internal state of the platform-native IME. + /// This will clear its composition text and prepare it for new input. + /// + public virtual void ResetIme() => ScheduleCommand(() => + { + SDL_StopTextInput(); + SDL_StartTextInput(); + }); + + public void SetTextInputRect(RectangleF rect) => ScheduleCommand(() => + { + var sdlRect = ((RectangleI)(rect / Scale)).ToSDLRect(); + SDL_SetTextInputRect(ref sdlRect); + }); + + #region SDL Event Handling + + private void handleDropEvent(SDL_DropEvent evtDrop) + { + switch (evtDrop.type) + { + case SDL_EventType.SDL_DROPFILE: + string str = UTF8_ToManaged(evtDrop.file, true); + if (str != null) + DragDrop?.Invoke(str); + + break; + } + } + + private readonly long?[] activeTouches = new long?[TouchState.MAX_TOUCH_COUNT]; + + private TouchSource? getTouchSource(long fingerId) + { + for (int i = 0; i < activeTouches.Length; i++) + { + if (fingerId == activeTouches[i]) + return (TouchSource)i; + } + + return null; + } + + private TouchSource? assignNextAvailableTouchSource(long fingerId) + { + for (int i = 0; i < activeTouches.Length; i++) + { + if (activeTouches[i] != null) continue; + + activeTouches[i] = fingerId; + return (TouchSource)i; + } + + // we only handle up to TouchState.MAX_TOUCH_COUNT. Ignore any further touches for now. + return null; + } + + protected virtual void HandleTouchFingerEvent(SDL_TouchFingerEvent evtTfinger) + { + var existingSource = getTouchSource(evtTfinger.fingerId); + + if (evtTfinger.type == SDL_EventType.SDL_FINGERDOWN) + { + Debug.Assert(existingSource == null); + existingSource = assignNextAvailableTouchSource(evtTfinger.fingerId); + } + + if (existingSource == null) + return; + + float x = evtTfinger.x * ClientSize.Width; + float y = evtTfinger.y * ClientSize.Height; + + var touch = new Touch(existingSource.Value, new Vector2(x, y)); + + switch (evtTfinger.type) + { + case SDL_EventType.SDL_FINGERDOWN: + case SDL_EventType.SDL_FINGERMOTION: + TouchDown?.Invoke(touch); + break; + + case SDL_EventType.SDL_FINGERUP: + TouchUp?.Invoke(touch); + activeTouches[(int)existingSource] = null; + break; + } + } + + private void handleControllerDeviceEvent(SDL_ControllerDeviceEvent evtCdevice) + { + switch (evtCdevice.type) + { + case SDL_EventType.SDL_CONTROLLERDEVICEADDED: + addJoystick(evtCdevice.which); + break; + + case SDL_EventType.SDL_CONTROLLERDEVICEREMOVED: + SDL_GameControllerClose(controllers[evtCdevice.which].ControllerHandle); + controllers.Remove(evtCdevice.which); + break; + + case SDL_EventType.SDL_CONTROLLERDEVICEREMAPPED: + if (controllers.TryGetValue(evtCdevice.which, out var state)) + state.PopulateBindings(); + + break; + } + } + + private void handleControllerButtonEvent(SDL_ControllerButtonEvent evtCbutton) + { + var button = ((SDL_GameControllerButton)evtCbutton.button).ToJoystickButton(); + + switch (evtCbutton.type) + { + case SDL_EventType.SDL_CONTROLLERBUTTONDOWN: + enqueueJoystickButtonInput(button, true); + break; + + case SDL_EventType.SDL_CONTROLLERBUTTONUP: + enqueueJoystickButtonInput(button, false); + break; + } + } + + private void handleControllerAxisEvent(SDL_ControllerAxisEvent evtCaxis) => + enqueueJoystickAxisInput(((SDL_GameControllerAxis)evtCaxis.axis).ToJoystickAxisSource(), evtCaxis.axisValue); + + private void addJoystick(int which) + { + int instanceID = SDL_JoystickGetDeviceInstanceID(which); + + // if the joystick is already opened, ignore it + if (controllers.ContainsKey(instanceID)) + return; + + IntPtr joystick = SDL_JoystickOpen(which); + + IntPtr controller = IntPtr.Zero; + if (SDL_IsGameController(which) == SDL_bool.SDL_TRUE) + controller = SDL_GameControllerOpen(which); + + controllers[instanceID] = new SDL2ControllerBindings(joystick, controller); + } + + /// + /// Populates with joysticks that are already connected. + /// + private void populateJoysticks() + { + for (int i = 0; i < SDL_NumJoysticks(); i++) + { + addJoystick(i); + } + } + + private void handleJoyDeviceEvent(SDL_JoyDeviceEvent evtJdevice) + { + switch (evtJdevice.type) + { + case SDL_EventType.SDL_JOYDEVICEADDED: + addJoystick(evtJdevice.which); + break; + + case SDL_EventType.SDL_JOYDEVICEREMOVED: + // if the joystick is already closed, ignore it + if (!controllers.ContainsKey(evtJdevice.which)) + break; + + SDL_JoystickClose(controllers[evtJdevice.which].JoystickHandle); + controllers.Remove(evtJdevice.which); + break; + } + } + + private void handleJoyButtonEvent(SDL_JoyButtonEvent evtJbutton) + { + // if this button exists in the controller bindings, skip it + if (controllers.TryGetValue(evtJbutton.which, out var state) && state.IsJoystickButtonBound(evtJbutton.button)) + return; + + var button = JoystickButton.FirstButton + evtJbutton.button; + + switch (evtJbutton.type) + { + case SDL_EventType.SDL_JOYBUTTONDOWN: + enqueueJoystickButtonInput(button, true); + break; + + case SDL_EventType.SDL_JOYBUTTONUP: + enqueueJoystickButtonInput(button, false); + break; + } + } + + // ReSharper disable once UnusedParameter.Local + private void handleJoyHatEvent(SDL_JoyHatEvent evtJhat) + { + } + + // ReSharper disable once UnusedParameter.Local + private void handleJoyBallEvent(SDL_JoyBallEvent evtJball) + { + } + + private void handleJoyAxisEvent(SDL_JoyAxisEvent evtJaxis) + { + // if this axis exists in the controller bindings, skip it + if (controllers.TryGetValue(evtJaxis.which, out var state) && state.IsJoystickAxisBound(evtJaxis.axis)) + return; + + enqueueJoystickAxisInput(JoystickAxisSource.Axis1 + evtJaxis.axis, evtJaxis.axisValue); + } + + private uint lastPreciseScroll; + private const uint precise_scroll_debounce = 100; + + private void handleMouseWheelEvent(SDL_MouseWheelEvent evtWheel) + { + bool isPrecise(float f) => f % 1 != 0; + + if (isPrecise(evtWheel.preciseX) || isPrecise(evtWheel.preciseY)) + lastPreciseScroll = evtWheel.timestamp; + + bool precise = evtWheel.timestamp < lastPreciseScroll + precise_scroll_debounce; + + // SDL reports horizontal scroll opposite of what framework expects (in non-"natural" mode, scrolling to the right gives positive deltas while we want negative). + TriggerMouseWheel(new Vector2(-evtWheel.preciseX, evtWheel.preciseY), precise); + } + + private void handleMouseButtonEvent(SDL_MouseButtonEvent evtButton) + { + MouseButton button = mouseButtonFromEvent(evtButton.button); + SDLButtonMask mask = (SDLButtonMask)SDL_BUTTON(evtButton.button); + Debug.Assert(Enum.IsDefined(mask)); + + switch (evtButton.type) + { + case SDL_EventType.SDL_MOUSEBUTTONDOWN: + pressedButtons |= mask; + MouseDown?.Invoke(button); + break; + + case SDL_EventType.SDL_MOUSEBUTTONUP: + pressedButtons &= ~mask; + MouseUp?.Invoke(button); + break; + } + } + + private void handleMouseMotionEvent(SDL_MouseMotionEvent evtMotion) + { + if (SDL_GetRelativeMouseMode() == SDL_bool.SDL_FALSE) + MouseMove?.Invoke(new Vector2(evtMotion.x * Scale, evtMotion.y * Scale)); + else + MouseMoveRelative?.Invoke(new Vector2(evtMotion.xrel * Scale, evtMotion.yrel * Scale)); + } + + protected virtual unsafe void HandleTextInputEvent(SDL_TextInputEvent evtText) + { + if (!SDL2Extensions.TryGetStringFromBytePointer(evtText.text, out string text)) + return; + + TriggerTextInput(text); + } + + protected virtual unsafe void HandleTextEditingEvent(SDL_TextEditingEvent evtEdit) + { + if (!SDL2Extensions.TryGetStringFromBytePointer(evtEdit.text, out string text)) + return; + + TriggerTextEditing(text, evtEdit.start, evtEdit.length); + } + + private void handleKeyboardEvent(SDL_KeyboardEvent evtKey) + { + Key key = evtKey.keysym.ToKey(); + + if (key == Key.Unknown) + { + Logger.Log($"Unknown SDL key: {evtKey.keysym.scancode}, {evtKey.keysym.sym}"); + return; + } + + switch (evtKey.type) + { + case SDL_EventType.SDL_KEYDOWN: + KeyDown?.Invoke(key); + break; + + case SDL_EventType.SDL_KEYUP: + KeyUp?.Invoke(key); + break; + } + } + + private void handleKeymapChangedEvent() => KeymapChanged?.Invoke(); + + private MouseButton mouseButtonFromEvent(byte button) + { + switch ((uint)button) + { + default: + case SDL_BUTTON_LEFT: + return MouseButton.Left; + + case SDL_BUTTON_RIGHT: + return MouseButton.Right; + + case SDL_BUTTON_MIDDLE: + return MouseButton.Middle; + + case SDL_BUTTON_X1: + return MouseButton.Button1; + + case SDL_BUTTON_X2: + return MouseButton.Button2; + } + } + + /// + /// Button mask as returned from and . + /// + [Flags] + private enum SDLButtonMask + { + None = 0, + + /// + Left = 1 << 0, + + /// + Middle = 1 << 1, + + /// + Right = 1 << 2, + + /// + X1 = 1 << 3, + + /// + X2 = 1 << 4 + } + + #endregion + + /// + /// Update the host window manager's cursor position based on a location relative to window coordinates. + /// + /// A position inside the window. + public void UpdateMousePosition(Vector2 mousePosition) => ScheduleCommand(() => + SDL_WarpMouseInWindow(SDLWindowHandle, (int)(mousePosition.X / Scale), (int)(mousePosition.Y / Scale))); + + private void updateConfineMode() + { + bool confine = false; + + switch (ConfineMouseMode.Value) + { + case Input.ConfineMouseMode.Fullscreen: + confine = WindowMode.Value != Configuration.WindowMode.Windowed; + break; + + case Input.ConfineMouseMode.Always: + confine = true; + break; + } + + if (confine) + CursorStateBindable.Value |= CursorState.Confined; + else + CursorStateBindable.Value &= ~CursorState.Confined; + } + + #region Events + + /// + /// Invoked when the mouse cursor enters the window. + /// + public event Action? MouseEntered; + + /// + /// Invoked when the mouse cursor leaves the window. + /// + public event Action? MouseLeft; + + /// + /// Invoked when the user scrolls the mouse wheel over the window. + /// + /// + /// Delta is positive when mouse wheel scrolled to the up or left, in non-"natural" scroll mode (ie. the classic way). + /// + public event Action? MouseWheel; + + protected void TriggerMouseWheel(Vector2 delta, bool precise) => MouseWheel?.Invoke(delta, precise); + + /// + /// Invoked when the user moves the mouse cursor within the window. + /// + public event Action? MouseMove; + + protected void TriggerMouseMove(float x, float y) => MouseMove?.Invoke(new Vector2(x, y)); + + /// + /// Invoked when the user moves the mouse cursor within the window (via relative / raw input). + /// + public event Action? MouseMoveRelative; + + /// + /// Invoked when the user presses a mouse button. + /// + public event Action? MouseDown; + + protected void TriggerMouseDown(MouseButton button) => MouseDown?.Invoke(button); + + /// + /// Invoked when the user releases a mouse button. + /// + public event Action? MouseUp; + + protected void TriggerMouseUp(MouseButton button) => MouseUp?.Invoke(button); + + /// + /// Invoked when the user presses a key. + /// + public event Action? KeyDown; + + /// + /// Invoked when the user releases a key. + /// + public event Action? KeyUp; + + /// + /// Invoked when the user enters text. + /// + public event Action? TextInput; + + protected void TriggerTextInput(string text) => TextInput?.Invoke(text); + + /// + /// Invoked when an IME text editing event occurs. + /// + public event TextEditingDelegate? TextEditing; + + protected void TriggerTextEditing(string text, int start, int length) => TextEditing?.Invoke(text, start, length); + + /// + public event Action? KeymapChanged; + + /// + /// Invoked when a joystick axis changes. + /// + public event Action? JoystickAxisChanged; + + /// + /// Invoked when the user presses a button on a joystick. + /// + public event Action? JoystickButtonDown; + + /// + /// Invoked when the user releases a button on a joystick. + /// + public event Action? JoystickButtonUp; + + /// + /// Invoked when a finger moves or touches a touchscreen. + /// + public event Action? TouchDown; + + /// + /// Invoked when a finger leaves the touchscreen. + /// + public event Action? TouchUp; + + #endregion + } +} diff --git a/osu.Framework/Platform/SDL2/SDL2Window_Windowing.cs b/osu.Framework/Platform/SDL2/SDL2Window_Windowing.cs new file mode 100644 index 0000000000..2584309b5f --- /dev/null +++ b/osu.Framework/Platform/SDL2/SDL2Window_Windowing.cs @@ -0,0 +1,889 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Drawing; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Configuration; +using osu.Framework.Logging; +using osuTK; +using static SDL2.SDL; + +namespace osu.Framework.Platform.SDL2 +{ + internal partial class SDL2Window + { + private void setupWindowing(FrameworkConfigManager config) + { + config.BindWith(FrameworkSetting.MinimiseOnFocusLossInFullscreen, minimiseOnFocusLoss); + minimiseOnFocusLoss.BindValueChanged(e => + { + ScheduleCommand(() => SDL_SetHint(SDL_HINT_VIDEO_MINIMIZE_ON_FOCUS_LOSS, e.NewValue ? "1" : "0")); + }, true); + + fetchDisplays(); + + DisplaysChanged += _ => CurrentDisplayBindable.Default = PrimaryDisplay; + CurrentDisplayBindable.Default = PrimaryDisplay; + CurrentDisplayBindable.ValueChanged += evt => + { + windowDisplayIndexBindable.Value = (DisplayIndex)evt.NewValue.Index; + }; + + config.BindWith(FrameworkSetting.LastDisplayDevice, windowDisplayIndexBindable); + windowDisplayIndexBindable.BindValueChanged(evt => + { + currentDisplay = Displays.ElementAtOrDefault((int)evt.NewValue) ?? PrimaryDisplay; + invalidateWindowSpecifics(); + }, true); + + sizeFullscreen.ValueChanged += _ => + { + if (storingSizeToConfig) return; + if (windowState != WindowState.Fullscreen) return; + + invalidateWindowSpecifics(); + }; + + sizeWindowed.ValueChanged += _ => + { + if (storingSizeToConfig) return; + if (windowState != WindowState.Normal) return; + + invalidateWindowSpecifics(); + }; + + config.BindWith(FrameworkSetting.WindowedSize, sizeWindowed); + + sizeWindowed.MinValueChanged += min => + { + if (min.Width < 0 || min.Height < 0) + throw new InvalidOperationException($"Expected zero or positive size, got {min}"); + + if (min.Width > sizeWindowed.MaxValue.Width || min.Height > sizeWindowed.MaxValue.Height) + throw new InvalidOperationException($"Expected a size less than max window size ({sizeWindowed.MaxValue}), got {min}"); + + ScheduleCommand(() => SDL_SetWindowMinimumSize(SDLWindowHandle, min.Width, min.Height)); + }; + + sizeWindowed.MaxValueChanged += max => + { + if (max.Width <= 0 || max.Height <= 0) + throw new InvalidOperationException($"Expected positive size, got {max}"); + + if (max.Width < sizeWindowed.MinValue.Width || max.Height < sizeWindowed.MinValue.Height) + throw new InvalidOperationException($"Expected a size greater than min window size ({sizeWindowed.MinValue}), got {max}"); + + ScheduleCommand(() => SDL_SetWindowMaximumSize(SDLWindowHandle, max.Width, max.Height)); + }; + + config.BindWith(FrameworkSetting.SizeFullscreen, sizeFullscreen); + + config.BindWith(FrameworkSetting.WindowedPositionX, windowPositionX); + config.BindWith(FrameworkSetting.WindowedPositionY, windowPositionY); + + config.BindWith(FrameworkSetting.WindowMode, WindowMode); + + WindowMode.BindValueChanged(evt => + { + switch (evt.NewValue) + { + case Configuration.WindowMode.Fullscreen: + WindowState = WindowState.Fullscreen; + break; + + case Configuration.WindowMode.Borderless: + WindowState = WindowState.FullscreenBorderless; + break; + + case Configuration.WindowMode.Windowed: + WindowState = windowMaximised ? WindowState.Maximised : WindowState.Normal; + break; + } + }); + } + + private void initialiseWindowingAfterCreation() + { + updateAndFetchWindowSpecifics(); + fetchWindowSize(); + + sizeWindowed.TriggerChange(); + + WindowMode.TriggerChange(); + } + + private bool focused; + + /// + /// Whether the window currently has focus. + /// + public bool Focused + { + get => focused; + private set + { + if (value == focused) + return; + + isActive.Value = focused = value; + } + } + + public WindowMode DefaultWindowMode => RuntimeInfo.IsMobile ? Configuration.WindowMode.Fullscreen : Configuration.WindowMode.Windowed; + + /// + public virtual IEnumerable SupportedWindowModes + { + get + { + if (RuntimeInfo.IsMobile) + return new[] { Configuration.WindowMode.Fullscreen }; + + return Enum.GetValues(); + } + } + + private Point position; + + /// + /// Returns or sets the window's position in screen space. Only valid when in + /// + public Point Position + { + get => position; + set + { + position = value; + ScheduleCommand(() => SDL_SetWindowPosition(SDLWindowHandle, value.X, value.Y)); + } + } + + private bool resizable = true; + + /// + /// Returns or sets whether the window is resizable or not. Only valid when in . + /// + public bool Resizable + { + get => resizable; + set + { + if (resizable == value) + return; + + resizable = value; + ScheduleCommand(() => SDL_SetWindowResizable(SDLWindowHandle, value ? SDL_bool.SDL_TRUE : SDL_bool.SDL_FALSE)); + } + } + + private Size size = new Size(default_width, default_height); + + /// + /// Returns or sets the window's internal size, before scaling. + /// + public virtual Size Size + { + get => size; + protected set + { + if (value.Equals(size)) return; + + size = value; + Resized?.Invoke(); + } + } + + public Size MinSize + { + get => sizeWindowed.MinValue; + set => sizeWindowed.MinValue = value; + } + + public Size MaxSize + { + get => sizeWindowed.MaxValue; + set => sizeWindowed.MaxValue = value; + } + + public Bindable CurrentDisplayBindable { get; } = new Bindable(); + + /// + /// Bound to . + /// + public Bindable WindowMode { get; } = new Bindable(); + + private readonly BindableBool isActive = new BindableBool(); + + public IBindable IsActive => isActive; + + private readonly BindableBool cursorInWindow = new BindableBool(); + + public IBindable CursorInWindow => cursorInWindow; + + private bool visible; + + /// + /// Enables or disables the window visibility. + /// + public bool Visible + { + get => visible; + set + { + visible = value; + ScheduleCommand(() => + { + if (value) + SDL_ShowWindow(SDLWindowHandle); + else + SDL_HideWindow(SDLWindowHandle); + }); + } + } + + private WindowState windowState = WindowState.Normal; + + private WindowState? pendingWindowState; + + /// + /// Returns or sets the window's current . + /// + public WindowState WindowState + { + get => windowState; + set + { + if (pendingWindowState == null && windowState == value) + return; + + pendingWindowState = value; + } + } + + /// + /// Stores whether the window used to be in maximised state or not. + /// Used to properly decide what window state to pick when switching to windowed mode (see change event) + /// + private bool windowMaximised; + + /// + /// Returns the drawable area, after scaling. + /// + public Size ClientSize => new Size((int)(Size.Width * Scale), (int)(Size.Height * Scale)); + + public float Scale { get; private set; } = 1; + + #region Displays (mostly self-contained) + + /// + /// Queries the physical displays and their supported resolutions. + /// + public ImmutableArray Displays { get; private set; } = ImmutableArray.Empty; + + public event Action>? DisplaysChanged; + + // ReSharper disable once UnusedParameter.Local + private void handleDisplayEvent(SDL_DisplayEvent evtDisplay) => fetchDisplays(); + + /// + /// Updates with the latest display information reported by SDL. + /// + /// + /// Has no effect on values of + /// / + /// . + /// + private void fetchDisplays() + { + Displays = getSDLDisplays(); + DisplaysChanged?.Invoke(Displays); + } + + /// + /// Asserts that the current match the actual displays as reported by + /// + /// + /// This assert is not fatal, as the will get updated sooner or later + /// in or . + /// + [Conditional("DEBUG")] + private void assertDisplaysMatchSDL() + { + var actualDisplays = getSDLDisplays(); + + const string message = $"Stored {nameof(Displays)} don't match actual displays"; + string detailedMessage = $"Stored displays:\n {string.Join("\n ", Displays)}\n\nActual displays:\n {string.Join("\n ", actualDisplays)}"; + + Debug.Assert(actualDisplays.SequenceEqual(Displays), message, detailedMessage); + } + + private static ImmutableArray getSDLDisplays() + { + int numDisplays = SDL_GetNumVideoDisplays(); + + if (numDisplays <= 0) + throw new InvalidOperationException($"Failed to get number of SDL displays. Return code: {numDisplays}. SDL Error: {SDL_GetError()}"); + + var builder = ImmutableArray.CreateBuilder(numDisplays); + + for (int i = 0; i < numDisplays; i++) + { + if (tryGetDisplayFromSDL(i, out Display? display)) + builder.Add(display); + else + Logger.Log($"Failed to retrieve SDL display at index ({i})", level: LogLevel.Error); + } + + return builder.MoveToImmutable(); + } + + private static bool tryGetDisplayFromSDL(int displayIndex, [NotNullWhen(true)] out Display? display) + { + ArgumentOutOfRangeException.ThrowIfNegative(displayIndex); + + if (SDL_GetDisplayBounds(displayIndex, out var rect) < 0) + { + Logger.Log($"Failed to get display bounds for display at index ({displayIndex}). SDL Error: {SDL_GetError()}"); + display = null; + return false; + } + + DisplayMode[] displayModes = Array.Empty(); + + if (RuntimeInfo.IsDesktop) + { + int numModes = SDL_GetNumDisplayModes(displayIndex); + + if (numModes < 0) + { + Logger.Log($"Failed to get display modes for display at index ({displayIndex}) ({rect.w}x{rect.h}). SDL Error: {SDL_GetError()} ({numModes})"); + display = null; + return false; + } + + if (numModes == 0) + Logger.Log($"Display at index ({displayIndex}) ({rect.w}x{rect.h}) has no display modes. Fullscreen might not work."); + + displayModes = Enumerable.Range(0, numModes) + .Select(modeIndex => + { + SDL_GetDisplayMode(displayIndex, modeIndex, out var mode); + return mode.ToDisplayMode(displayIndex); + }) + .ToArray(); + } + + display = new Display(displayIndex, SDL_GetDisplayName(displayIndex), new Rectangle(rect.x, rect.y, rect.w, rect.h), displayModes); + return true; + } + + #endregion + + /// + /// Gets the that has been set as "primary" or "default" in the operating system. + /// + public virtual Display PrimaryDisplay => Displays.First(); + + private Display currentDisplay = null!; + private int displayIndex = -1; + + private readonly Bindable currentDisplayMode = new Bindable(); + + /// + /// The for the display that this window is currently on. + /// + public IBindable CurrentDisplayMode => currentDisplayMode; + + private Rectangle windowDisplayBounds + { + get + { + SDL_GetDisplayBounds(displayIndex, out var rect); + return new Rectangle(rect.x, rect.y, rect.w, rect.h); + } + } + + /// + /// Bound to . + /// + private readonly BindableSize sizeFullscreen = new BindableSize(); + + /// + /// Bound to . + /// + private readonly BindableSize sizeWindowed = new BindableSize(); + + /// + /// Bound to . + /// + private readonly BindableDouble windowPositionX = new BindableDouble(); + + /// + /// Bound to . + /// + private readonly BindableDouble windowPositionY = new BindableDouble(); + + /// + /// Bound to . + /// + private readonly Bindable windowDisplayIndexBindable = new Bindable(); + + private readonly BindableBool minimiseOnFocusLoss = new BindableBool(); + + /// + /// Updates and according to SDL state. + /// + /// Whether the window size has been changed after updating. + private void fetchWindowSize() + { + SDL_GetWindowSize(SDLWindowHandle, out int w, out int h); + + int drawableW = graphicsSurface.GetDrawableSize().Width; + + // When minimised on windows, values may be zero. + // If we receive zeroes for either of these, it seems safe to completely ignore them. + if (w <= 0 || drawableW <= 0) + return; + + Scale = (float)drawableW / w; + Size = new Size(w, h); + + storeWindowSizeToConfig(); + } + + #region SDL Event Handling + + private void handleWindowEvent(SDL_WindowEvent evtWindow) + { + updateAndFetchWindowSpecifics(); + + switch (evtWindow.windowEvent) + { + case SDL_WindowEventID.SDL_WINDOWEVENT_MOVED: + // explicitly requery as there are occasions where what SDL has provided us with is not up-to-date. + SDL_GetWindowPosition(SDLWindowHandle, out int x, out int y); + var newPosition = new Point(x, y); + + if (!newPosition.Equals(Position)) + { + position = newPosition; + Moved?.Invoke(newPosition); + + if (WindowMode.Value == Configuration.WindowMode.Windowed) + storeWindowPositionToConfig(); + } + + break; + + case SDL_WindowEventID.SDL_WINDOWEVENT_SIZE_CHANGED: + fetchWindowSize(); + break; + + case SDL_WindowEventID.SDL_WINDOWEVENT_ENTER: + cursorInWindow.Value = true; + MouseEntered?.Invoke(); + break; + + case SDL_WindowEventID.SDL_WINDOWEVENT_LEAVE: + cursorInWindow.Value = false; + MouseLeft?.Invoke(); + break; + + case SDL_WindowEventID.SDL_WINDOWEVENT_RESTORED: + case SDL_WindowEventID.SDL_WINDOWEVENT_FOCUS_GAINED: + Focused = true; + break; + + case SDL_WindowEventID.SDL_WINDOWEVENT_MINIMIZED: + case SDL_WindowEventID.SDL_WINDOWEVENT_FOCUS_LOST: + Focused = false; + break; + + case SDL_WindowEventID.SDL_WINDOWEVENT_CLOSE: + break; + } + + // displays can change without a SDL_DISPLAYEVENT being sent, eg. changing resolution. + // force update displays when gaining keyboard focus to always have up-to-date information. + // eg. this covers scenarios when changing resolution outside of the game, and then tabbing in. + switch (evtWindow.windowEvent) + { + case SDL_WindowEventID.SDL_WINDOWEVENT_RESTORED: + case SDL_WindowEventID.SDL_WINDOWEVENT_FOCUS_GAINED: + case SDL_WindowEventID.SDL_WINDOWEVENT_MINIMIZED: + case SDL_WindowEventID.SDL_WINDOWEVENT_FOCUS_LOST: + case SDL_WindowEventID.SDL_WINDOWEVENT_SHOWN: + case SDL_WindowEventID.SDL_WINDOWEVENT_HIDDEN: + fetchDisplays(); + break; + } + +#if DEBUG + EventScheduler.AddOnce(() => assertDisplaysMatchSDL()); +#endif + } + + /// + /// Invalidates the the state of the window. + /// This forces to run before the next event loop. + /// + private void invalidateWindowSpecifics() + { + pendingWindowState = windowState; + } + + /// + /// Should be run on a regular basis to check for external window state changes. + /// + private void updateAndFetchWindowSpecifics() + { + // don't attempt to run before the window is initialised, as Create() will do so anyway. + if (SDLWindowHandle == IntPtr.Zero) + return; + + var stateBefore = windowState; + + // check for a pending user state change and give precedence. + if (pendingWindowState != null) + { + windowState = pendingWindowState.Value; + pendingWindowState = null; + + updatingWindowStateAndSize = true; + UpdateWindowStateAndSize(windowState, currentDisplay, currentDisplayMode.Value); + updatingWindowStateAndSize = false; + + fetchWindowSize(); + + if (tryFetchMaximisedState(windowState, out bool maximized)) + windowMaximised = maximized; + + if (tryFetchDisplayMode(SDLWindowHandle, windowState, currentDisplay, out var newMode)) + currentDisplayMode.Value = newMode; + + fetchDisplays(); + } + else + { + windowState = ((SDL_WindowFlags)SDL_GetWindowFlags(SDLWindowHandle)).ToWindowState(); + } + + if (windowState != stateBefore) + { + WindowStateChanged?.Invoke(windowState); + + if (tryFetchMaximisedState(windowState, out bool maximized)) + windowMaximised = maximized; + } + + int newDisplayIndex = SDL_GetWindowDisplayIndex(SDLWindowHandle); + + if (displayIndex != newDisplayIndex) + { + displayIndex = newDisplayIndex; + currentDisplay = Displays.ElementAtOrDefault(displayIndex) ?? PrimaryDisplay; + CurrentDisplayBindable.Value = currentDisplay; + } + } + + /// + /// Should be run after a local window state change, to propagate the correct SDL actions. + /// + /// + /// Call sites need to set appropriately. + /// + protected virtual void UpdateWindowStateAndSize(WindowState state, Display display, DisplayMode displayMode) + { + switch (state) + { + case WindowState.Normal: + Size = sizeWindowed.Value; + + SDL_RestoreWindow(SDLWindowHandle); + SDL_SetWindowSize(SDLWindowHandle, Size.Width, Size.Height); + SDL_SetWindowResizable(SDLWindowHandle, Resizable ? SDL_bool.SDL_TRUE : SDL_bool.SDL_FALSE); + + readWindowPositionFromConfig(state, display); + break; + + case WindowState.Fullscreen: + var closestMode = getClosestDisplayMode(SDLWindowHandle, sizeFullscreen.Value, display, displayMode); + + Size = new Size(closestMode.w, closestMode.h); + + ensureWindowOnDisplay(display); + + SDL_SetWindowDisplayMode(SDLWindowHandle, ref closestMode); + SDL_SetWindowFullscreen(SDLWindowHandle, (uint)SDL_WindowFlags.SDL_WINDOW_FULLSCREEN); + break; + + case WindowState.FullscreenBorderless: + Size = SetBorderless(display); + break; + + case WindowState.Maximised: + SDL_RestoreWindow(SDLWindowHandle); + + ensureWindowOnDisplay(display); + + SDL_MaximizeWindow(SDLWindowHandle); + break; + + case WindowState.Minimised: + ensureWindowOnDisplay(display); + SDL_MinimizeWindow(SDLWindowHandle); + break; + } + } + + private static bool tryFetchDisplayMode(IntPtr windowHandle, WindowState windowState, Display display, out DisplayMode displayMode) + { + // TODO: displayIndex should be valid here at all times. + // on startup, the displayIndex will be invalid (-1) due to it being set later in the startup sequence. + // related to order of operations in `updateWindowSpecifics()`. + int localIndex = SDL_GetWindowDisplayIndex(windowHandle); + + if (localIndex != display.Index) + Logger.Log($"Stored display index ({display.Index}) doesn't match current index ({localIndex})"); + + bool success; + SDL_DisplayMode mode; + + if (windowState == WindowState.Fullscreen) + success = SDL_GetWindowDisplayMode(windowHandle, out mode) >= 0; + else + success = SDL_GetCurrentDisplayMode(localIndex, out mode) >= 0; + + string type = windowState == WindowState.Fullscreen ? "fullscreen" : "desktop"; + + if (success) + { + displayMode = mode.ToDisplayMode(localIndex); + Logger.Log($"Updated display mode to {type} resolution: {mode.w}x{mode.h}@{mode.refresh_rate}, {displayMode.Format}"); + return true; + } + else + { + Logger.Log($"Failed to get {type} display mode. Display index: {localIndex}. SDL error: {SDL_GetError()}"); + displayMode = default; + return false; + } + } + + private static bool tryFetchMaximisedState(WindowState windowState, out bool maximized) + { + if (windowState is WindowState.Normal or WindowState.Maximised) + { + maximized = windowState == WindowState.Maximised; + return true; + } + + maximized = default; + return false; + } + + private void readWindowPositionFromConfig(WindowState state, Display display) + { + if (state != WindowState.Normal) + return; + + var configPosition = new Vector2((float)windowPositionX.Value, (float)windowPositionY.Value); + + moveWindowTo(display, configPosition); + } + + /// + /// Ensures that the window is located on the provided . + /// + /// The to center the window on. + private void ensureWindowOnDisplay(Display display) + { + if (display.Index == SDL_GetWindowDisplayIndex(SDLWindowHandle)) + return; + + moveWindowTo(display, new Vector2(0.5f)); + } + + /// + /// Moves the window to be centred around the normalised on a . + /// + /// The to move the window to. + /// Relative position on the display, normalised to [-0.5, 1.5]. + private void moveWindowTo(Display display, Vector2 newPosition) + { + Debug.Assert(newPosition == Vector2.Clamp(newPosition, new Vector2(-0.5f), new Vector2(1.5f))); + + var displayBounds = display.Bounds; + var windowSize = sizeWindowed.Value; + int windowX = (int)Math.Round((displayBounds.Width - windowSize.Width) * newPosition.X); + int windowY = (int)Math.Round((displayBounds.Height - windowSize.Height) * newPosition.Y); + + Position = new Point(windowX + displayBounds.X, windowY + displayBounds.Y); + } + + private void storeWindowPositionToConfig() + { + if (WindowState != WindowState.Normal) + return; + + var displayBounds = currentDisplay.Bounds; + + int windowX = Position.X - displayBounds.X; + int windowY = Position.Y - displayBounds.Y; + + var windowSize = sizeWindowed.Value; + + windowPositionX.Value = displayBounds.Width > windowSize.Width ? (float)windowX / (displayBounds.Width - windowSize.Width) : 0; + windowPositionY.Value = displayBounds.Height > windowSize.Height ? (float)windowY / (displayBounds.Height - windowSize.Height) : 0; + } + + /// + /// Set to true while the window size is being stored to config to avoid bindable feedback. + /// + private bool storingSizeToConfig; + + /// + /// Set when is in progress to avoid being called with invalid data. + /// + /// + /// Since is a multi-step process, intermediary windows size changes might be invalid. + /// This is usually not a problem, but since runs out-of-band, invalid data might appear in those events. + /// + private bool updatingWindowStateAndSize; + + private void storeWindowSizeToConfig() + { + if (WindowState != WindowState.Normal) + return; + + storingSizeToConfig = true; + sizeWindowed.Value = Size; + storingSizeToConfig = false; + } + + /// + /// Prepare display of a borderless window. + /// + /// The display to make the window fullscreen borderless on. + /// + /// The size of the borderless window's draw area. + /// + protected virtual Size SetBorderless(Display display) + { + ensureWindowOnDisplay(display); + + // this is a generally sane method of handling borderless, and works well on macOS and linux. + SDL_SetWindowFullscreen(SDLWindowHandle, (uint)SDL_WindowFlags.SDL_WINDOW_FULLSCREEN_DESKTOP); + + return display.Bounds.Size; + } + + #endregion + + public void CycleMode() + { + var currentValue = WindowMode.Value; + + do + { + switch (currentValue) + { + case Configuration.WindowMode.Windowed: + currentValue = Configuration.WindowMode.Borderless; + break; + + case Configuration.WindowMode.Borderless: + currentValue = Configuration.WindowMode.Fullscreen; + break; + + case Configuration.WindowMode.Fullscreen: + currentValue = Configuration.WindowMode.Windowed; + break; + } + } while (!SupportedWindowModes.Contains(currentValue) && currentValue != WindowMode.Value); + + WindowMode.Value = currentValue; + } + + #region Helper functions + + private static SDL_DisplayMode getClosestDisplayMode(IntPtr windowHandle, Size size, Display display, DisplayMode requestedMode) + { + SDL_ClearError(); // clear any stale error. + + // default size means to use the display's native size. + if (size.Width == 9999 && size.Height == 9999) + size = display.Bounds.Size; + + var targetMode = new SDL_DisplayMode { w = size.Width, h = size.Height, refresh_rate = (int)Math.Round(requestedMode.RefreshRate) }; + + if (SDL_GetClosestDisplayMode(display.Index, ref targetMode, out var mode) != IntPtr.Zero) + return mode; + else + Logger.Log($"Unable to get preferred display mode (try #1/2). Target display: {display.Index}, mode: {targetMode.ReadableString()}. SDL error: {SDL2Extensions.GetAndClearError()}"); + + // fallback to current display's native bounds + targetMode.w = display.Bounds.Width; + targetMode.h = display.Bounds.Height; + targetMode.refresh_rate = 0; + + if (SDL_GetClosestDisplayMode(display.Index, ref targetMode, out mode) != IntPtr.Zero) + return mode; + else + Logger.Log($"Unable to get preferred display mode (try #2/2). Target display: {display.Index}, mode: {targetMode.ReadableString()}. SDL error: {SDL2Extensions.GetAndClearError()}"); + + // try the display's native display mode. + if (SDL_GetDesktopDisplayMode(display.Index, out mode) == 0) + return mode; + else + Logger.Log($"Failed to get desktop display mode (try #1/3). Target display: {display.Index}. SDL error: {SDL2Extensions.GetAndClearError()}", level: LogLevel.Error); + + // try the primary display mode. + if (SDL_GetDisplayMode(display.Index, 0, out mode) == 0) + return mode; + else + Logger.Log($"Failed to get desktop display mode (try #2/3). Target display: {display.Index}. SDL error: {SDL2Extensions.GetAndClearError()}", level: LogLevel.Error); + + // try the primary display's primary display mode. + if (SDL_GetDisplayMode(0, 0, out mode) == 0) + return mode; + else + Logger.Log($"Failed to get desktop display mode (try #3/3). Target display: primary. SDL error: {SDL2Extensions.GetAndClearError()}", level: LogLevel.Error); + + // finally return the current mode if everything else fails. + if (SDL_GetWindowDisplayMode(windowHandle, out mode) >= 0) + return mode; + else + Logger.Log($"Failed to get window display mode. SDL error: {SDL2Extensions.GetAndClearError()}", level: LogLevel.Error); + + throw new InvalidOperationException("couldn't retrieve valid display mode"); + } + + #endregion + + #region Events + + /// + /// Invoked after the window has resized. + /// + public event Action? Resized; + + /// + /// Invoked after the window's state has changed. + /// + public event Action? WindowStateChanged; + + /// + /// Invoked when the window moves. + /// + public event Action? Moved; + + #endregion + } +} diff --git a/osu.Framework/Platform/SDL3/SDL3Clipboard.cs b/osu.Framework/Platform/SDL3/SDL3Clipboard.cs new file mode 100644 index 0000000000..1c2b08fdfe --- /dev/null +++ b/osu.Framework/Platform/SDL3/SDL3Clipboard.cs @@ -0,0 +1,219 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using osu.Framework.Allocation; +using osu.Framework.Logging; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats; +using static SDL.SDL3; + +namespace osu.Framework.Platform.SDL3 +{ + public class SDL3Clipboard : Clipboard + { + /// + /// Supported formats for decoding images from the clipboard. + /// + // It's possible for a format to not have a registered decoder, but all default formats will have one: + // https://github.com/SixLabors/ImageSharp/discussions/1353#discussioncomment-9142056 + private static IEnumerable supportedImageMimeTypes => SixLabors.ImageSharp.Configuration.Default.ImageFormats.SelectMany(f => f.MimeTypes); + + /// + /// Format used for encoding (saving) images to the clipboard. + /// + private readonly IImageFormat imageFormat; + + public SDL3Clipboard(IImageFormat imageFormat) + { + this.imageFormat = imageFormat; + } + + // SDL cannot differentiate between string.Empty and no text (eg. empty clipboard or an image) + // doesn't matter as text editors don't really allow copying empty strings. + // assume that empty text means no text. + public override string? GetText() => SDL_HasClipboardText() ? SDL_GetClipboardText() : null; + + public override void SetText(string text) => SDL_SetClipboardText(text); + + public override Image? GetImage() + { + foreach (string mimeType in supportedImageMimeTypes) + { + if (tryGetData(mimeType, Image.Load, out var image)) + { + Logger.Log($"Decoded {mimeType} from clipboard."); + return image; + } + } + + return null; + } + + public override bool SetImage(Image image) + { + ReadOnlyMemory memory; + + // we can't save the image in the callback as the caller owns the image and might dispose it from under us. + + using (var stream = new MemoryStream()) + { + image.Save(stream, imageFormat); + + // The buffer is allowed to escape the lifetime of the MemoryStream. + // https://learn.microsoft.com/en-us/dotnet/api/system.io.memorystream.getbuffer?view=net-8.0 + // "This method works when the memory stream is closed." + memory = new ReadOnlyMemory(stream.GetBuffer(), 0, (int)stream.Length); + } + + return trySetData(imageFormat.DefaultMimeType, () => memory); + } + + /// + /// Decodes data from a native memory span. Return null or throw an exception if the data couldn't be decoded. + /// + /// Type of decoded data. + private delegate T? SpanDecoder(ReadOnlySpan span); + + private static unsafe bool tryGetData(string mimeType, SpanDecoder decoder, out T? data) + { + if (!SDL_HasClipboardData(mimeType)) + { + data = default; + return false; + } + + UIntPtr nativeSize; + IntPtr pointer = SDL_GetClipboardData(mimeType, &nativeSize); + + if (pointer == IntPtr.Zero) + { + Logger.Log($"Failed to get SDL clipboard data for {mimeType}. SDL error: {SDL_GetError()}"); + data = default; + return false; + } + + try + { + var nativeMemory = new ReadOnlySpan((void*)pointer, (int)nativeSize); + data = decoder(nativeMemory); + return data != null; + } + catch (Exception e) + { + Logger.Error(e, $"Failed to decode clipboard data for {mimeType}."); + data = default; + return false; + } + finally + { + SDL_free(pointer); + } + } + + private static unsafe bool trySetData(string mimeType, Func> dataProvider) + { + var callbackContext = new ClipboardCallbackContext(mimeType, dataProvider); + var objectHandle = new ObjectHandle(callbackContext, GCHandleType.Normal); + + // TODO: support multiple mime types in a single callback + fixed (byte* ptr = Encoding.UTF8.GetBytes(mimeType + '\0')) + { + if (!SDL_SetClipboardData(&dataCallback, &cleanupCallback, objectHandle.Handle, &ptr, 1)) + { + objectHandle.Dispose(); + Logger.Log($"Failed to set clipboard data callback. SDL error: {SDL_GetError()}"); + return false; + } + + return true; + } + } + + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static unsafe IntPtr dataCallback(IntPtr userdata, byte* mimeType, UIntPtr* length) + { + using var objectHandle = new ObjectHandle(userdata); + + if (!objectHandle.GetTarget(out var context) || context.MimeType != PtrToStringUTF8(mimeType)) + { + *length = 0; + return IntPtr.Zero; + } + + context.EnsureDataValid(); + *length = context.DataLength; + return context.Address; + } + + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static void cleanupCallback(IntPtr userdata) + { + using var objectHandle = new ObjectHandle(userdata, true); + + if (objectHandle.GetTarget(out var context)) + { + context.Dispose(); + } + } + + private class ClipboardCallbackContext : IDisposable + { + public readonly string MimeType; + + /// + /// Provider of data suitable for the . + /// + /// Called when another application requests that mime type from the OS clipboard. + private Func>? dataProvider; + + private MemoryHandle memoryHandle; + + /// + /// Address of the returned by the . + /// + /// Pinned and suitable for passing to unmanaged code. + public unsafe IntPtr Address => (IntPtr)memoryHandle.Pointer; + + /// + /// Length of the returned by the . + /// + public UIntPtr DataLength { get; private set; } + + public ClipboardCallbackContext(string mimeType, Func> dataProvider) + { + MimeType = mimeType; + this.dataProvider = dataProvider; + } + + public void EnsureDataValid() + { + if (dataProvider == null) + { + Debug.Assert(Address != IntPtr.Zero); + Debug.Assert(DataLength != 0); + return; + } + + var data = dataProvider(); + dataProvider = null!; + DataLength = (UIntPtr)data.Length; + memoryHandle = data.Pin(); + } + + public void Dispose() + { + memoryHandle.Dispose(); + DataLength = 0; + } + } + } +} diff --git a/osu.Framework/Platform/SDL/SDL3ControllerBindings.cs b/osu.Framework/Platform/SDL3/SDL3ControllerBindings.cs similarity index 91% rename from osu.Framework/Platform/SDL/SDL3ControllerBindings.cs rename to osu.Framework/Platform/SDL3/SDL3ControllerBindings.cs index 15bbdb55d4..5d525218ff 100644 --- a/osu.Framework/Platform/SDL/SDL3ControllerBindings.cs +++ b/osu.Framework/Platform/SDL3/SDL3ControllerBindings.cs @@ -5,8 +5,9 @@ using System; using SDL; +using static SDL.SDL3; -namespace osu.Framework.Platform.SDL +namespace osu.Framework.Platform.SDL3 { /// /// Maintain a copy of the SDL-provided bindings for the given controller. @@ -18,7 +19,7 @@ internal unsafe class SDL3ControllerBindings public readonly SDL_Gamepad* GamepadHandle; /// - /// Bindings returned from . + /// Bindings returned from . /// Empty if the joystick does not have a corresponding GamepadHandle. /// public SDL_GamepadBinding[] Bindings; @@ -39,7 +40,7 @@ public void PopulateBindings() return; } - using var bindings = SDL3.SDL_GetGamepadBindings(GamepadHandle); + using var bindings = SDL_GetGamepadBindings(GamepadHandle); if (bindings == null) { diff --git a/osu.Framework/Platform/SDL3DesktopWindow.cs b/osu.Framework/Platform/SDL3/SDL3DesktopWindow.cs similarity index 75% rename from osu.Framework/Platform/SDL3DesktopWindow.cs rename to osu.Framework/Platform/SDL3/SDL3DesktopWindow.cs index 84e5480a7d..ca86b8affc 100644 --- a/osu.Framework/Platform/SDL3DesktopWindow.cs +++ b/osu.Framework/Platform/SDL3/SDL3DesktopWindow.cs @@ -1,14 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using SDL; +using static SDL.SDL3; -namespace osu.Framework.Platform +namespace osu.Framework.Platform.SDL3 { internal class SDL3DesktopWindow : SDL3Window { - public SDL3DesktopWindow(GraphicsSurfaceType surfaceType) - : base(surfaceType) + public SDL3DesktopWindow(GraphicsSurfaceType surfaceType, string appName) + : base(surfaceType, appName) { } @@ -17,9 +17,9 @@ protected override unsafe void UpdateWindowStateAndSize(WindowState state, Displ // this reset is required even on changing from one fullscreen resolution to another. // if it is not included, the GL context will not get the correct size. // this is mentioned by multiple sources as an SDL issue, which seems to resolve by similar means (see https://discourse.libsdl.org/t/sdl-setwindowsize-does-not-work-in-fullscreen/20711/4). - SDL3.SDL_SetWindowBordered(SDLWindowHandle, SDL_bool.SDL_TRUE); - SDL3.SDL_SetWindowFullscreen(SDLWindowHandle, SDL_bool.SDL_FALSE); - SDL3.SDL_RestoreWindow(SDLWindowHandle); + SDL_SetWindowBordered(SDLWindowHandle, true); + SDL_SetWindowFullscreen(SDLWindowHandle, false); + SDL_RestoreWindow(SDLWindowHandle); base.UpdateWindowStateAndSize(state, display, displayMode); } diff --git a/osu.Framework/Platform/SDL/SDL3Extensions.cs b/osu.Framework/Platform/SDL3/SDL3Extensions.cs similarity index 96% rename from osu.Framework/Platform/SDL/SDL3Extensions.cs rename to osu.Framework/Platform/SDL3/SDL3Extensions.cs index 3c75634ba3..e2b77fc67e 100644 --- a/osu.Framework/Platform/SDL/SDL3Extensions.cs +++ b/osu.Framework/Platform/SDL3/SDL3Extensions.cs @@ -9,18 +9,19 @@ using osu.Framework.Input.Bindings; using osuTK.Input; using SDL; +using static SDL.SDL3; -namespace osu.Framework.Platform.SDL +namespace osu.Framework.Platform.SDL3 { public static class SDL3Extensions { - public static Key ToKey(this SDL_Keysym sdlKeysym) + public static Key ToKey(this SDL_KeyboardEvent sdlKeyboardEvent) { // Apple devices don't have the notion of NumLock (they have a Clear key instead). // treat them as if they always have NumLock on (the numpad always performs its primary actions). - bool numLockOn = sdlKeysym.Mod.HasFlagFast(SDL_Keymod.SDL_KMOD_NUM) || RuntimeInfo.IsApple; + bool numLockOn = sdlKeyboardEvent.mod.HasFlagFast(SDL_Keymod.SDL_KMOD_NUM) || RuntimeInfo.IsApple; - switch (sdlKeysym.scancode) + switch (sdlKeyboardEvent.scancode) { default: case SDL_Scancode.SDL_SCANCODE_UNKNOWN: @@ -429,23 +430,23 @@ public static Key ToKey(this SDL_Keysym sdlKeysym) case SDL_Scancode.SDL_SCANCODE_RGUI: return Key.WinRight; - case SDL_Scancode.SDL_SCANCODE_AUDIONEXT: + case SDL_Scancode.SDL_SCANCODE_MEDIA_NEXT_TRACK: return Key.TrackNext; - case SDL_Scancode.SDL_SCANCODE_AUDIOPREV: + case SDL_Scancode.SDL_SCANCODE_MEDIA_PREVIOUS_TRACK: return Key.TrackPrevious; - case SDL_Scancode.SDL_SCANCODE_AUDIOSTOP: + case SDL_Scancode.SDL_SCANCODE_MEDIA_STOP: return Key.Stop; - case SDL_Scancode.SDL_SCANCODE_AUDIOPLAY: + case SDL_Scancode.SDL_SCANCODE_MEDIA_PLAY_PAUSE: return Key.PlayPause; - case SDL_Scancode.SDL_SCANCODE_AUDIOMUTE: - return Key.Mute; - case SDL_Scancode.SDL_SCANCODE_SLEEP: return Key.Sleep; + + case SDL_Scancode.SDL_SCANCODE_AC_BACK: + return Key.Escape; } } @@ -813,13 +814,13 @@ public static SDL_Scancode ToScancode(this InputKey inputKey) return SDL_Scancode.SDL_SCANCODE_NONUSBACKSLASH; case InputKey.Mute: - return SDL_Scancode.SDL_SCANCODE_AUDIOMUTE; + return SDL_Scancode.SDL_SCANCODE_MUTE; case InputKey.PlayPause: - return SDL_Scancode.SDL_SCANCODE_AUDIOPLAY; + return SDL_Scancode.SDL_SCANCODE_MEDIA_PLAY_PAUSE; case InputKey.Stop: - return SDL_Scancode.SDL_SCANCODE_AUDIOSTOP; + return SDL_Scancode.SDL_SCANCODE_MEDIA_STOP; case InputKey.VolumeUp: return SDL_Scancode.SDL_SCANCODE_VOLUMEUP; @@ -828,10 +829,10 @@ public static SDL_Scancode ToScancode(this InputKey inputKey) return SDL_Scancode.SDL_SCANCODE_VOLUMEDOWN; case InputKey.TrackPrevious: - return SDL_Scancode.SDL_SCANCODE_AUDIOPREV; + return SDL_Scancode.SDL_SCANCODE_MEDIA_PREVIOUS_TRACK; case InputKey.TrackNext: - return SDL_Scancode.SDL_SCANCODE_AUDIONEXT; + return SDL_Scancode.SDL_SCANCODE_MEDIA_NEXT_TRACK; case InputKey.LShift: return SDL_Scancode.SDL_SCANCODE_LSHIFT; @@ -1014,8 +1015,8 @@ public static unsafe DisplayMode ToDisplayMode(this SDL_DisplayMode mode, int di { int bpp; uint unused; - SDL3.SDL_GetMasksForPixelFormatEnum(mode.format, &bpp, &unused, &unused, &unused, &unused); - return new DisplayMode(SDL3.SDL_GetPixelFormatName(mode.format), new Size(mode.w, mode.h), bpp, mode.refresh_rate, displayIndex); + SDL_GetMasksForPixelFormat(mode.format, &bpp, &unused, &unused, &unused, &unused); + return new DisplayMode(SDL_GetPixelFormatName(mode.format), new Size(mode.w, mode.h), bpp, mode.refresh_rate, displayIndex); } public static string ReadableName(this SDL_LogCategory category) @@ -1094,8 +1095,8 @@ public static string ReadableName(this SDL_LogPriority priority) /// public static string? GetAndClearError() { - string? error = SDL3.SDL_GetError(); - SDL3.SDL_ClearError(); + string? error = SDL_GetError(); + SDL_ClearError(); return error; } @@ -1107,7 +1108,7 @@ public static string ReadableName(this SDL_LogPriority priority) /// public static bool TryGetTouchName(this SDL_TouchFingerEvent e, [NotNullWhen(true)] out string? name) { - name = SDL3.SDL_GetTouchDeviceName(e.touchID); + name = SDL_GetTouchDeviceName(e.touchID); return name != null; } } diff --git a/osu.Framework/Platform/SDL/SDL3GraphicsSurface.cs b/osu.Framework/Platform/SDL3/SDL3GraphicsSurface.cs similarity index 65% rename from osu.Framework/Platform/SDL/SDL3GraphicsSurface.cs rename to osu.Framework/Platform/SDL3/SDL3GraphicsSurface.cs index 911aac88bd..590c79b504 100644 --- a/osu.Framework/Platform/SDL/SDL3GraphicsSurface.cs +++ b/osu.Framework/Platform/SDL3/SDL3GraphicsSurface.cs @@ -7,20 +7,21 @@ using System.Linq; using System.Reflection; using System.Runtime.InteropServices; +using System.Runtime.Versioning; using osuTK.Graphics; using osuTK.Graphics.ES30; using SDL; +using static SDL.SDL3; -namespace osu.Framework.Platform.SDL +namespace osu.Framework.Platform.SDL3 { - internal unsafe class SDL3GraphicsSurface : IGraphicsSurface, IOpenGLGraphicsSurface, IMetalGraphicsSurface, ILinuxGraphicsSurface + internal unsafe class SDL3GraphicsSurface : IGraphicsSurface, IOpenGLGraphicsSurface, IMetalGraphicsSurface, ILinuxGraphicsSurface, IAndroidGraphicsSurface { private readonly SDL3Window window; - private IntPtr context; + private SDL_GLContextState* context; public IntPtr WindowHandle => window.WindowHandle; - public IntPtr DisplayHandle => window.DisplayHandle; public GraphicsSurfaceType Type { get; } @@ -32,12 +33,12 @@ public SDL3GraphicsSurface(SDL3Window window, GraphicsSurfaceType surfaceType) switch (surfaceType) { case GraphicsSurfaceType.OpenGL: - SDL3.SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_RED_SIZE, 8); - SDL3.SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_GREEN_SIZE, 8); - SDL3.SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_BLUE_SIZE, 8); - SDL3.SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_ACCUM_ALPHA_SIZE, 0); - SDL3.SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_DEPTH_SIZE, 16); - SDL3.SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_STENCIL_SIZE, 8); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_RED_SIZE, 8); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_GREEN_SIZE, 8); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_BLUE_SIZE, 8); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_ACCUM_ALPHA_SIZE, 0); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_DEPTH_SIZE, 16); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_STENCIL_SIZE, 8); break; case GraphicsSurfaceType.Vulkan: @@ -59,7 +60,7 @@ public void Initialise() public Size GetDrawableSize() { int width, height; - SDL3.SDL_GetWindowSizeInPixels(window.SDLWindowHandle, &width, &height); + SDL_GetWindowSizeInPixels(window.SDLWindowHandle, &width, &height); return new Size(width, height); } @@ -69,27 +70,27 @@ private void initialiseOpenGL() { if (RuntimeInfo.IsMobile) { - SDL3.SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_CONTEXT_PROFILE_MASK, (int)SDL_GLprofile.SDL_GL_CONTEXT_PROFILE_ES); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_CONTEXT_PROFILE_MASK, (int)SDL_GLprofile.SDL_GL_CONTEXT_PROFILE_ES); // Minimum OpenGL version for ES profile: - SDL3.SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_CONTEXT_MAJOR_VERSION, 3); - SDL3.SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_CONTEXT_MINOR_VERSION, 0); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_CONTEXT_MINOR_VERSION, 0); } else { - SDL3.SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_CONTEXT_PROFILE_MASK, (int)SDL_GLprofile.SDL_GL_CONTEXT_PROFILE_CORE); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_CONTEXT_PROFILE_MASK, (int)SDL_GLprofile.SDL_GL_CONTEXT_PROFILE_CORE); // Minimum OpenGL version for core profile: - SDL3.SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_CONTEXT_MAJOR_VERSION, 3); - SDL3.SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_CONTEXT_MINOR_VERSION, 2); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_CONTEXT_MINOR_VERSION, 2); } - context = SDL3.SDL_GL_CreateContext(window.SDLWindowHandle); + context = SDL_GL_CreateContext(window.SDLWindowHandle); - if (context == IntPtr.Zero) - throw new InvalidOperationException($"Failed to create an SDL3 GL context ({SDL3.SDL_GetError()})"); + if (context == null) + throw new InvalidOperationException($"Failed to create an SDL3 GL context ({SDL_GetError()})"); - SDL3.SDL_GL_MakeCurrent(window.SDLWindowHandle, context); + SDL_GL_MakeCurrent(window.SDLWindowHandle, context); loadBindings(); } @@ -135,15 +136,15 @@ private void loadEntryPoints(GraphicsBindingsBase bindings) private IntPtr getProcAddress(string symbol) { const SDL_LogCategory error_category = SDL_LogCategory.SDL_LOG_CATEGORY_ERROR; - SDL_LogPriority oldPriority = SDL3.SDL_LogGetPriority(error_category); + SDL_LogPriority oldPriority = SDL_GetLogPriority(error_category); // Prevent logging calls to SDL_GL_GetProcAddress() that fail on systems which don't have the requested symbol (typically macOS). - SDL3.SDL_LogSetPriority(error_category, SDL_LogPriority.SDL_LOG_PRIORITY_INFO); + SDL_SetLogPriority(error_category, SDL_LogPriority.SDL_LOG_PRIORITY_INFO); - IntPtr ret = SDL3.SDL_GL_GetProcAddress(symbol); + IntPtr ret = SDL_GL_GetProcAddress(symbol); // Reset the logging behaviour. - SDL3.SDL_LogSetPriority(error_category, oldPriority); + SDL_SetLogPriority(error_category, oldPriority); return ret; } @@ -180,34 +181,34 @@ bool IOpenGLGraphicsSurface.VerticalSync return verticalSync.Value; int interval; - SDL3.SDL_GL_GetSwapInterval(&interval); + SDL_GL_GetSwapInterval(&interval); return (verticalSync = interval != 0).Value; } set { if (RuntimeInfo.IsDesktop) { - SDL3.SDL_GL_SetSwapInterval(value ? 1 : 0); + SDL_GL_SetSwapInterval(value ? 1 : 0); verticalSync = value; } } } - IntPtr IOpenGLGraphicsSurface.WindowContext => context; - IntPtr IOpenGLGraphicsSurface.CurrentContext => SDL3.SDL_GL_GetCurrentContext(); + IntPtr IOpenGLGraphicsSurface.WindowContext => (IntPtr)context; + IntPtr IOpenGLGraphicsSurface.CurrentContext => (IntPtr)SDL_GL_GetCurrentContext(); - void IOpenGLGraphicsSurface.SwapBuffers() => SDL3.SDL_GL_SwapWindow(window.SDLWindowHandle); - void IOpenGLGraphicsSurface.CreateContext() => SDL3.SDL_GL_CreateContext(window.SDLWindowHandle); - void IOpenGLGraphicsSurface.DeleteContext(IntPtr context) => SDL3.SDL_GL_DeleteContext(context); - void IOpenGLGraphicsSurface.MakeCurrent(IntPtr context) => SDL3.SDL_GL_MakeCurrent(window.SDLWindowHandle, context); - void IOpenGLGraphicsSurface.ClearCurrent() => SDL3.SDL_GL_MakeCurrent(window.SDLWindowHandle, IntPtr.Zero); + void IOpenGLGraphicsSurface.SwapBuffers() => SDL_GL_SwapWindow(window.SDLWindowHandle); + void IOpenGLGraphicsSurface.CreateContext() => SDL_GL_CreateContext(window.SDLWindowHandle); + void IOpenGLGraphicsSurface.DeleteContext(IntPtr context) => SDL_GL_DestroyContext((SDL_GLContextState*)context); + void IOpenGLGraphicsSurface.MakeCurrent(IntPtr context) => SDL_GL_MakeCurrent(window.SDLWindowHandle, (SDL_GLContextState*)context); + void IOpenGLGraphicsSurface.ClearCurrent() => SDL_GL_MakeCurrent(window.SDLWindowHandle, null); IntPtr IOpenGLGraphicsSurface.GetProcAddress(string symbol) => getProcAddress(symbol); #endregion #region Metal-specific implementation - IntPtr IMetalGraphicsSurface.CreateMetalView() => SDL3.SDL_Metal_CreateView(window.SDLWindowHandle); + IntPtr IMetalGraphicsSurface.CreateMetalView() => SDL_Metal_CreateView(window.SDLWindowHandle); #endregion @@ -215,6 +216,19 @@ bool IOpenGLGraphicsSurface.VerticalSync bool ILinuxGraphicsSurface.IsWayland => window.IsWayland; + [SupportedOSPlatform("linux")] + IntPtr ILinuxGraphicsSurface.DisplayHandle => window.DisplayHandle; + + #endregion + + #region Android-specific implementation + + [SupportedOSPlatform("android")] + IntPtr IAndroidGraphicsSurface.JniEnvHandle => SDL_GetAndroidJNIEnv(); + + [SupportedOSPlatform("android")] + IntPtr IAndroidGraphicsSurface.SurfaceHandle => window.SurfaceHandle; + #endregion } } diff --git a/osu.Framework/Platform/SDL3/SDL3MobileWindow.cs b/osu.Framework/Platform/SDL3/SDL3MobileWindow.cs new file mode 100644 index 0000000000..cae21ec3b8 --- /dev/null +++ b/osu.Framework/Platform/SDL3/SDL3MobileWindow.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using static SDL.SDL3; + +namespace osu.Framework.Platform.SDL3 +{ + internal class SDL3MobileWindow : SDL3Window + { + public SDL3MobileWindow(GraphicsSurfaceType surfaceType, string appName) + : base(surfaceType, appName) + { + } + + protected override unsafe void UpdateWindowStateAndSize(WindowState state, Display display, DisplayMode displayMode) + { + // This sets the status bar to hidden. + SDL_SetWindowFullscreen(SDLWindowHandle, true); + + // Don't run base logic at all. Let's keep things simple. + } + } +} diff --git a/osu.Framework/Platform/SDL/SDL3ReadableKeyCombinationProvider.cs b/osu.Framework/Platform/SDL3/SDL3ReadableKeyCombinationProvider.cs similarity index 83% rename from osu.Framework/Platform/SDL/SDL3ReadableKeyCombinationProvider.cs rename to osu.Framework/Platform/SDL3/SDL3ReadableKeyCombinationProvider.cs index bb2763fced..6ce945f708 100644 --- a/osu.Framework/Platform/SDL/SDL3ReadableKeyCombinationProvider.cs +++ b/osu.Framework/Platform/SDL3/SDL3ReadableKeyCombinationProvider.cs @@ -5,14 +5,32 @@ using osu.Framework.Input; using osu.Framework.Input.Bindings; using SDL; +using static SDL.SDL3; -namespace osu.Framework.Platform.SDL +namespace osu.Framework.Platform.SDL3 { public class SDL3ReadableKeyCombinationProvider : ReadableKeyCombinationProvider { + private static SDL_Keycode getKeyFromScancode(SDL_Scancode scancode, SDL_Keymod modstate) + { + if (FrameworkEnvironment.UseSDL3) + return SDL_GetKeyFromScancode(scancode, modstate, false); // third parameter is not useful unless SDL_HINT_KEYCODE_OPTIONS is set + + return (SDL_Keycode)global::SDL2.SDL.SDL_GetKeyFromScancode((global::SDL2.SDL.SDL_Scancode)scancode); + } + + private static string? getKeyName(SDL_Keycode keycode) + { + if (FrameworkEnvironment.UseSDL3) + return SDL_GetKeyName(keycode); + + return global::SDL2.SDL.SDL_GetKeyName((global::SDL2.SDL.SDL_Keycode)keycode); + } + protected override string GetReadableKey(InputKey key) { - var keycode = SDL3.SDL_GetKeyFromScancode(key.ToScancode()); + // In SDL3, SDL_GetKeyFromScancode may return a different keycode depending on key modifier. Use NONE to keep consistency with SDL2 for now. + var keycode = getKeyFromScancode(key.ToScancode(), SDL_KMOD_NONE); // early return if unknown. probably because key isn't a keyboard key, or doesn't map to an `SDL_Scancode`. if (keycode == SDL_Keycode.SDLK_UNKNOWN) @@ -24,7 +42,7 @@ protected override string GetReadableKey(InputKey key) if (TryGetNameFromKeycode(keycode, out name)) return name; - name = SDL3.SDL_GetKeyName(keycode); + name = getKeyName(keycode); // fall back if SDL couldn't find a name. if (string.IsNullOrEmpty(name)) @@ -32,7 +50,7 @@ protected override string GetReadableKey(InputKey key) // true if SDL_GetKeyName() returned a proper key/scancode name. // see https://github.com/libsdl-org/SDL/blob/release-2.0.16/src/events/SDL_keyboard.c#L1012 - if (((int)keycode & SDL3.SDLK_SCANCODE_MASK) != 0) + if (((int)keycode & SDLK_SCANCODE_MASK) != 0) return name; // SDL_GetKeyName() returned a unicode character that would be produced if that key was pressed. @@ -201,23 +219,23 @@ protected virtual bool TryGetNameFromKeycode(SDL_Keycode keycode, out string nam name = "Vol. Down"; return true; - case SDL_Keycode.SDLK_AUDIONEXT: + case SDL_Keycode.SDLK_MEDIA_NEXT_TRACK: name = "Media Next"; return true; - case SDL_Keycode.SDLK_AUDIOPREV: + case SDL_Keycode.SDLK_MEDIA_PREVIOUS_TRACK: name = "Media Previous"; return true; - case SDL_Keycode.SDLK_AUDIOSTOP: + case SDL_Keycode.SDLK_MEDIA_STOP: name = "Media Stop"; return true; - case SDL_Keycode.SDLK_AUDIOPLAY: + case SDL_Keycode.SDLK_MEDIA_PLAY: name = "Media Play"; return true; - case SDL_Keycode.SDLK_AUDIOMUTE: + case SDL_Keycode.SDLK_MUTE: name = "Mute"; return true; diff --git a/osu.Framework/Platform/SDL3Window.cs b/osu.Framework/Platform/SDL3/SDL3Window.cs similarity index 76% rename from osu.Framework/Platform/SDL3Window.cs rename to osu.Framework/Platform/SDL3/SDL3Window.cs index fbff4b4b87..bb03b60937 100644 --- a/osu.Framework/Platform/SDL3Window.cs +++ b/osu.Framework/Platform/SDL3/SDL3Window.cs @@ -5,26 +5,27 @@ using System.IO; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using System.Runtime.Versioning; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Extensions.ImageExtensions; using osu.Framework.Logging; -using osu.Framework.Platform.SDL; using osu.Framework.Threading; using SDL; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using Image = SixLabors.ImageSharp.Image; using Point = System.Drawing.Point; +using static SDL.SDL3; -namespace osu.Framework.Platform +namespace osu.Framework.Platform.SDL3 { /// /// Default implementation of a window, using SDL for windowing and graphics support. /// - internal abstract unsafe partial class SDL3Window : IWindow + internal abstract unsafe partial class SDL3Window : ISDLWindow { internal SDL_Window* SDLWindowHandle { get; private set; } = null; @@ -69,14 +70,14 @@ public string Title set { title = value; - ScheduleCommand(() => SDL3.SDL_SetWindowTitle(SDLWindowHandle, title)); + ScheduleCommand(() => SDL_SetWindowTitle(SDLWindowHandle, title)); } } /// /// Whether the current display server is Wayland. /// - internal bool IsWayland => SDL3.SDL_GetCurrentVideoDriver() == "wayland"; + internal bool IsWayland => SDL_GetCurrentVideoDriver() == "wayland"; /// /// Gets the native window handle as provided by the operating system. @@ -88,30 +89,30 @@ public IntPtr WindowHandle if (SDLWindowHandle == null) return IntPtr.Zero; - var props = SDL3.SDL_GetWindowProperties(SDLWindowHandle); + var props = SDL_GetWindowProperties(SDLWindowHandle); switch (RuntimeInfo.OS) { case RuntimeInfo.Platform.Windows: - return SDL3.SDL_GetProperty(props, SDL3.SDL_PROP_WINDOW_WIN32_HWND_POINTER, IntPtr.Zero); + return SDL_GetPointerProperty(props, SDL_PROP_WINDOW_WIN32_HWND_POINTER, IntPtr.Zero); case RuntimeInfo.Platform.Linux: if (IsWayland) - return SDL3.SDL_GetProperty(props, SDL3.SDL_PROP_WINDOW_WAYLAND_SURFACE_POINTER, IntPtr.Zero); + return SDL_GetPointerProperty(props, SDL_PROP_WINDOW_WAYLAND_SURFACE_POINTER, IntPtr.Zero); - if (SDL3.SDL_GetCurrentVideoDriver() == "x11") - return new IntPtr(SDL3.SDL_GetNumberProperty(props, SDL3.SDL_PROP_WINDOW_X11_WINDOW_NUMBER, 0)); + if (SDL_GetCurrentVideoDriver() == "x11") + return new IntPtr(SDL_GetNumberProperty(props, SDL_PROP_WINDOW_X11_WINDOW_NUMBER, 0)); return IntPtr.Zero; case RuntimeInfo.Platform.macOS: - return SDL3.SDL_GetProperty(props, SDL3.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, IntPtr.Zero); + return SDL_GetPointerProperty(props, SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, IntPtr.Zero); case RuntimeInfo.Platform.iOS: - return SDL3.SDL_GetProperty(props, SDL3.SDL_PROP_WINDOW_UIKIT_WINDOW_POINTER, IntPtr.Zero); + return SDL_GetPointerProperty(props, SDL_PROP_WINDOW_UIKIT_WINDOW_POINTER, IntPtr.Zero); case RuntimeInfo.Platform.Android: - return SDL3.SDL_GetProperty(props, SDL3.SDL_PROP_WINDOW_ANDROID_WINDOW_POINTER, IntPtr.Zero); + return SDL_GetPointerProperty(props, SDL_PROP_WINDOW_ANDROID_WINDOW_POINTER, IntPtr.Zero); default: throw new ArgumentOutOfRangeException(); @@ -119,6 +120,7 @@ public IntPtr WindowHandle } } + [SupportedOSPlatform("linux")] public IntPtr DisplayHandle { get @@ -126,37 +128,49 @@ public IntPtr DisplayHandle if (SDLWindowHandle == null) return IntPtr.Zero; - var props = SDL3.SDL_GetWindowProperties(SDLWindowHandle); + var props = SDL_GetWindowProperties(SDLWindowHandle); if (IsWayland) - return SDL3.SDL_GetProperty(props, SDL3.SDL_PROP_WINDOW_WAYLAND_DISPLAY_POINTER, IntPtr.Zero); + return SDL_GetPointerProperty(props, SDL_PROP_WINDOW_WAYLAND_DISPLAY_POINTER, IntPtr.Zero); - if (SDL3.SDL_GetCurrentVideoDriver() == "x11") - return SDL3.SDL_GetProperty(props, SDL3.SDL_PROP_WINDOW_X11_DISPLAY_POINTER, IntPtr.Zero); + if (SDL_GetCurrentVideoDriver() == "x11") + return SDL_GetPointerProperty(props, SDL_PROP_WINDOW_X11_DISPLAY_POINTER, IntPtr.Zero); return IntPtr.Zero; } } - public bool CapsLockPressed => SDL3.SDL_GetModState().HasFlagFast(SDL_Keymod.SDL_KMOD_CAPS); + [SupportedOSPlatform("android")] + public virtual IntPtr SurfaceHandle => throw new PlatformNotSupportedException(); + + public bool CapsLockPressed => SDL_GetModState().HasFlagFast(SDL_Keymod.SDL_KMOD_CAPS); + + public bool KeyboardAttached => SDL_HasKeyboard(); /// /// Represents a handle to this instance, used for unmanaged callbacks. /// protected ObjectHandle ObjectHandle { get; private set; } - protected SDL3Window(GraphicsSurfaceType surfaceType) + protected SDL3Window(GraphicsSurfaceType surfaceType, string appName) { ObjectHandle = new ObjectHandle(this, GCHandleType.Normal); - if (SDL3.SDL_Init(SDL_InitFlags.SDL_INIT_VIDEO | SDL_InitFlags.SDL_INIT_GAMEPAD) < 0) + SDL_SetHint(SDL_HINT_APP_NAME, appName); + + if (!SDL_Init(SDL_InitFlags.SDL_INIT_VIDEO | SDL_InitFlags.SDL_INIT_GAMEPAD)) { - throw new InvalidOperationException($"Failed to initialise SDL: {SDL3.SDL_GetError()}"); + throw new InvalidOperationException($"Failed to initialise SDL: {SDL_GetError()}"); } - SDL3.SDL_LogSetPriority(SDL_LogCategory.SDL_LOG_CATEGORY_ERROR, SDL_LogPriority.SDL_LOG_PRIORITY_DEBUG); - SDL3.SDL_SetLogOutputFunction(&logOutput, IntPtr.Zero); - SDL3.SDL_SetEventFilter(&eventFilter, ObjectHandle.Handle); + int version = SDL_GetVersion(); + Logger.Log($@"SDL3 Initialized + SDL3 Version: {SDL_VERSIONNUM_MAJOR(version)}.{SDL_VERSIONNUM_MINOR(version)}.{SDL_VERSIONNUM_MICRO(version)} + SDL3 Revision: {SDL_GetRevision()}"); + + SDL_SetLogPriority(SDL_LogCategory.SDL_LOG_CATEGORY_ERROR, SDL_LogPriority.SDL_LOG_PRIORITY_DEBUG); + SDL_SetLogOutputFunction(&logOutput, IntPtr.Zero); + SDL_SetEventFilter(&eventFilter, ObjectHandle.Handle); graphicsSurface = new SDL3GraphicsSurface(this, surfaceType); @@ -170,10 +184,11 @@ protected SDL3Window(GraphicsSurfaceType surfaceType) } [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] - private static void logOutput(IntPtr _, SDL_LogCategory category, SDL_LogPriority priority, byte* messagePtr) + private static void logOutput(IntPtr _, int category, SDL_LogPriority priority, byte* messagePtr) { - string? message = SDL3.PtrToStringUTF8(messagePtr); - Logger.Log($@"SDL {category.ReadableName()} log [{priority.ReadableName()}]: {message}"); + SDL_LogCategory categoryEnum = (SDL_LogCategory)category; + string? message = PtrToStringUTF8(messagePtr); + Logger.Log($@"SDL {categoryEnum.ReadableName()} log [{priority.ReadableName()}]: {message}"); } public void SetupWindow(FrameworkConfigManager config) @@ -191,21 +206,21 @@ public virtual void Create() flags |= WindowState.ToFlags(); flags |= graphicsSurface.Type.ToFlags(); - SDL3.SDL_SetHint(SDL3.SDL_HINT_WINDOWS_CLOSE_ON_ALT_F4, "0"u8); - SDL3.SDL_SetHint(SDL3.SDL_HINT_IME_SHOW_UI, "1"u8); - SDL3.SDL_SetHint(SDL3.SDL_HINT_MOUSE_RELATIVE_MODE_CENTER, "0"u8); - SDL3.SDL_SetHint(SDL3.SDL_HINT_TOUCH_MOUSE_EVENTS, "0"u8); // disable touch events generating synthetic mouse events on desktop platforms - SDL3.SDL_SetHint(SDL3.SDL_HINT_MOUSE_TOUCH_EVENTS, "0"u8); // disable mouse events generating synthetic touch events on mobile platforms + SDL_SetHint(SDL_HINT_WINDOWS_CLOSE_ON_ALT_F4, "0"u8); + SDL_SetHint(SDL_HINT_MOUSE_RELATIVE_MODE_CENTER, "0"u8); + SDL_SetHint(SDL_HINT_TOUCH_MOUSE_EVENTS, "0"u8); // disable touch events generating synthetic mouse events on desktop platforms + SDL_SetHint(SDL_HINT_MOUSE_TOUCH_EVENTS, "0"u8); // disable mouse events generating synthetic touch events on mobile platforms + SDL_SetHint(SDL_HINT_IME_IMPLEMENTED_UI, "composition"u8); + + SDLWindowHandle = SDL_CreateWindow(title, Size.Width, Size.Height, flags); + + if (SDLWindowHandle == null) + throw new InvalidOperationException($"Failed to create SDL window. SDL Error: {SDL_GetError()}"); // we want text input to only be active when SDL3DesktopWindowTextInput is active. // SDL activates it by default on some platforms: https://github.com/libsdl-org/SDL/blob/release-2.0.16/src/video/SDL_video.c#L573-L582 // so we deactivate it on startup. - SDL3.SDL_StopTextInput(); - - SDLWindowHandle = SDL3.SDL_CreateWindow(title, Size.Width, Size.Height, flags); - - if (SDLWindowHandle == null) - throw new InvalidOperationException($"Failed to create SDL window. SDL Error: {SDL3.SDL_GetError()}"); + SDL_StopTextInput(SDLWindowHandle); graphicsSurface.Initialise(); @@ -218,7 +233,7 @@ public virtual void Create() /// public virtual void Run() { - SDL3.SDL_AddEventWatch(&eventWatch, ObjectHandle.Handle); + SDL_AddEventWatch(&eventWatch, ObjectHandle.Handle); RunMainLoop(); } @@ -240,7 +255,7 @@ protected virtual void RunMainLoop() Exited?.Invoke(); Close(); - SDL3.SDL_Quit(); + SDL_Quit(); } /// @@ -308,23 +323,23 @@ protected void HandleEventFromWatch(SDL_Event evt) } [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] - private static int eventFilter(IntPtr userdata, SDL_Event* eventPtr) + private static SDLBool eventFilter(IntPtr userdata, SDL_Event* eventPtr) { var handle = new ObjectHandle(userdata); if (handle.GetTarget(out SDL3Window window)) window.HandleEventFromFilter(*eventPtr); - return 1; + return true; } [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] - private static int eventWatch(IntPtr userdata, SDL_Event* eventPtr) + private static SDLBool eventWatch(IntPtr userdata, SDL_Event* eventPtr) { var handle = new ObjectHandle(userdata); if (handle.GetTarget(out SDL3Window window)) window.HandleEventFromWatch(*eventPtr); - return 1; + return true; } private bool firstDraw = true; @@ -352,7 +367,7 @@ public void Close() { if (SDLWindowHandle != null) { - SDL3.SDL_DestroyWindow(SDLWindowHandle); + SDL_DestroyWindow(SDLWindowHandle); SDLWindowHandle = null; } } @@ -360,22 +375,22 @@ public void Close() public void Raise() => ScheduleCommand(() => { - var flags = SDL3.SDL_GetWindowFlags(SDLWindowHandle); + var flags = SDL_GetWindowFlags(SDLWindowHandle); if (flags.HasFlagFast(SDL_WindowFlags.SDL_WINDOW_MINIMIZED)) - SDL3.SDL_RestoreWindow(SDLWindowHandle); + SDL_RestoreWindow(SDLWindowHandle); - SDL3.SDL_RaiseWindow(SDLWindowHandle); + SDL_RaiseWindow(SDLWindowHandle); }); public void Hide() => ScheduleCommand(() => { - SDL3.SDL_HideWindow(SDLWindowHandle); + SDL_HideWindow(SDLWindowHandle); }); public void Show() => ScheduleCommand(() => { - SDL3.SDL_ShowWindow(SDLWindowHandle); + SDL_ShowWindow(SDLWindowHandle); }); public void Flash(bool flashUntilFocused = false) => ScheduleCommand(() => @@ -386,7 +401,7 @@ public void Flash(bool flashUntilFocused = false) => ScheduleCommand(() => if (!RuntimeInfo.IsDesktop) return; - SDL3.SDL_FlashWindow(SDLWindowHandle, flashUntilFocused + SDL_FlashWindow(SDLWindowHandle, flashUntilFocused ? SDL_FlashOperation.SDL_FLASH_UNTIL_FOCUSED : SDL_FlashOperation.SDL_FLASH_BRIEFLY); }); @@ -396,9 +411,13 @@ public void CancelFlash() => ScheduleCommand(() => if (!RuntimeInfo.IsDesktop) return; - SDL3.SDL_FlashWindow(SDLWindowHandle, SDL_FlashOperation.SDL_FLASH_CANCEL); + SDL_FlashWindow(SDLWindowHandle, SDL_FlashOperation.SDL_FLASH_CANCEL); }); + public void EnableScreenSuspension() => ScheduleCommand(() => SDL_EnableScreenSaver()); + + public void DisableScreenSuspension() => ScheduleCommand(() => SDL_DisableScreenSaver()); + /// /// Attempts to set the window's icon to the specified image. /// @@ -416,12 +435,12 @@ private void setSDLIcon(Image image) fixed (Rgba32* ptr = pixelSpan) { - var pixelFormat = SDL3.SDL_GetPixelFormatEnumForMasks(32, 0xff, 0xff00, 0xff0000, 0xff000000); - surface = SDL3.SDL_CreateSurfaceFrom(new IntPtr(ptr), imageSize.Width, imageSize.Height, imageSize.Width * 4, pixelFormat); + var pixelFormat = SDL_GetPixelFormatForMasks(32, 0xff, 0xff00, 0xff0000, 0xff000000); + surface = SDL_CreateSurfaceFrom(imageSize.Width, imageSize.Height, pixelFormat, new IntPtr(ptr), imageSize.Width * 4); } - SDL3.SDL_SetWindowIcon(SDLWindowHandle, surface); - SDL3.SDL_DestroySurface(surface); + SDL_SetWindowIcon(SDLWindowHandle, surface); + SDL_DestroySurface(surface); }); } @@ -443,13 +462,13 @@ private void setSDLIcon(Image image) /// private void pollSDLEvents() { - SDL3.SDL_PumpEvents(); + SDL_PumpEvents(); int eventsRead; do { - eventsRead = SDL3.SDL_PeepEvents(events, SDL_eventaction.SDL_GETEVENT, SDL_EventType.SDL_EVENT_FIRST, SDL_EventType.SDL_EVENT_LAST); + eventsRead = SDL_PeepEvents(events, SDL_EventAction.SDL_GETEVENT, SDL_EventType.SDL_EVENT_FIRST, SDL_EventType.SDL_EVENT_LAST); for (int i = 0; i < eventsRead; i++) HandleEvent(events[i]); } while (eventsRead == events_per_peep); @@ -484,11 +503,11 @@ protected virtual void HandleEvent(SDL_Event e) break; case SDL_EventType.SDL_EVENT_TEXT_EDITING: - HandleTextEditingEvent(e.edit); + handleTextEditingEvent(e.edit); break; case SDL_EventType.SDL_EVENT_TEXT_INPUT: - HandleTextInputEvent(e.text); + handleTextInputEvent(e.text); break; case SDL_EventType.SDL_EVENT_KEYMAP_CHANGED: @@ -557,6 +576,20 @@ protected virtual void HandleEvent(SDL_Event e) case SDL_EventType.SDL_EVENT_DROP_COMPLETE: handleDropEvent(e.drop); break; + + case SDL_EventType.SDL_EVENT_PEN_DOWN: + case SDL_EventType.SDL_EVENT_PEN_UP: + handlePenTouchEvent(e.ptouch); + break; + + case SDL_EventType.SDL_EVENT_PEN_BUTTON_DOWN: + case SDL_EventType.SDL_EVENT_PEN_BUTTON_UP: + handlePenButtonEvent(e.pbutton); + break; + + case SDL_EventType.SDL_EVENT_PEN_MOTION: + handlePenMotionEvent(e.pmotion); + break; } } @@ -638,7 +671,7 @@ internal virtual void SetIconFromGroup(IconGroup iconGroup) public void Dispose() { Close(); - SDL3.SDL_Quit(); + SDL_Quit(); ObjectHandle.Dispose(); } diff --git a/osu.Framework/Platform/SDL3Window_Input.cs b/osu.Framework/Platform/SDL3/SDL3Window_Input.cs similarity index 74% rename from osu.Framework/Platform/SDL3Window_Input.cs rename to osu.Framework/Platform/SDL3/SDL3Window_Input.cs index 334d96bfef..7709f531eb 100644 --- a/osu.Framework/Platform/SDL3Window_Input.cs +++ b/osu.Framework/Platform/SDL3/SDL3Window_Input.cs @@ -12,15 +12,15 @@ using osu.Framework.Input; using osu.Framework.Input.States; using osu.Framework.Logging; -using osu.Framework.Platform.SDL; using osuTK; using osuTK.Input; using SDL; using RectangleF = osu.Framework.Graphics.Primitives.RectangleF; +using static SDL.SDL3; -namespace osu.Framework.Platform +namespace osu.Framework.Platform.SDL3 { - internal partial class SDL3Window + internal unsafe partial class SDL3Window { private void setupInput(FrameworkConfigManager config) { @@ -49,7 +49,7 @@ public bool RelativeMouseMode throw new InvalidOperationException($"Cannot set {nameof(RelativeMouseMode)} to true when the cursor is not hidden via {nameof(CursorState)}."); relativeMouseMode = value; - ScheduleCommand(() => SDL3.SDL_SetRelativeMouseMode(value ? SDL_bool.SDL_TRUE : SDL_bool.SDL_FALSE)); + ScheduleCommand(() => SDL_SetWindowRelativeMouseMode(SDLWindowHandle, value)); updateCursorConfinement(); } } @@ -65,9 +65,9 @@ public bool RelativeMouseMode /// The above culminate in staying off when the cursor leaves and enters the window bounds when any buttons are pressed. /// This is an invalid state, as the cursor is inside the window, and is off. /// - internal bool MouseAutoCapture + public bool MouseAutoCapture { - set => ScheduleCommand(() => SDL3.SDL_SetHint(SDL3.SDL_HINT_MOUSE_AUTO_CAPTURE, value ? "1"u8 : "0"u8)); + set => ScheduleCommand(() => SDL_SetHint(SDL_HINT_MOUSE_AUTO_CAPTURE, value ? "1"u8 : "0"u8)); } /// @@ -99,19 +99,19 @@ private void updateCursorVisibility(bool cursorVisible) => ScheduleCommand(() => { if (cursorVisible) - SDL3.SDL_ShowCursor(); + SDL_ShowCursor(); else - SDL3.SDL_HideCursor(); + SDL_HideCursor(); }); /// /// Updates OS cursor confinement based on the current , and . /// - private unsafe void updateCursorConfinement() + private void updateCursorConfinement() { bool confined = CursorState.HasFlagFast(CursorState.Confined); - ScheduleCommand(() => SDL3.SDL_SetWindowMouseGrab(SDLWindowHandle, confined ? SDL_bool.SDL_TRUE : SDL_bool.SDL_FALSE)); + ScheduleCommand(() => SDL_SetWindowMouseGrab(SDLWindowHandle, confined)); // Don't use SDL_SetWindowMouseRect when relative mode is enabled, as relative mode already confines the OS cursor to the window. // This is fine for our use case, as UserInputManager will clamp the mouse position. @@ -120,12 +120,12 @@ private unsafe void updateCursorConfinement() ScheduleCommand(() => { var rect = ((RectangleI)(CursorConfineRect / Scale)).ToSDLRect(); - SDL3.SDL_SetWindowMouseRect(SDLWindowHandle, &rect); + SDL_SetWindowMouseRect(SDLWindowHandle, &rect); }); } else { - ScheduleCommand(() => SDL3.SDL_SetWindowMouseRect(SDLWindowHandle, null)); + ScheduleCommand(() => SDL_SetWindowMouseRect(SDLWindowHandle, null)); } } @@ -151,12 +151,14 @@ private void enqueueJoystickButtonInput(JoystickButton button, bool isPressed) private PointF previousPolledPoint = PointF.Empty; - private SDLButtonMask pressedButtons; + private SDL_MouseButtonFlags mousePressedButtons; - private unsafe void pollMouse() + private SDL_MouseButtonFlags penPressedButtons; + + private void pollMouse() { float x, y; - SDLButtonMask globalButtons = (SDLButtonMask)SDL3.SDL_GetGlobalMouseState(&x, &y); + SDL_MouseButtonFlags globalButtons = SDL_GetGlobalMouseState(&x, &y); if (previousPolledPoint.X != x || previousPolledPoint.Y != y) { @@ -170,22 +172,24 @@ private unsafe void pollMouse() } // a button should be released if it was pressed and its current global state differs (its bit in globalButtons is set to 0) - SDLButtonMask buttonsToRelease = pressedButtons & (globalButtons ^ pressedButtons); + SDL_MouseButtonFlags buttonsToRelease = mousePressedButtons & (globalButtons ^ mousePressedButtons) & ~penPressedButtons; // the outer if just optimises for the common case that there are no buttons to release. if (buttonsToRelease != 0) { - if (buttonsToRelease.HasFlagFast(SDLButtonMask.SDL_BUTTON_LMASK)) MouseUp?.Invoke(MouseButton.Left); - if (buttonsToRelease.HasFlagFast(SDLButtonMask.SDL_BUTTON_MMASK)) MouseUp?.Invoke(MouseButton.Middle); - if (buttonsToRelease.HasFlagFast(SDLButtonMask.SDL_BUTTON_RMASK)) MouseUp?.Invoke(MouseButton.Right); - if (buttonsToRelease.HasFlagFast(SDLButtonMask.SDL_BUTTON_X1MASK)) MouseUp?.Invoke(MouseButton.Button1); - if (buttonsToRelease.HasFlagFast(SDLButtonMask.SDL_BUTTON_X2MASK)) MouseUp?.Invoke(MouseButton.Button2); + if (buttonsToRelease.HasFlagFast(SDL_MouseButtonFlags.SDL_BUTTON_LMASK)) MouseUp?.Invoke(MouseButton.Left); + if (buttonsToRelease.HasFlagFast(SDL_MouseButtonFlags.SDL_BUTTON_MMASK)) MouseUp?.Invoke(MouseButton.Middle); + if (buttonsToRelease.HasFlagFast(SDL_MouseButtonFlags.SDL_BUTTON_RMASK)) MouseUp?.Invoke(MouseButton.Right); + if (buttonsToRelease.HasFlagFast(SDL_MouseButtonFlags.SDL_BUTTON_X1MASK)) MouseUp?.Invoke(MouseButton.Button1); + if (buttonsToRelease.HasFlagFast(SDL_MouseButtonFlags.SDL_BUTTON_X2MASK)) MouseUp?.Invoke(MouseButton.Button2); + + mousePressedButtons &= ~buttonsToRelease; } } - public virtual void StartTextInput(bool allowIme) => ScheduleCommand(SDL3.SDL_StartTextInput); + public virtual void StartTextInput(bool allowIme) => ScheduleCommand(() => SDL_StartTextInput(SDLWindowHandle)); - public void StopTextInput() => ScheduleCommand(SDL3.SDL_StopTextInput); + public void StopTextInput() => ScheduleCommand(() => SDL_StopTextInput(SDLWindowHandle)); /// /// Resets internal state of the platform-native IME. @@ -193,14 +197,15 @@ private unsafe void pollMouse() /// public virtual void ResetIme() => ScheduleCommand(() => { - SDL3.SDL_StopTextInput(); - SDL3.SDL_StartTextInput(); + SDL_StopTextInput(SDLWindowHandle); + SDL_StartTextInput(SDLWindowHandle); }); - public unsafe void SetTextInputRect(RectangleF rect) => ScheduleCommand(() => + public void SetTextInputRect(RectangleF rect) => ScheduleCommand(() => { + // TODO: SDL3 allows apps to set cursor position through the third parameter of SDL_SetTextInputArea. var sdlRect = ((RectangleI)(rect / Scale)).ToSDLRect(); - SDL3.SDL_SetTextInputRect(&sdlRect); + SDL_SetTextInputArea(SDLWindowHandle, &sdlRect, 0); }); #region SDL Event Handling @@ -282,7 +287,7 @@ protected virtual void HandleTouchFingerEvent(SDL_TouchFingerEvent evtTfinger) } } - private unsafe void handleControllerDeviceEvent(SDL_GamepadDeviceEvent evtCdevice) + private void handleControllerDeviceEvent(SDL_GamepadDeviceEvent evtCdevice) { switch (evtCdevice.type) { @@ -291,7 +296,7 @@ private unsafe void handleControllerDeviceEvent(SDL_GamepadDeviceEvent evtCdevic break; case SDL_EventType.SDL_EVENT_GAMEPAD_REMOVED: - SDL3.SDL_CloseGamepad(controllers[evtCdevice.which].GamepadHandle); + SDL_CloseGamepad(controllers[evtCdevice.which].GamepadHandle); controllers.Remove(evtCdevice.which); break; @@ -322,17 +327,17 @@ private void handleControllerButtonEvent(SDL_GamepadButtonEvent evtCbutton) private void handleControllerAxisEvent(SDL_GamepadAxisEvent evtCaxis) => enqueueJoystickAxisInput(evtCaxis.Axis.ToJoystickAxisSource(), evtCaxis.value); - private unsafe void addJoystick(SDL_JoystickID instanceID) + private void addJoystick(SDL_JoystickID instanceID) { // if the joystick is already opened, ignore it if (controllers.ContainsKey(instanceID)) return; - SDL_Joystick* joystick = SDL3.SDL_OpenJoystick(instanceID); + SDL_Joystick* joystick = SDL_OpenJoystick(instanceID); SDL_Gamepad* controller = null; - if (SDL3.SDL_IsGamepad(instanceID) == SDL_bool.SDL_TRUE) - controller = SDL3.SDL_OpenGamepad(instanceID); + if (SDL_IsGamepad(instanceID)) + controller = SDL_OpenGamepad(instanceID); controllers[instanceID] = new SDL3ControllerBindings(joystick, controller); } @@ -342,7 +347,7 @@ private unsafe void addJoystick(SDL_JoystickID instanceID) /// private void populateJoysticks() { - using var joysticks = SDL3.SDL_GetJoysticks(); + using var joysticks = SDL_GetJoysticks(); if (joysticks == null) return; @@ -353,7 +358,7 @@ private void populateJoysticks() } } - private unsafe void handleJoyDeviceEvent(SDL_JoyDeviceEvent evtJdevice) + private void handleJoyDeviceEvent(SDL_JoyDeviceEvent evtJdevice) { switch (evtJdevice.type) { @@ -366,7 +371,7 @@ private unsafe void handleJoyDeviceEvent(SDL_JoyDeviceEvent evtJdevice) if (!controllers.ContainsKey(evtJdevice.which)) break; - SDL3.SDL_CloseJoystick(controllers[evtJdevice.which].JoystickHandle); + SDL_CloseJoystick(controllers[evtJdevice.which].JoystickHandle); controllers.Remove(evtJdevice.which); break; } @@ -430,18 +435,30 @@ private void handleMouseWheelEvent(SDL_MouseWheelEvent evtWheel) private void handleMouseButtonEvent(SDL_MouseButtonEvent evtButton) { MouseButton button = mouseButtonFromEvent(evtButton.Button); - SDLButtonMask mask = SDL3.SDL_BUTTON(evtButton.Button); + SDL_MouseButtonFlags mask = SDL_BUTTON(evtButton.Button); Debug.Assert(Enum.IsDefined(mask)); switch (evtButton.type) { case SDL_EventType.SDL_EVENT_MOUSE_BUTTON_DOWN: - pressedButtons |= mask; + if (penPressedButtons.HasFlagFast(mask)) + { + Logger.Log("Mouse tried pressing a button already pressed by tablet!", level: LogLevel.Debug); + return; + } + + mousePressedButtons |= mask; MouseDown?.Invoke(button); break; case SDL_EventType.SDL_EVENT_MOUSE_BUTTON_UP: - pressedButtons &= ~mask; + if (!mousePressedButtons.HasFlagFast(mask)) + { + Logger.Log("Mouse tried releasing a button already released by tablet!", level: LogLevel.Debug); + return; + } + + mousePressedButtons &= ~mask; MouseUp?.Invoke(button); break; } @@ -449,33 +466,33 @@ private void handleMouseButtonEvent(SDL_MouseButtonEvent evtButton) private void handleMouseMotionEvent(SDL_MouseMotionEvent evtMotion) { - if (SDL3.SDL_GetRelativeMouseMode() == SDL_bool.SDL_FALSE) + if (!SDL_GetWindowRelativeMouseMode(SDLWindowHandle)) MouseMove?.Invoke(new Vector2(evtMotion.x * Scale, evtMotion.y * Scale)); else MouseMoveRelative?.Invoke(new Vector2(evtMotion.xrel * Scale, evtMotion.yrel * Scale)); } - protected virtual void HandleTextInputEvent(SDL_TextInputEvent evtText) + private void handleTextInputEvent(SDL_TextInputEvent evtText) { string? text = evtText.GetText(); Debug.Assert(text != null); - TriggerTextInput(text); + TextInput?.Invoke(text); } - protected virtual void HandleTextEditingEvent(SDL_TextEditingEvent evtEdit) + private void handleTextEditingEvent(SDL_TextEditingEvent evtEdit) { string? text = evtEdit.GetText(); Debug.Assert(text != null); - TriggerTextEditing(text, evtEdit.start, evtEdit.length); + TextEditing?.Invoke(text, evtEdit.start, evtEdit.length); } private void handleKeyboardEvent(SDL_KeyboardEvent evtKey) { - Key key = evtKey.keysym.ToKey(); + Key key = evtKey.ToKey(); if (key == Key.Unknown) { - Logger.Log($"Unknown SDL key: {evtKey.keysym.scancode}, {evtKey.keysym.sym}"); + Logger.Log($"Unknown SDL key: {evtKey.scancode}, {evtKey.key}"); return; } @@ -493,6 +510,55 @@ private void handleKeyboardEvent(SDL_KeyboardEvent evtKey) private void handleKeymapChangedEvent() => KeymapChanged?.Invoke(); + private void handlePenMotionEvent(SDL_PenMotionEvent evtPenMotion) + { + MouseMove?.Invoke(new Vector2(evtPenMotion.x * Scale, evtPenMotion.y * Scale)); + } + + private void handlePenTouchEvent(SDL_PenTouchEvent evtPenTouch) + { + if (evtPenTouch.eraser) + return; + + handlePenPressEvent(0, evtPenTouch.down); + } + + private void handlePenButtonEvent(SDL_PenButtonEvent evtPenButton) + { + handlePenPressEvent(evtPenButton.button, evtPenButton.down); + } + + private void handlePenPressEvent(byte penButton, bool pressed) + { + mouseButtonFromPen(pressed, penButton, out MouseButton button, out SDL_MouseButtonFlags mask); + + if (mask == 0) + return; + + if (pressed) + { + if (mousePressedButtons.HasFlagFast(mask)) + { + Logger.Log("Tablet tried pressing a button already pressed by mouse!", level: LogLevel.Debug); + return; + } + + penPressedButtons |= mask; + MouseDown?.Invoke(button); + } + else + { + if (!penPressedButtons.HasFlagFast(mask)) + { + Logger.Log("Tablet tried releasing a button already released by mouse!", level: LogLevel.Debug); + return; + } + + penPressedButtons &= ~mask; + MouseUp?.Invoke(button); + } + } + private MouseButton mouseButtonFromEvent(SDLButton button) { switch (button) @@ -518,13 +584,50 @@ private MouseButton mouseButtonFromEvent(SDLButton button) } } + private void mouseButtonFromPen(bool pressed, byte penButton, out MouseButton button, out SDL_MouseButtonFlags buttonFlag) + { + switch (penButton) + { + case 0: + button = MouseButton.Left; + buttonFlag = SDL_MouseButtonFlags.SDL_BUTTON_LMASK; + break; + + case 1: + button = MouseButton.Right; + buttonFlag = SDL_MouseButtonFlags.SDL_BUTTON_RMASK; + break; + + case 2: + button = MouseButton.Middle; + buttonFlag = SDL_MouseButtonFlags.SDL_BUTTON_MMASK; + break; + + case 3: + button = MouseButton.Button1; + buttonFlag = SDL_MouseButtonFlags.SDL_BUTTON_X1MASK; + break; + + case 4: + button = MouseButton.Button2; + buttonFlag = SDL_MouseButtonFlags.SDL_BUTTON_X2MASK; + break; + + default: + Logger.Log($"unknown pen button index: {penButton}, ignoring..."); + button = MouseButton.Button3; + buttonFlag = 0; + break; + } + } + #endregion /// /// Update the host window manager's cursor position based on a location relative to window coordinates. /// /// A position inside the window. - public unsafe void UpdateMousePosition(Vector2 mousePosition) => ScheduleCommand(() => SDL3.SDL_WarpMouseInWindow(SDLWindowHandle, mousePosition.X / Scale, mousePosition.Y / Scale)); + public void UpdateMousePosition(Vector2 mousePosition) => ScheduleCommand(() => SDL_WarpMouseInWindow(SDLWindowHandle, mousePosition.X / Scale, mousePosition.Y / Scale)); private void updateConfineMode() { @@ -610,15 +713,11 @@ private void updateConfineMode() /// public event Action? TextInput; - protected void TriggerTextInput(string text) => TextInput?.Invoke(text); - /// /// Invoked when an IME text editing event occurs. /// public event TextEditingDelegate? TextEditing; - protected void TriggerTextEditing(string text, int start, int length) => TextEditing?.Invoke(text, start, length); - /// public event Action? KeymapChanged; @@ -648,13 +747,5 @@ private void updateConfineMode() public event Action? TouchUp; #endregion - - /// - /// Fired when text is edited, usually via IME composition. - /// - /// The composition text. - /// The index of the selection start. - /// The length of the selection. - public delegate void TextEditingDelegate(string text, int start, int length); } } diff --git a/osu.Framework/Platform/SDL3Window_Windowing.cs b/osu.Framework/Platform/SDL3/SDL3Window_Windowing.cs similarity index 86% rename from osu.Framework/Platform/SDL3Window_Windowing.cs rename to osu.Framework/Platform/SDL3/SDL3Window_Windowing.cs index ad31c39f6e..2a2fd94181 100644 --- a/osu.Framework/Platform/SDL3Window_Windowing.cs +++ b/osu.Framework/Platform/SDL3/SDL3Window_Windowing.cs @@ -11,11 +11,11 @@ using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Logging; -using osu.Framework.Platform.SDL; using osuTK; using SDL; +using static SDL.SDL3; -namespace osu.Framework.Platform +namespace osu.Framework.Platform.SDL3 { internal partial class SDL3Window { @@ -24,7 +24,7 @@ private unsafe void setupWindowing(FrameworkConfigManager config) config.BindWith(FrameworkSetting.MinimiseOnFocusLossInFullscreen, minimiseOnFocusLoss); minimiseOnFocusLoss.BindValueChanged(e => { - ScheduleCommand(() => SDL3.SDL_SetHint(SDL3.SDL_HINT_VIDEO_MINIMIZE_ON_FOCUS_LOSS, e.NewValue ? "1"u8 : "0"u8)); + ScheduleCommand(() => SDL_SetHint(SDL_HINT_VIDEO_MINIMIZE_ON_FOCUS_LOSS, e.NewValue ? "1"u8 : "0"u8)); }, true); fetchDisplays(); @@ -69,7 +69,7 @@ private unsafe void setupWindowing(FrameworkConfigManager config) if (min.Width > sizeWindowed.MaxValue.Width || min.Height > sizeWindowed.MaxValue.Height) throw new InvalidOperationException($"Expected a size less than max window size ({sizeWindowed.MaxValue}), got {min}"); - ScheduleCommand(() => SDL3.SDL_SetWindowMinimumSize(SDLWindowHandle, min.Width, min.Height)); + ScheduleCommand(() => SDL_SetWindowMinimumSize(SDLWindowHandle, min.Width, min.Height)); }; sizeWindowed.MaxValueChanged += max => @@ -80,7 +80,7 @@ private unsafe void setupWindowing(FrameworkConfigManager config) if (max.Width < sizeWindowed.MinValue.Width || max.Height < sizeWindowed.MinValue.Height) throw new InvalidOperationException($"Expected a size greater than min window size ({sizeWindowed.MinValue}), got {max}"); - ScheduleCommand(() => SDL3.SDL_SetWindowMaximumSize(SDLWindowHandle, max.Width, max.Height)); + ScheduleCommand(() => SDL_SetWindowMaximumSize(SDLWindowHandle, max.Width, max.Height)); }; config.BindWith(FrameworkSetting.SizeFullscreen, sizeFullscreen); @@ -90,6 +90,9 @@ private unsafe void setupWindowing(FrameworkConfigManager config) config.BindWith(FrameworkSetting.WindowMode, WindowMode); + if (!SupportedWindowModes.Contains(WindowMode.Value)) + WindowMode.Value = DefaultWindowMode; + WindowMode.BindValueChanged(evt => { switch (evt.NewValue) @@ -127,7 +130,7 @@ private void initialiseWindowingAfterCreation() public bool Focused { get => focused; - private set + protected set { if (value == focused) return; @@ -161,7 +164,7 @@ public unsafe Point Position set { position = value; - ScheduleCommand(() => SDL3.SDL_SetWindowPosition(SDLWindowHandle, value.X, value.Y)); + ScheduleCommand(() => SDL_SetWindowPosition(SDLWindowHandle, value.X, value.Y)); } } @@ -179,7 +182,7 @@ public unsafe bool Resizable return; resizable = value; - ScheduleCommand(() => SDL3.SDL_SetWindowResizable(SDLWindowHandle, value ? SDL_bool.SDL_TRUE : SDL_bool.SDL_FALSE)); + ScheduleCommand(() => SDL_SetWindowResizable(SDLWindowHandle, value)); } } @@ -241,9 +244,9 @@ public unsafe bool Visible ScheduleCommand(() => { if (value) - SDL3.SDL_ShowWindow(SDLWindowHandle); + SDL_ShowWindow(SDLWindowHandle); else - SDL3.SDL_HideWindow(SDLWindowHandle); + SDL_HideWindow(SDLWindowHandle); }); } } @@ -278,7 +281,7 @@ public WindowState WindowState /// public Size ClientSize => new Size((int)(Size.Width * Scale), (int)(Size.Height * Scale)); - public float Scale = 1; + public float Scale { get; private set; } = 1; #region Displays (mostly self-contained) @@ -326,10 +329,10 @@ private void assertDisplaysMatchSDL() private static ImmutableArray getSDLDisplays() { - using var displays = SDL3.SDL_GetDisplays(); + using var displays = SDL_GetDisplays(); if (displays == null) - throw new InvalidOperationException($"Failed to get number of SDL displays. SDL Error: {SDL3.SDL_GetError()}"); + throw new InvalidOperationException($"Failed to get number of SDL displays. SDL Error: {SDL_GetError()}"); var builder = ImmutableArray.CreateBuilder(displays.Count); @@ -350,9 +353,9 @@ private static unsafe bool tryGetDisplayFromSDL(int displayIndex, SDL_DisplayID SDL_Rect rect; - if (SDL3.SDL_GetDisplayBounds(displayID, &rect) < 0) + if (!SDL_GetDisplayBounds(displayID, &rect)) { - Logger.Log($"Failed to get display bounds for display at index ({displayIndex}). SDL Error: {SDL3.SDL_GetError()}"); + Logger.Log($"Failed to get display bounds for display at index ({displayIndex}). SDL Error: {SDL_GetError()}"); display = null; return false; } @@ -361,11 +364,11 @@ private static unsafe bool tryGetDisplayFromSDL(int displayIndex, SDL_DisplayID if (RuntimeInfo.IsDesktop) { - using var modes = SDL3.SDL_GetFullscreenDisplayModes(displayID); + using var modes = SDL_GetFullscreenDisplayModes(displayID); if (modes == null) { - Logger.Log($"Failed to get display modes for display at index ({displayIndex}) ({rect.w}x{rect.h}). SDL Error: {SDL3.SDL_GetError()}"); + Logger.Log($"Failed to get display modes for display at index ({displayIndex}) ({rect.w}x{rect.h}). SDL Error: {SDL_GetError()}"); display = null; return false; } @@ -379,7 +382,7 @@ private static unsafe bool tryGetDisplayFromSDL(int displayIndex, SDL_DisplayID displayModes[i] = modes[i].ToDisplayMode(displayIndex); } - display = new Display(displayIndex, SDL3.SDL_GetDisplayName(displayID), new Rectangle(rect.x, rect.y, rect.w, rect.h), displayModes); + display = new Display(displayIndex, SDL_GetDisplayName(displayID), new Rectangle(rect.x, rect.y, rect.w, rect.h), displayModes); return true; } @@ -405,7 +408,7 @@ private unsafe Rectangle windowDisplayBounds get { SDL_Rect rect; - SDL3.SDL_GetDisplayBounds(displayID, &rect); + SDL_GetDisplayBounds(displayID, &rect); return new Rectangle(rect.x, rect.y, rect.w, rect.h); } } @@ -444,7 +447,7 @@ private unsafe Rectangle windowDisplayBounds private unsafe void fetchWindowSize() { int w, h; - SDL3.SDL_GetWindowSize(SDLWindowHandle, &w, &h); + SDL_GetWindowSize(SDLWindowHandle, &w, &h); int drawableW = graphicsSurface.GetDrawableSize().Width; @@ -470,7 +473,7 @@ private unsafe void handleWindowEvent(SDL_WindowEvent evtWindow) case SDL_EventType.SDL_EVENT_WINDOW_MOVED: // explicitly requery as there are occasions where what SDL has provided us with is not up-to-date. int x, y; - SDL3.SDL_GetWindowPosition(SDLWindowHandle, &x, &y); + SDL_GetWindowPosition(SDLWindowHandle, &x, &y); var newPosition = new Point(x, y); if (!newPosition.Equals(Position)) @@ -523,6 +526,9 @@ private unsafe void handleWindowEvent(SDL_WindowEvent evtWindow) case SDL_EventType.SDL_EVENT_WINDOW_FOCUS_LOST: case SDL_EventType.SDL_EVENT_WINDOW_SHOWN: case SDL_EventType.SDL_EVENT_WINDOW_HIDDEN: + + // See https://github.com/libsdl-org/SDL/issues/9585 + case SDL_EventType.SDL_EVENT_WINDOW_RESIZED when RuntimeInfo.OS == RuntimeInfo.Platform.Android: fetchDisplays(); break; } @@ -574,7 +580,7 @@ private unsafe void updateAndFetchWindowSpecifics() } else { - windowState = SDL3.SDL_GetWindowFlags(SDLWindowHandle).ToWindowState(SDL3.SDL_GetWindowFullscreenMode(SDLWindowHandle) == null); + windowState = SDL_GetWindowFlags(SDLWindowHandle).ToWindowState(SDL_GetWindowFullscreenMode(SDLWindowHandle) == null); } if (windowState != stateBefore) @@ -585,7 +591,7 @@ private unsafe void updateAndFetchWindowSpecifics() windowMaximised = maximized; } - var newDisplayID = SDL3.SDL_GetDisplayForWindow(SDLWindowHandle); + var newDisplayID = SDL_GetDisplayForWindow(SDLWindowHandle); if (displayID != newDisplayID) { @@ -602,10 +608,10 @@ private unsafe void updateAndFetchWindowSpecifics() private static bool tryGetDisplayIndex(SDL_DisplayID id, out int index) { - using var displays = SDL3.SDL_GetDisplays(); + using var displays = SDL_GetDisplays(); if (displays == null) - throw new InvalidOperationException($"Failed to get SDL displays. SDL error: {SDL3.SDL_GetError()}"); + throw new InvalidOperationException($"Failed to get SDL displays. SDL error: {SDL_GetError()}"); for (int i = 0; i < displays.Count; i++) { @@ -633,9 +639,9 @@ protected virtual unsafe void UpdateWindowStateAndSize(WindowState state, Displa case WindowState.Normal: Size = sizeWindowed.Value; - SDL3.SDL_RestoreWindow(SDLWindowHandle); - SDL3.SDL_SetWindowSize(SDLWindowHandle, Size.Width, Size.Height); - SDL3.SDL_SetWindowResizable(SDLWindowHandle, Resizable ? SDL_bool.SDL_TRUE : SDL_bool.SDL_FALSE); + SDL_RestoreWindow(SDLWindowHandle); + SDL_SetWindowSize(SDLWindowHandle, Size.Width, Size.Height); + SDL_SetWindowResizable(SDLWindowHandle, Resizable); readWindowPositionFromConfig(state, display); break; @@ -647,8 +653,8 @@ protected virtual unsafe void UpdateWindowStateAndSize(WindowState state, Displa ensureWindowOnDisplay(display); - SDL3.SDL_SetWindowFullscreenMode(SDLWindowHandle, &closestMode); - SDL3.SDL_SetWindowFullscreen(SDLWindowHandle, SDL_bool.SDL_TRUE); + SDL_SetWindowFullscreenMode(SDLWindowHandle, &closestMode); + SDL_SetWindowFullscreen(SDLWindowHandle, true); break; case WindowState.FullscreenBorderless: @@ -656,16 +662,16 @@ protected virtual unsafe void UpdateWindowStateAndSize(WindowState state, Displa break; case WindowState.Maximised: - SDL3.SDL_RestoreWindow(SDLWindowHandle); + SDL_RestoreWindow(SDLWindowHandle); ensureWindowOnDisplay(display); - SDL3.SDL_MaximizeWindow(SDLWindowHandle); + SDL_MaximizeWindow(SDLWindowHandle); break; case WindowState.Minimised: ensureWindowOnDisplay(display); - SDL3.SDL_MinimizeWindow(SDLWindowHandle); + SDL_MinimizeWindow(SDLWindowHandle); break; } } @@ -678,7 +684,7 @@ private static unsafe bool tryFetchDisplayMode(SDL_Window* windowHandle, WindowS return false; } - var mode = windowState == WindowState.Fullscreen ? SDL3.SDL_GetWindowFullscreenMode(windowHandle) : SDL3.SDL_GetDesktopDisplayMode(displayID); + var mode = windowState == WindowState.Fullscreen ? SDL_GetWindowFullscreenMode(windowHandle) : SDL_GetDesktopDisplayMode(displayID); string type = windowState == WindowState.Fullscreen ? "fullscreen" : "desktop"; if (mode != null) @@ -689,7 +695,7 @@ private static unsafe bool tryFetchDisplayMode(SDL_Window* windowHandle, WindowS } else { - Logger.Log($"Failed to get {type} display mode. Display index: {display.Index}. SDL error: {SDL3.SDL_GetError()}"); + Logger.Log($"Failed to get {type} display mode. Display index: {display.Index}. SDL error: {SDL_GetError()}"); displayMode = default; return false; } @@ -725,7 +731,7 @@ private unsafe void ensureWindowOnDisplay(Display display) { if (tryGetDisplayAtIndex(display.Index, out var requestedID)) { - if (requestedID == SDL3.SDL_GetDisplayForWindow(SDLWindowHandle)) + if (requestedID == SDL_GetDisplayForWindow(SDLWindowHandle)) return; } @@ -800,9 +806,7 @@ protected virtual unsafe Size SetBorderless(Display display) { ensureWindowOnDisplay(display); - // this is a generally sane method of handling borderless, and works well on macOS and linux. - SDL3.SDL_SetWindowFullscreenMode(SDLWindowHandle, null); - SDL3.SDL_SetWindowFullscreen(SDLWindowHandle, SDL_bool.SDL_TRUE); + SDL_SetWindowFullscreen(SDLWindowHandle, true); return display.Bounds.Size; } @@ -846,10 +850,10 @@ private static bool tryGetDisplayAtIndex(int index, out SDL_DisplayID displayID) { ArgumentOutOfRangeException.ThrowIfNegative(index); - using var displays = SDL3.SDL_GetDisplays(); + using var displays = SDL_GetDisplays(); if (displays == null) - throw new InvalidOperationException($"Unable to get displays. SDL error: {SDL3.SDL_GetError()}"); + throw new InvalidOperationException($"Unable to get displays. SDL error: {SDL_GetError()}"); if (index >= displays.Count) { @@ -863,7 +867,7 @@ private static bool tryGetDisplayAtIndex(int index, out SDL_DisplayID displayID) private static unsafe SDL_DisplayMode getClosestDisplayMode(SDL_Window* windowHandle, Size size, Display display, DisplayMode requestedMode) { - SDL3.SDL_ClearError(); // clear any stale error. + SDL_ClearError(); // clear any stale error. if (!tryGetDisplayAtIndex(display.Index, out var displayID)) throw new ArgumentException($"Requested display index ({display}) is invalid.", nameof(display)); @@ -872,32 +876,34 @@ private static unsafe SDL_DisplayMode getClosestDisplayMode(SDL_Window* windowHa if (size.Width == 9999 && size.Height == 9999) size = display.Bounds.Size; - var mode = SDL3.SDL_GetClosestFullscreenDisplayMode(displayID, size.Width, size.Height, requestedMode.RefreshRate, SDL_bool.SDL_TRUE); - if (mode != null) - return *mode; - else - Logger.Log($"Unable to get preferred display mode (try #1/2). Target display: {display.Index}, mode: {size.Width}x{size.Height}@{requestedMode.RefreshRate}. SDL error: {SDL3Extensions.GetAndClearError()}"); + SDL_DisplayMode mode; + + if (SDL_GetClosestFullscreenDisplayMode(displayID, size.Width, size.Height, requestedMode.RefreshRate, true, &mode)) + return mode; + + Logger.Log( + $"Unable to get preferred display mode (try #1/2). Target display: {display.Index}, mode: {size.Width}x{size.Height}@{requestedMode.RefreshRate}. SDL error: {SDL3Extensions.GetAndClearError()}"); // fallback to current display's native bounds - mode = SDL3.SDL_GetClosestFullscreenDisplayMode(displayID, display.Bounds.Width, display.Bounds.Height, 0f, SDL_bool.SDL_TRUE); - if (mode != null) - return *mode; - else - Logger.Log($"Unable to get preferred display mode (try #2/2). Target display: {display.Index}, mode: {display.Bounds.Width}x{display.Bounds.Height}@default. SDL error: {SDL3Extensions.GetAndClearError()}"); + if (SDL_GetClosestFullscreenDisplayMode(displayID, display.Bounds.Width, display.Bounds.Height, 0f, true, &mode)) + return mode; + + Logger.Log( + $"Unable to get preferred display mode (try #2/2). Target display: {display.Index}, mode: {display.Bounds.Width}x{display.Bounds.Height}@default. SDL error: {SDL3Extensions.GetAndClearError()}"); // try the display's native display mode. - mode = SDL3.SDL_GetDesktopDisplayMode(displayID); - if (mode != null) - return *mode; - else - Logger.Log($"Failed to get desktop display mode (try #1/1). Target display: {display.Index}. SDL error: {SDL3Extensions.GetAndClearError()}", level: LogLevel.Error); + var modePtr = SDL_GetDesktopDisplayMode(displayID); + if (modePtr != null) + return *modePtr; + + Logger.Log($"Failed to get desktop display mode (try #1/1). Target display: {display.Index}. SDL error: {SDL3Extensions.GetAndClearError()}", level: LogLevel.Error); // finally return the current mode if everything else fails. - mode = SDL3.SDL_GetWindowFullscreenMode(windowHandle); - if (mode != null) - return *mode; - else - Logger.Log($"Failed to get window display mode. SDL error: {SDL3Extensions.GetAndClearError()}", level: LogLevel.Error); + modePtr = SDL_GetWindowFullscreenMode(windowHandle); + if (modePtr != null) + return *modePtr; + + Logger.Log($"Failed to get window display mode. SDL error: {SDL3Extensions.GetAndClearError()}", level: LogLevel.Error); throw new InvalidOperationException("couldn't retrieve valid display mode"); } diff --git a/osu.Framework/Platform/SDL3GameHost.cs b/osu.Framework/Platform/SDLGameHost.cs similarity index 63% rename from osu.Framework/Platform/SDL3GameHost.cs rename to osu.Framework/Platform/SDLGameHost.cs index 345c06a13b..042a7fc9a9 100644 --- a/osu.Framework/Platform/SDL3GameHost.cs +++ b/osu.Framework/Platform/SDLGameHost.cs @@ -10,28 +10,35 @@ using osu.Framework.Input.Handlers.Mouse; using osu.Framework.Input.Handlers.Tablet; using osu.Framework.Input.Handlers.Touch; -using osu.Framework.Platform.SDL; +using osu.Framework.Platform.SDL2; +using osu.Framework.Platform.SDL3; +using SixLabors.ImageSharp.Formats.Png; namespace osu.Framework.Platform { - public abstract class SDL3GameHost : GameHost + public abstract class SDLGameHost : GameHost { - public override bool CapsLockEnabled => (Window as SDL3Window)?.CapsLockPressed == true; + public override bool CapsLockEnabled => (Window as ISDLWindow)?.CapsLockPressed == true; - protected SDL3GameHost(string gameName, HostOptions? options = null) + public override bool OnScreenKeyboardOverlapsGameWindow => (Window as ISDLWindow)?.KeyboardAttached == false; + + protected SDLGameHost(string gameName, HostOptions? options = null) : base(gameName, options) { } protected override TextInputSource CreateTextInput() { - if (Window is SDL3Window window) - return new SDL3WindowTextInput(window); + if (Window is ISDLWindow window) + return new SDLWindowTextInput(window); return base.CreateTextInput(); } - protected override Clipboard CreateClipboard() => new SDL3Clipboard(); + protected override Clipboard CreateClipboard() + => FrameworkEnvironment.UseSDL3 + ? new SDL3Clipboard(PngFormat.Instance) // PNG works well on linux + : new SDL2Clipboard(); protected override IEnumerable CreateAvailableInputHandlers() => new InputHandler[] diff --git a/osu.Framework/Platform/Windows/IWindowsWindow.cs b/osu.Framework/Platform/Windows/IWindowsWindow.cs new file mode 100644 index 0000000000..19b0431d41 --- /dev/null +++ b/osu.Framework/Platform/Windows/IWindowsWindow.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osuTK; + +namespace osu.Framework.Platform.Windows +{ + internal interface IWindowsWindow : ISDLWindow + { + /// + /// The last mouse position as reported by . + /// + Vector2? LastMousePosition { get; set; } + } +} diff --git a/osu.Framework/Platform/Windows/Native/Input.cs b/osu.Framework/Platform/Windows/Native/Input.cs index fcb28d5634..851172654f 100644 --- a/osu.Framework/Platform/Windows/Native/Input.cs +++ b/osu.Framework/Platform/Windows/Native/Input.cs @@ -2,12 +2,72 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Drawing; using System.Runtime.InteropServices; namespace osu.Framework.Platform.Windows.Native { internal static class Input { + [DllImport("user32.dll")] + public static extern bool RegisterRawInputDevices( + [MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 1)] + RawInputDevice[] pRawInputDevices, + int uiNumDevices, + int cbSize); + + [DllImport("user32.dll")] + public static extern int GetRawInputData(IntPtr hRawInput, RawInputCommand uiCommand, out RawInputData pData, ref int pcbSize, int cbSizeHeader); + + internal static Rectangle VirtualScreenRect => new Rectangle( + GetSystemMetrics(SM_XVIRTUALSCREEN), + GetSystemMetrics(SM_YVIRTUALSCREEN), + GetSystemMetrics(SM_CXVIRTUALSCREEN), + GetSystemMetrics(SM_CYVIRTUALSCREEN)); + + internal const int WM_INPUT = 0x00FF; + + [DllImport("user32.dll")] + public static extern int GetSystemMetrics(int nIndex); + + internal static Rectangle GetVirtualScreenRect() => new Rectangle( + GetSystemMetrics(SM_XVIRTUALSCREEN), + GetSystemMetrics(SM_YVIRTUALSCREEN), + GetSystemMetrics(SM_CXVIRTUALSCREEN), + GetSystemMetrics(SM_CYVIRTUALSCREEN) + ); + + public const int SM_XVIRTUALSCREEN = 76; + public const int SM_YVIRTUALSCREEN = 77; + public const int SM_CXVIRTUALSCREEN = 78; + public const int SM_CYVIRTUALSCREEN = 79; + + public const long MI_WP_SIGNATURE = 0xFF515700; + public const long MI_WP_SIGNATURE_MASK = 0xFFFFFF00; + + /// + /// Flag distinguishing touch input from mouse input in events. + /// + /// + /// https://docs.microsoft.com/en-us/windows/win32/tablet/system-events-and-mouse-messages + /// Additionally, the eighth bit, masked by 0x80, is used to differentiate touch input from pen input (0 = pen, 1 = touch). + /// + private const long touch_flag = 0x80; + + /// + /// https://docs.microsoft.com/en-us/windows/win32/tablet/system-events-and-mouse-messages + /// + /// for the current event. + /// true if this event is from a finger touch, false if it's from mouse or pen input. + public static bool IsTouchEvent(long dw) => (dw & MI_WP_SIGNATURE_MASK) == MI_WP_SIGNATURE && HasTouchFlag(dw); + + /// or + /// Whether has the set. + public static bool HasTouchFlag(long extraInformation) => (extraInformation & touch_flag) == touch_flag; + + [DllImport("user32.dll", SetLastError = false)] + public static extern long GetMessageExtraInfo(); + [DllImport("user32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern unsafe bool SetWindowFeedbackSetting(IntPtr hwnd, FeedbackType feedback, ulong flags, uint size, int* configuration); @@ -27,6 +87,277 @@ public static unsafe void SetWindowFeedbackSetting(IntPtr hwnd, FeedbackType fee } } + /// + /// Value type for raw input. + /// + public struct RawInputData + { + /// Header for the data. + public RawInputHeader Header; + + /// Mouse raw input data. + public RawMouse Mouse; + + // This struct is a lot larger but the remaining elements have been omitted until required (Keyboard / HID / Touch). + } + + /// + /// Contains information about the state of the mouse. + /// + public struct RawMouse + { + /// + /// The mouse state. + /// + public RawMouseFlags Flags; + + /// + /// Flags for the event. + /// + public RawMouseButtons ButtonFlags; + + /// + /// If the mouse wheel is moved, this will contain the delta amount. + /// + public short ButtonData; + + /// + /// Raw button data. + /// + public uint RawButtons; + + /// + /// The motion in the X direction. This is signed relative motion or + /// absolute motion, depending on the value of usFlags. + /// + public int LastX; + + /// + /// The motion in the Y direction. This is signed relative motion or absolute motion, + /// depending on the value of usFlags. + /// + public int LastY; + + /// + /// The device-specific additional information for the event. + /// + public uint ExtraInformation; + } + + /// + /// Enumeration containing the flags for raw mouse data. + /// + [Flags] + public enum RawMouseFlags : ushort + { + /// Relative to the last position. + MoveRelative = 0, + + /// Absolute positioning. + MoveAbsolute = 1, + + /// Coordinate data is mapped to a virtual desktop. + VirtualDesktop = 2, + + /// Attributes for the mouse have changed. + AttributesChanged = 4, + + /// WM_MOUSEMOVE and WM_INPUT don't coalesce + MoveNoCoalesce = 8, + } + + /// + /// Enumeration containing the button data for raw mouse input. + /// + public enum RawMouseButtons : ushort + { + /// No button. + None = 0, + + /// Left (button 1) down. + LeftDown = 0x0001, + + /// Left (button 1) up. + LeftUp = 0x0002, + + /// Right (button 2) down. + RightDown = 0x0004, + + /// Right (button 2) up. + RightUp = 0x0008, + + /// Middle (button 3) down. + MiddleDown = 0x0010, + + /// Middle (button 3) up. + MiddleUp = 0x0020, + + /// Button 4 down. + Button4Down = 0x0040, + + /// Button 4 up. + Button4Up = 0x0080, + + /// Button 5 down. + Button5Down = 0x0100, + + /// Button 5 up. + Button5Up = 0x0200, + + /// Mouse wheel moved. + MouseWheel = 0x0400 + } + + /// + /// Enumeration contanining the command types to issue. + /// + public enum RawInputCommand + { + /// + /// Get input data. + /// + Input = 0x10000003, + + /// + /// Get header data. + /// + Header = 0x10000005 + } + + /// + /// Enumeration containing the type device the raw input is coming from. + /// + public enum RawInputType + { + /// + /// Mouse input. + /// + Mouse = 0, + + /// + /// Keyboard input. + /// + Keyboard = 1, + + /// + /// Another device that is not the keyboard or the mouse. + /// + HID = 2 + } + +#pragma warning disable IDE1006 // Naming style + + /// + /// Value type for a raw input header. + /// + [StructLayout(LayoutKind.Sequential)] + public struct RawInputHeader + { + /// Type of device the input is coming from. + public RawInputType Type; + + /// Size of the packet of data. + public int Size; + + /// Handle to the device sending the data. + public IntPtr Device; + + /// wParam from the window message. + public IntPtr wParam; + } + + [StructLayout(LayoutKind.Sequential)] + public struct RawInputDevice + { + /// Top level collection Usage page for the raw input device. + public HIDUsagePage UsagePage; + + /// Top level collection Usage for the raw input device. + public HIDUsage Usage; + + /// Mode flag that specifies how to interpret the information provided by UsagePage and Usage. + public RawInputDeviceFlags Flags; + + /// Handle to the target device. If NULL, it follows the keyboard focus. + public IntPtr WindowHandle; + } + +#pragma warning restore IDE1006 + + /// Enumeration containing flags for a raw input device. + public enum RawInputDeviceFlags + { + /// No flags. + None = 0, + + /// If set, this removes the top level collection from the inclusion list. This tells the operating system to stop reading from a device which matches the top level collection. + Remove = 0x00000001, + + /// If set, this specifies the top level collections to exclude when reading a complete usage page. This flag only affects a TLC whose usage page is already specified with PageOnly. + Exclude = 0x00000010, + + /// If set, this specifies all devices whose top level collection is from the specified usUsagePage. Note that Usage must be zero. To exclude a particular top level collection, use Exclude. + PageOnly = 0x00000020, + + /// If set, this prevents any devices specified by UsagePage or Usage from generating legacy messages. This is only for the mouse and keyboard. + NoLegacy = 0x00000030, + + /// If set, this enables the caller to receive the input even when the caller is not in the foreground. Note that WindowHandle must be specified. + InputSink = 0x00000100, + + /// If set, the mouse button click does not activate the other window. + CaptureMouse = 0x00000200, + + /// If set, the application-defined keyboard device hotkeys are not handled. However, the system hotkeys; for example, ALT+TAB and CTRL+ALT+DEL, are still handled. By default, all keyboard hotkeys are handled. NoHotKeys can be specified even if NoLegacy is not specified and WindowHandle is NULL. + NoHotKeys = 0x00000200, + + /// If set, application keys are handled. NoLegacy must be specified. Keyboard only. + AppKeys = 0x00000400 + } + + public enum HIDUsage : ushort + { + Pointer = 0x01, + Mouse = 0x02, + Joystick = 0x04, + Gamepad = 0x05, + Keyboard = 0x06, + Keypad = 0x07, + SystemControl = 0x80, + } + + public enum HIDUsagePage : ushort + { + Undefined = 0x00, + Generic = 0x01, + Simulation = 0x02, + VR = 0x03, + Sport = 0x04, + Game = 0x05, + Keyboard = 0x07, + LED = 0x08, + Button = 0x09, + Ordinal = 0x0A, + Telephony = 0x0B, + Consumer = 0x0C, + Digitizer = 0x0D, + PID = 0x0F, + Unicode = 0x10, + AlphaNumeric = 0x14, + Medical = 0x40, + MonitorPage0 = 0x80, + MonitorPage1 = 0x81, + MonitorPage2 = 0x82, + MonitorPage3 = 0x83, + PowerPage0 = 0x84, + PowerPage1 = 0x85, + PowerPage2 = 0x86, + PowerPage3 = 0x87, + BarCode = 0x8C, + Scale = 0x8D, + MSR = 0x8E + } + public enum FeedbackType { TouchContactVisualization = 1, diff --git a/osu.Framework/Platform/Windows/WindowsWindow.cs b/osu.Framework/Platform/Windows/SDL2WindowsWindow.cs similarity index 74% rename from osu.Framework/Platform/Windows/WindowsWindow.cs rename to osu.Framework/Platform/Windows/SDL2WindowsWindow.cs index 60b56e06cd..4284cbf0e1 100644 --- a/osu.Framework/Platform/Windows/WindowsWindow.cs +++ b/osu.Framework/Platform/Windows/SDL2WindowsWindow.cs @@ -2,24 +2,21 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Diagnostics; using System.Drawing; -using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Versioning; -using osu.Framework.Allocation; using osu.Framework.Input.Handlers.Mouse; -using osu.Framework.Platform.SDL; +using osu.Framework.Platform.SDL2; using osu.Framework.Platform.Windows.Native; using osuTK; using osuTK.Input; -using SDL; using Icon = osu.Framework.Platform.Windows.Native.Icon; +using static SDL2.SDL; namespace osu.Framework.Platform.Windows { [SupportedOSPlatform("windows")] - internal class WindowsWindow : SDL3DesktopWindow + internal class SDL2WindowsWindow : SDL2DesktopWindow, IWindowsWindow { private const int seticon_message = 0x0080; private const int icon_big = 1; @@ -31,13 +28,15 @@ internal class WindowsWindow : SDL3DesktopWindow private Icon? smallIcon; private Icon? largeIcon; + private const int wm_killfocus = 8; + /// /// Whether to apply the . /// private readonly bool applyBorderlessWindowHack; - public WindowsWindow(GraphicsSurfaceType surfaceType) - : base(surfaceType) + public SDL2WindowsWindow(GraphicsSurfaceType surfaceType, string appName) + : base(surfaceType, appName) { switch (surfaceType) { @@ -50,63 +49,72 @@ public WindowsWindow(GraphicsSurfaceType surfaceType) applyBorderlessWindowHack = false; break; } + + if (!declareDpiAwareV2()) + declareDpiAware(); } - public override void Create() + private bool declareDpiAwareV2() { - base.Create(); - - // disable all pen and touch feedback as this causes issues when running "optimised" fullscreen under Direct3D11. - foreach (var feedbackType in Enum.GetValues()) - Native.Input.SetWindowFeedbackSetting(WindowHandle, feedbackType, false); + try + { + return SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT.DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + } + catch + { + return false; + } } - public override unsafe void Run() + private bool declareDpiAware() { - SDL3.SDL_SetWindowsMessageHook(&messageHook, ObjectHandle.Handle); - base.Run(); + try + { + return SetProcessDpiAwareness(ProcessDpiAwareness.Process_Per_Monitor_DPI_Aware); + } + catch + { + return false; + } } - [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] - private static unsafe SDL_bool messageHook(IntPtr userdata, MSG* msg) + public override void Create() { - var handle = new ObjectHandle(userdata); - if (handle.GetTarget(out WindowsWindow window)) - return window.handleEventFromHook(*msg); + base.Create(); - return SDL_bool.SDL_TRUE; + // disable all pen and touch feedback as this causes issues when running "optimised" fullscreen under Direct3D11. + foreach (var feedbackType in Enum.GetValues()) + Native.Input.SetWindowFeedbackSetting(WindowHandle, feedbackType, false); + + // enable window message events to use with `OnSDLEvent` below. + SDL_EventState(SDL_EventType.SDL_SYSWMEVENT, SDL_ENABLE); } - private SDL_bool handleEventFromHook(MSG msg) + protected override void HandleEventFromFilter(SDL_Event e) { - switch (msg.message) + if (e.type == SDL_EventType.SDL_SYSWMEVENT) { - case Imm.WM_IME_STARTCOMPOSITION: - case Imm.WM_IME_COMPOSITION: - case Imm.WM_IME_ENDCOMPOSITION: - handleImeMessage(msg.hwnd, msg.message, msg.lParam); - break; - } + var wmMsg = Marshal.PtrToStructure(e.syswm.msg); + var m = wmMsg.msg.win; - return SDL_bool.SDL_TRUE; - } + switch (m.msg) + { + case wm_killfocus: + warpCursorFromFocusLoss(); + break; - protected override void HandleEventFromFilter(SDL_Event evt) - { - switch (evt.Type) - { - case SDL_EventType.SDL_EVENT_WINDOW_FOCUS_LOST: - warpCursorFromFocusLoss(); - break; + case Imm.WM_IME_STARTCOMPOSITION: + case Imm.WM_IME_COMPOSITION: + case Imm.WM_IME_ENDCOMPOSITION: + handleImeMessage(m.hwnd, m.msg, m.lParam); + break; + } } - base.HandleEventFromFilter(evt); + base.HandleEventFromFilter(e); } - /// - /// The last mouse position as reported by . - /// - internal Vector2? LastMousePosition { private get; set; } + public Vector2? LastMousePosition { get; set; } /// /// If required, warps the OS cursor to match the framework cursor position. @@ -114,7 +122,7 @@ protected override void HandleEventFromFilter(SDL_Event evt) /// /// The normal warp in doesn't work in fullscreen, /// as it is called when the window has already lost focus and is minimized. - /// So we do an out-of-band warp, immediately after receiving the message. + /// So we do an out-of-band warp, immediately after receiving the message. /// private void warpCursorFromFocusLoss() { @@ -123,7 +131,7 @@ private void warpCursorFromFocusLoss() && RelativeMouseMode) { var pt = PointToScreen(new Point((int)LastMousePosition.Value.X, (int)LastMousePosition.Value.Y)); - SDL3.SDL_WarpMouseGlobal(pt.X, pt.Y); // this directly calls the SetCursorPos win32 API + SDL_WarpMouseGlobal(pt.X, pt.Y); // this directly calls the SetCursorPos win32 API } } @@ -137,10 +145,10 @@ public override void StartTextInput(bool allowIme) public override void ResetIme() => ScheduleCommand(() => Imm.CancelComposition(WindowHandle)); - protected override void HandleTextInputEvent(SDL_TextInputEvent evtText) + protected override unsafe void HandleTextInputEvent(SDL_TextInputEvent evtText) { - string? sdlResult = evtText.GetText(); - Debug.Assert(sdlResult != null); + if (!SDL2Extensions.TryGetStringFromBytePointer(evtText.text, out string sdlResult)) + return; // Block SDL text input if it was already handled by `handleImeMessage()`. // SDL truncates text over 32 bytes and sends it as multiple events. @@ -215,7 +223,7 @@ private void handleImeMessage(IntPtr hWnd, uint uMsg, long lParam) protected override void HandleTouchFingerEvent(SDL_TouchFingerEvent evtTfinger) { - if (evtTfinger.TryGetTouchName(out string? name) && name == "pen") + if (evtTfinger.TryGetTouchName(out string name) && name == "pen") { // Windows Ink tablet/pen handling // InputManager expects to receive this as mouse events, to have proper `mouseSource` input priority (see InputManager.GetPendingInputs) @@ -225,11 +233,11 @@ protected override void HandleTouchFingerEvent(SDL_TouchFingerEvent evtTfinger) switch (evtTfinger.type) { - case SDL_EventType.SDL_EVENT_FINGER_DOWN: + case SDL_EventType.SDL_FINGERDOWN: TriggerMouseDown(MouseButton.Left); break; - case SDL_EventType.SDL_EVENT_FINGER_UP: + case SDL_EventType.SDL_FINGERUP: TriggerMouseUp(MouseButton.Left); break; } @@ -259,9 +267,9 @@ protected set /// Used on and . private const int windows_borderless_width_hack = 1; - protected override unsafe Size SetBorderless(Display display) + protected override Size SetBorderless(Display display) { - SDL3.SDL_SetWindowBordered(SDLWindowHandle, SDL_bool.SDL_FALSE); + SDL_SetWindowBordered(SDLWindowHandle, SDL_bool.SDL_FALSE); var newSize = display.Bounds.Size; @@ -270,7 +278,7 @@ protected override unsafe Size SetBorderless(Display display) // we also trick the game into thinking the window has normal size: see Size setter override newSize += new Size(windows_borderless_width_hack, 0); - SDL3.SDL_SetWindowSize(SDLWindowHandle, newSize.Width, newSize.Height); + SDL_SetWindowSize(SDLWindowHandle, newSize.Width, newSize.Height); Position = display.Bounds.Location; return newSize; @@ -310,6 +318,30 @@ public override Point PointToScreen(Point point) return point; } + [DllImport("SHCore.dll", SetLastError = true)] + internal static extern bool SetProcessDpiAwareness(ProcessDpiAwareness awareness); + + internal enum ProcessDpiAwareness + { + Process_DPI_Unaware = 0, + Process_System_DPI_Aware = 1, + Process_Per_Monitor_DPI_Aware = 2 + } + + [DllImport("User32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT value); + + // ReSharper disable once InconsistentNaming + internal enum DPI_AWARENESS_CONTEXT + { + DPI_AWARENESS_CONTEXT_UNAWARE = -1, + DPI_AWARENESS_CONTEXT_SYSTEM_AWARE = -2, + DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE = -3, + DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4, + DPI_AWARENESS_CONTEXT_UNAWARE_GDISCALED = -5, + } + [DllImport("user32.dll", SetLastError = true)] internal static extern bool ScreenToClient(IntPtr hWnd, ref Point point); diff --git a/osu.Framework/Platform/Windows/SDL3WindowsWindow.cs b/osu.Framework/Platform/Windows/SDL3WindowsWindow.cs new file mode 100644 index 0000000000..d04ea28ba1 --- /dev/null +++ b/osu.Framework/Platform/Windows/SDL3WindowsWindow.cs @@ -0,0 +1,209 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Drawing; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using osu.Framework.Input.Handlers.Mouse; +using osu.Framework.Platform.SDL3; +using osu.Framework.Platform.Windows.Native; +using osuTK; +using osuTK.Input; +using SDL; +using Icon = osu.Framework.Platform.Windows.Native.Icon; +using static SDL.SDL3; + +namespace osu.Framework.Platform.Windows +{ + [SupportedOSPlatform("windows")] + internal class SDL3WindowsWindow : SDL3DesktopWindow, IWindowsWindow + { + private const int seticon_message = 0x0080; + private const int icon_big = 1; + private const int icon_small = 0; + + private const int large_icon_size = 256; + private const int small_icon_size = 16; + + private Icon? smallIcon; + private Icon? largeIcon; + + /// + /// Whether to apply the . + /// + private readonly bool applyBorderlessWindowHack; + + public SDL3WindowsWindow(GraphicsSurfaceType surfaceType, string appName) + : base(surfaceType, appName) + { + switch (surfaceType) + { + case GraphicsSurfaceType.OpenGL: + case GraphicsSurfaceType.Vulkan: + applyBorderlessWindowHack = true; + break; + + case GraphicsSurfaceType.Direct3D11: + applyBorderlessWindowHack = false; + break; + } + } + + public override void Create() + { + base.Create(); + + // disable all pen and touch feedback as this causes issues when running "optimised" fullscreen under Direct3D11. + foreach (var feedbackType in Enum.GetValues()) + Native.Input.SetWindowFeedbackSetting(WindowHandle, feedbackType, false); + } + + protected override void HandleEventFromFilter(SDL_Event evt) + { + switch (evt.Type) + { + case SDL_EventType.SDL_EVENT_WINDOW_FOCUS_LOST: + warpCursorFromFocusLoss(); + break; + } + + base.HandleEventFromFilter(evt); + } + + public Vector2? LastMousePosition { get; set; } + + /// + /// If required, warps the OS cursor to match the framework cursor position. + /// + /// + /// The normal warp in doesn't work in fullscreen, + /// as it is called when the window has already lost focus and is minimized. + /// So we do an out-of-band warp, immediately after receiving the message. + /// + private void warpCursorFromFocusLoss() + { + if (LastMousePosition.HasValue + && WindowMode.Value == Configuration.WindowMode.Fullscreen + && RelativeMouseMode) + { + var pt = PointToScreen(new Point((int)LastMousePosition.Value.X, (int)LastMousePosition.Value.Y)); + SDL_WarpMouseGlobal(pt.X, pt.Y); // this directly calls the SetCursorPos win32 API + } + } + + public override void StartTextInput(bool allowIme) + { + base.StartTextInput(allowIme); + ScheduleCommand(() => Imm.SetImeAllowed(WindowHandle, allowIme)); + } + + public override void ResetIme() => ScheduleCommand(() => Imm.CancelComposition(WindowHandle)); + + protected override void HandleTouchFingerEvent(SDL_TouchFingerEvent evtTfinger) + { + if (evtTfinger.TryGetTouchName(out string? name) && name == "pen") + { + // Windows Ink tablet/pen handling + // InputManager expects to receive this as mouse events, to have proper `mouseSource` input priority (see InputManager.GetPendingInputs) + // osu! expects to get tablet events as mouse events, and touch events as touch events for touch device (TD mod) handling (see https://github.com/ppy/osu/issues/25590) + + TriggerMouseMove(evtTfinger.x * ClientSize.Width, evtTfinger.y * ClientSize.Height); + + switch (evtTfinger.type) + { + case SDL_EventType.SDL_EVENT_FINGER_DOWN: + TriggerMouseDown(MouseButton.Left); + break; + + case SDL_EventType.SDL_EVENT_FINGER_UP: + TriggerMouseUp(MouseButton.Left); + break; + } + + return; + } + + base.HandleTouchFingerEvent(evtTfinger); + } + + public override Size Size + { + protected set + { + // trick the game into thinking the borderless window has normal size so that it doesn't render into the extra space. + if (applyBorderlessWindowHack && WindowState == WindowState.FullscreenBorderless) + value.Width -= windows_borderless_width_hack; + + base.Size = value; + } + } + + /// + /// Amount of extra width added to window size when in borderless mode on Windows. + /// Some drivers require this to avoid the window switching to exclusive fullscreen automatically. + /// + /// Used on and . + private const int windows_borderless_width_hack = 1; + + protected override unsafe Size SetBorderless(Display display) + { + SDL_SetWindowBordered(SDLWindowHandle, false); + + var newSize = display.Bounds.Size; + + if (applyBorderlessWindowHack) + // use the 1px hack we've always used, but only expand the width. + // we also trick the game into thinking the window has normal size: see Size setter override + newSize += new Size(windows_borderless_width_hack, 0); + + SDL_SetWindowSize(SDLWindowHandle, newSize.Width, newSize.Height); + Position = display.Bounds.Location; + + return newSize; + } + + /// + /// On Windows, SDL will use the same image for both large and small icons (scaled as necessary). + /// This can look bad if scaling down a large image, so we use the Windows API directly so as + /// to get a cleaner icon set than SDL can provide. + /// If called before the window has been created, or we do not find two separate icon sizes, we fall back to the base method. + /// + internal override void SetIconFromGroup(IconGroup iconGroup) + { + smallIcon = iconGroup.CreateIcon(small_icon_size, small_icon_size); + largeIcon = iconGroup.CreateIcon(large_icon_size, large_icon_size); + + IntPtr windowHandle = WindowHandle; + + if (windowHandle == IntPtr.Zero || largeIcon == null || smallIcon == null) + base.SetIconFromGroup(iconGroup); + else + { + SendMessage(windowHandle, seticon_message, icon_small, smallIcon.Handle); + SendMessage(windowHandle, seticon_message, icon_big, largeIcon.Handle); + } + } + + public override Point PointToClient(Point point) + { + ScreenToClient(WindowHandle, ref point); + return point; + } + + public override Point PointToScreen(Point point) + { + ClientToScreen(WindowHandle, ref point); + return point; + } + + [DllImport("user32.dll", SetLastError = true)] + internal static extern bool ScreenToClient(IntPtr hWnd, ref Point point); + + [DllImport("user32.dll", SetLastError = true)] + internal static extern bool ClientToScreen(IntPtr hWnd, ref Point point); + + [DllImport("user32.dll", CharSet = CharSet.Auto)] + private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); + } +} diff --git a/osu.Framework/Platform/Windows/WindowsGLRenderer.cs b/osu.Framework/Platform/Windows/WindowsGLRenderer.cs index d52f2f67a5..aee3e5c410 100644 --- a/osu.Framework/Platform/Windows/WindowsGLRenderer.cs +++ b/osu.Framework/Platform/Windows/WindowsGLRenderer.cs @@ -28,7 +28,7 @@ protected override void Initialise(IGraphicsSurface graphicsSurface) { base.Initialise(graphicsSurface); - WindowsWindow windowsWindow = (WindowsWindow)host.Window; + ISDLWindow windowsWindow = (ISDLWindow)host.Window; bool isIntel = GL.GetString(StringName.Vendor).Trim() == "Intel"; diff --git a/osu.Framework/Platform/Windows/WindowsGameHost.cs b/osu.Framework/Platform/Windows/WindowsGameHost.cs index ae1db05b3f..f27fd1c4d7 100644 --- a/osu.Framework/Platform/Windows/WindowsGameHost.cs +++ b/osu.Framework/Platform/Windows/WindowsGameHost.cs @@ -86,7 +86,10 @@ protected override void SetupForRun() timePeriod = new TimePeriod(1); } - protected override IWindow CreateWindow(GraphicsSurfaceType preferredSurface) => new WindowsWindow(preferredSurface); + protected override IWindow CreateWindow(GraphicsSurfaceType preferredSurface) + => FrameworkEnvironment.UseSDL3 + ? new SDL3WindowsWindow(preferredSurface, Options.FriendlyGameName) + : new SDL2WindowsWindow(preferredSurface, Options.FriendlyGameName); public override IEnumerable PlatformKeyBindings => base.PlatformKeyBindings.Concat(new[] { diff --git a/osu.Framework/Platform/Windows/WindowsMouseHandler.cs b/osu.Framework/Platform/Windows/WindowsMouseHandler.cs index b74535b787..4abe150a6e 100644 --- a/osu.Framework/Platform/Windows/WindowsMouseHandler.cs +++ b/osu.Framework/Platform/Windows/WindowsMouseHandler.cs @@ -1,4 +1,4 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System.Runtime.Versioning; @@ -12,16 +12,22 @@ namespace osu.Framework.Platform.Windows /// This is done to better handle quirks of some devices. /// [SupportedOSPlatform("windows")] - internal class WindowsMouseHandler : MouseHandler + internal partial class WindowsMouseHandler : MouseHandler { - private WindowsWindow window = null!; + private IWindowsWindow window = null!; + + public override bool IsActive => Enabled.Value; public override bool Initialize(GameHost host) { - if (!(host.Window is WindowsWindow desktopWindow)) + if (host.Window is not IWindowsWindow windowsWindow) return false; - window = desktopWindow; + window = windowsWindow; + + if (window is SDL2WindowsWindow) + initialiseSDL2(host); + return base.Initialize(host); } diff --git a/osu.Framework/Platform/Windows/WindowsMouseHandler_SDL2.cs b/osu.Framework/Platform/Windows/WindowsMouseHandler_SDL2.cs new file mode 100644 index 0000000000..2d941ec173 --- /dev/null +++ b/osu.Framework/Platform/Windows/WindowsMouseHandler_SDL2.cs @@ -0,0 +1,142 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Drawing; +using osu.Framework.Extensions.EnumExtensions; +using osu.Framework.Input.StateChanges; +using osu.Framework.Platform.Windows.Native; +using osu.Framework.Statistics; +using osuTK; +using static SDL2.SDL; + +namespace osu.Framework.Platform.Windows +{ + internal partial class WindowsMouseHandler + { + private static readonly GlobalStatistic statistic_relative_events = GlobalStatistics.Get(StatisticGroupFor(), "Relative events"); + private static readonly GlobalStatistic statistic_absolute_events = GlobalStatistics.Get(StatisticGroupFor(), "Absolute events"); + private static readonly GlobalStatistic statistic_dropped_touch_inputs = GlobalStatistics.Get(StatisticGroupFor(), "Dropped native touch inputs"); + + private static readonly GlobalStatistic statistic_inputs_with_extra_information = + GlobalStatistics.Get(StatisticGroupFor(), "Native inputs with ExtraInformation"); + + private const int raw_input_coordinate_space = 65535; + + private SDL_WindowsMessageHook sdl2Callback = null!; + + private void initialiseSDL2(GameHost host) + { + // ReSharper disable once ConvertClosureToMethodGroup + sdl2Callback = (ptr, wnd, u, param, l) => onWndProcSDL2(ptr, wnd, u, param, l); + + Enabled.BindValueChanged(enabled => + { + host.InputThread.Scheduler.Add(() => SDL_SetWindowsMessageHook(enabled.NewValue ? sdl2Callback : null, IntPtr.Zero)); + }, true); + } + + protected override void HandleMouseMoveRelative(Vector2 delta) + { + if (window is SDL2WindowsWindow) + { + // on SDL2, relative movement is reported via the WndProc handler below. + return; + } + + base.HandleMouseMoveRelative(delta); + } + + private unsafe IntPtr onWndProcSDL2(IntPtr userData, IntPtr hWnd, uint message, ulong wParam, long lParam) + { + if (!Enabled.Value) + return IntPtr.Zero; + + if (message != Native.Input.WM_INPUT) + return IntPtr.Zero; + + if (Native.Input.IsTouchEvent(Native.Input.GetMessageExtraInfo())) + { + // sometimes GetMessageExtraInfo returns 0, so additionally, mouse.ExtraInformation is checked below. + // touch events are handled by TouchHandler + statistic_dropped_touch_inputs.Value++; + return IntPtr.Zero; + } + + int payloadSize = sizeof(RawInputData); + + Native.Input.GetRawInputData((IntPtr)lParam, RawInputCommand.Input, out var data, ref payloadSize, sizeof(RawInputHeader)); + + if (data.Header.Type != RawInputType.Mouse) + return IntPtr.Zero; + + var mouse = data.Mouse; + + // `ExtraInformation` doesn't have the MI_WP_SIGNATURE set, so we have to rely solely on the touch flag. + if (Native.Input.HasTouchFlag(mouse.ExtraInformation)) + { + statistic_dropped_touch_inputs.Value++; + return IntPtr.Zero; + } + + //TODO: this isn't correct. + if (mouse.ExtraInformation > 0) + { + statistic_inputs_with_extra_information.Value++; + + // i'm not sure if there is a valid case where we need to handle packets with this present + // but the osu!tablet fires noise events with non-zero values, which we want to ignore. + // return IntPtr.Zero; + } + + var position = new Vector2(mouse.LastX, mouse.LastY); + float sensitivity = (float)Sensitivity.Value; + + if (mouse.Flags.HasFlagFast(RawMouseFlags.MoveAbsolute)) + { + var screenRect = mouse.Flags.HasFlagFast(RawMouseFlags.VirtualDesktop) ? Native.Input.VirtualScreenRect : new Rectangle(window.Position, window.ClientSize); + + Vector2 screenSize = new Vector2(screenRect.Width, screenRect.Height); + + if (mouse.LastX == 0 && mouse.LastY == 0) + { + // not sure if this is the case for all tablets, but on osu!tablet these can appear and are noise. + return IntPtr.Zero; + } + + // i am not sure what this 64 flag is, but it's set on the osu!tablet at very least. + // using it here as a method of determining where the coordinate space is incorrect. + if (((int)mouse.Flags & 64) == 0) + { + position /= raw_input_coordinate_space; + position *= screenSize; + } + + if (Sensitivity.Value != 1) + { + // apply absolute sensitivity adjustment from the centre of the screen area. + Vector2 halfScreenSize = (screenSize / 2); + + position -= halfScreenSize; + position *= (float)Sensitivity.Value; + position += halfScreenSize; + } + + // map from screen to client coordinate space. + // not using Window's PointToClient implementation to keep floating point precision here. + position -= new Vector2(window.Position.X, window.Position.Y); + position *= window.Scale; + + PendingInputs.Enqueue(new MousePositionAbsoluteInput { Position = position }); + statistic_absolute_events.Value++; + } + else + { + PendingInputs.Enqueue(new MousePositionRelativeInput { Delta = new Vector2(mouse.LastX, mouse.LastY) * sensitivity }); + statistic_relative_events.Value++; + } + + return IntPtr.Zero; + } + } +} diff --git a/osu.Framework/Platform/Windows/WindowsReadableKeyCombinationProvider.cs b/osu.Framework/Platform/Windows/WindowsReadableKeyCombinationProvider.cs index fffe665478..b8e58dca9a 100644 --- a/osu.Framework/Platform/Windows/WindowsReadableKeyCombinationProvider.cs +++ b/osu.Framework/Platform/Windows/WindowsReadableKeyCombinationProvider.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Input.Bindings; -using osu.Framework.Platform.SDL; +using osu.Framework.Platform.SDL3; using SDL; namespace osu.Framework.Platform.Windows diff --git a/osu.Framework/Resources/Shaders/sh_FastCircle.fs b/osu.Framework/Resources/Shaders/sh_FastCircle.fs new file mode 100644 index 0000000000..307f00a74c --- /dev/null +++ b/osu.Framework/Resources/Shaders/sh_FastCircle.fs @@ -0,0 +1,26 @@ +#ifndef FAST_CIRCLE_FS +#define FAST_CIRCLE_FS + +#undef HIGH_PRECISION_VERTEX +#define HIGH_PRECISION_VERTEX + +#include "sh_Utils.h" +#include "sh_Masking.h" + +layout(location = 2) in highp vec2 v_TexCoord; + +layout(location = 0) out vec4 o_Colour; + +void main(void) +{ + highp vec2 pixelPos = v_TexRect.zw * 0.5 - abs(v_TexCoord - v_TexRect.zw * 0.5); + highp float radius = min(v_TexRect.z, v_TexRect.w) * 0.5; + + highp float dst = max(pixelPos.x, pixelPos.y) > radius ? radius - min(pixelPos.x, pixelPos.y) : distance(pixelPos, vec2(radius)); + + highp float alpha = v_BlendRange.x == 0.0 ? float(dst < radius) : (clamp(radius - dst, 0.0, v_BlendRange.x) / v_BlendRange.x); + + o_Colour = getRoundedColor(vec4(vec3(1.0), alpha), vec2(0.0)); +} + +#endif diff --git a/osu.Framework/Testing/Drawables/Steps/AssertButton.cs b/osu.Framework/Testing/Drawables/Steps/AssertButton.cs index e75e05fb03..d910acb964 100644 --- a/osu.Framework/Testing/Drawables/Steps/AssertButton.cs +++ b/osu.Framework/Testing/Drawables/Steps/AssertButton.cs @@ -1,10 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Diagnostics; +using System.Runtime.ExceptionServices; using System.Text; using NUnit.Framework; using osuTK.Graphics; @@ -13,15 +12,14 @@ namespace osu.Framework.Testing.Drawables.Steps { public partial class AssertButton : StepButton { - public Func Assertion; - public string ExtendedDescription; - public StackTrace CallStack; - private readonly Func getFailureMessage; + public required StackTrace CallStack { get; init; } + public required Func Assertion { get; init; } + public Func? GetFailureMessage { get; init; } + + public string? ExtendedDescription { get; init; } - public AssertButton(bool isSetupStep = false, Func getFailureMessage = null) - : base(isSetupStep) + public AssertButton() { - this.getFailureMessage = getFailureMessage; Action += checkAssert; LightColour = Color4.OrangeRed; } @@ -39,26 +37,13 @@ private void checkAssert() if (!string.IsNullOrEmpty(ExtendedDescription)) builder.Append($" {ExtendedDescription}"); - if (getFailureMessage != null) - builder.Append($": {getFailureMessage()}"); + if (GetFailureMessage != null) + builder.Append($": {GetFailureMessage()}"); - throw new TracedException(builder.ToString(), CallStack); + throw ExceptionDispatchInfo.SetRemoteStackTrace(new AssertionException(builder.ToString()), CallStack.ToString()); } } public override string ToString() => "Assert: " + base.ToString(); - - private class TracedException : AssertionException - { - private readonly StackTrace trace; - - public TracedException(string description, StackTrace trace) - : base(description) - { - this.trace = trace; - } - - public override string StackTrace => trace.ToString(); - } } } diff --git a/osu.Framework/Testing/Drawables/Steps/LabelStep.cs b/osu.Framework/Testing/Drawables/Steps/LabelStep.cs index c6fcd61616..5f1271a63a 100644 --- a/osu.Framework/Testing/Drawables/Steps/LabelStep.cs +++ b/osu.Framework/Testing/Drawables/Steps/LabelStep.cs @@ -1,12 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osuTK.Graphics; namespace osu.Framework.Testing.Drawables.Steps { public partial class LabelStep : StepButton { + public new required Action Action { get; init; } + protected override Color4 IdleColour => new Color4(77, 77, 77, 255); protected override Color4 RunningColour => new Color4(128, 128, 128, 255); @@ -15,6 +18,9 @@ public LabelStep() { Light.Hide(); Height = 30; + base.Action = clickAction; } + + private void clickAction() => Action(this); } } diff --git a/osu.Framework/Testing/Drawables/Steps/RepeatStepButton.cs b/osu.Framework/Testing/Drawables/Steps/RepeatStepButton.cs index 5a386b60af..20e7aa4e1f 100644 --- a/osu.Framework/Testing/Drawables/Steps/RepeatStepButton.cs +++ b/osu.Framework/Testing/Drawables/Steps/RepeatStepButton.cs @@ -1,45 +1,39 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; namespace osu.Framework.Testing.Drawables.Steps { public partial class RepeatStepButton : StepButton { - private readonly int count; - private int invocations; + public int Count { get; init; } = 1; - public override int RequiredRepetitions => count; + public override int RequiredRepetitions => Count; - private string text; + private readonly string text = string.Empty; + private int invocations; - public new string Text + public RepeatStepButton() { - get => text; - set => base.Text = text = value; + updateText(); } - public RepeatStepButton(Action action, int count = 1, bool isSetupStep = false) - : base(isSetupStep) + public new string Text { - this.count = count; - Action = action; - - updateText(); + get => text; + init => base.Text = text = value; } public override void PerformStep(bool userTriggered = false) { - if (invocations == count && !userTriggered) throw new InvalidOperationException("Repeat step was invoked too many times"); + if (invocations == Count && !userTriggered) throw new InvalidOperationException("Repeat step was invoked too many times"); invocations++; base.PerformStep(userTriggered); - if (invocations >= count) // Allows for manual execution beyond the invocation limit. + if (invocations >= Count) // Allows for manual execution beyond the invocation limit. Success(); updateText(); @@ -53,7 +47,7 @@ public override void Reset() updateText(); } - private void updateText() => base.Text = $@"{Text} {invocations}/{count}"; + private void updateText() => base.Text = $@"{Text} {invocations}/{Count}"; public override string ToString() => "Repeat: " + base.ToString(); } diff --git a/osu.Framework/Testing/Drawables/Steps/SingleStepButton.cs b/osu.Framework/Testing/Drawables/Steps/SingleStepButton.cs index bc8806c971..203621e217 100644 --- a/osu.Framework/Testing/Drawables/Steps/SingleStepButton.cs +++ b/osu.Framework/Testing/Drawables/Steps/SingleStepButton.cs @@ -1,24 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; namespace osu.Framework.Testing.Drawables.Steps { public partial class SingleStepButton : StepButton { - public new Action Action; + public new required Action Action { get; init; } + + public SingleStepButton() + { + base.Action = clickAction; + } - public SingleStepButton(bool isSetupStep = false) - : base(isSetupStep) + private void clickAction() { - base.Action = () => - { - Action?.Invoke(); - Success(); - }; + Action(); + Success(); } } } diff --git a/osu.Framework/Testing/Drawables/Steps/StepButton.cs b/osu.Framework/Testing/Drawables/Steps/StepButton.cs index d85b9180ef..23d90f8a2a 100644 --- a/osu.Framework/Testing/Drawables/Steps/StepButton.cs +++ b/osu.Framework/Testing/Drawables/Steps/StepButton.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -16,42 +14,20 @@ namespace osu.Framework.Testing.Drawables.Steps { public abstract partial class StepButton : CompositeDrawable { - public virtual int RequiredRepetitions => 1; - - protected Box Light; - protected Box Background; - protected SpriteText SpriteText; - - public Action Action { get; set; } - - public LocalisableString Text - { - get => SpriteText.Text; - set => SpriteText.Text = value; - } - - private Color4 lightColour = Color4.BlueViolet; - - public Color4 LightColour - { - get => lightColour; - set - { - lightColour = value; - if (IsLoaded) Reset(); - } - } + public required bool IsSetupStep { get; init; } + public Action? Action { get; set; } - public readonly bool IsSetupStep; + public virtual int RequiredRepetitions => 1; protected virtual Color4 IdleColour => new Color4(0.15f, 0.15f, 0.15f, 1); - protected virtual Color4 RunningColour => new Color4(0.5f, 0.5f, 0.5f, 1); - protected StepButton(bool isSetupStep = false) - { - IsSetupStep = isSetupStep; + protected readonly Box Light; + protected readonly Box Background; + protected readonly SpriteText SpriteText; + protected StepButton() + { InternalChildren = new Drawable[] { Background = new Box @@ -85,6 +61,24 @@ protected StepButton(bool isSetupStep = false) Masking = true; } + public LocalisableString Text + { + get => SpriteText.Text; + set => SpriteText.Text = value; + } + + private Color4 lightColour = Color4.BlueViolet; + + public Color4 LightColour + { + get => lightColour; + set + { + lightColour = value; + if (IsLoaded) Reset(); + } + } + protected override void LoadComplete() { base.LoadComplete(); diff --git a/osu.Framework/Testing/Drawables/Steps/ToggleStepButton.cs b/osu.Framework/Testing/Drawables/Steps/ToggleStepButton.cs index 5d2b7d8857..41341b6f92 100644 --- a/osu.Framework/Testing/Drawables/Steps/ToggleStepButton.cs +++ b/osu.Framework/Testing/Drawables/Steps/ToggleStepButton.cs @@ -9,31 +9,31 @@ namespace osu.Framework.Testing.Drawables.Steps { public partial class ToggleStepButton : StepButton { - private readonly Action? reloadCallback; private static readonly Color4 off_colour = Color4.Red; private static readonly Color4 on_colour = Color4.YellowGreen; - public bool State; + public new required Action Action { get; init; } public override int RequiredRepetitions => 2; - public ToggleStepButton(Action? reloadCallback) + private bool state; + + public ToggleStepButton() { - this.reloadCallback = reloadCallback; - Action = clickAction; + base.Action = clickAction; LightColour = off_colour; } private void clickAction() { - State = !State; - Light.FadeColour(State ? on_colour : off_colour); - reloadCallback?.Invoke(State); + state = !state; + Light.FadeColour(state ? on_colour : off_colour); + Action(state); - if (!State) + if (!state) Success(); } - public override string ToString() => $"Toggle: {base.ToString()} ({(State ? "on" : "off")})"; + public override string ToString() => $"Toggle: {base.ToString()} ({(state ? "on" : "off")})"; } } diff --git a/osu.Framework/Testing/Drawables/Steps/UntilStepButton.cs b/osu.Framework/Testing/Drawables/Steps/UntilStepButton.cs index 54656d8953..89447a9512 100644 --- a/osu.Framework/Testing/Drawables/Steps/UntilStepButton.cs +++ b/osu.Framework/Testing/Drawables/Steps/UntilStepButton.cs @@ -1,10 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Diagnostics; +using System.Runtime.ExceptionServices; using System.Text; using NUnit.Framework; using osu.Framework.Graphics; @@ -14,60 +13,59 @@ namespace osu.Framework.Testing.Drawables.Steps { public partial class UntilStepButton : StepButton { - private bool success; - - private int invocations; - private static readonly int max_attempt_milliseconds = FrameworkEnvironment.NoTestTimeout ? int.MaxValue : 10000; + public required StackTrace CallStack { get; init; } + public required Func Assertion { get; init; } + public Func? GetFailureMessage { get; init; } + public new Action? Action { get; set; } + public override int RequiredRepetitions => success ? 0 : int.MaxValue; - public new Action Action; + private readonly string text = string.Empty; + private bool success; + private int invocations; + private Stopwatch? elapsedTime; - private string text; + public UntilStepButton() + { + updateText(); + LightColour = Color4.Sienna; + base.Action = checkAssert; + } public new string Text { get => text; - set => base.Text = text = value; + init => base.Text = text = value; } - private Stopwatch elapsedTime; - - public UntilStepButton(Func waitUntilTrueDelegate, bool isSetupStep = false, Func getFailureMessage = null) - : base(isSetupStep) + private void checkAssert() { + invocations++; + elapsedTime ??= Stopwatch.StartNew(); + updateText(); - LightColour = Color4.Sienna; - base.Action = () => + if (Assertion()) { - invocations++; - - elapsedTime ??= Stopwatch.StartNew(); - - updateText(); - - if (waitUntilTrueDelegate()) - { - elapsedTime = null; - success = true; - Success(); - } - else if (!Debugger.IsAttached && elapsedTime.ElapsedMilliseconds >= max_attempt_milliseconds) - { - StringBuilder builder = new StringBuilder(); + elapsedTime = null; + success = true; + Success(); + } + else if (!Debugger.IsAttached && elapsedTime.ElapsedMilliseconds >= max_attempt_milliseconds) + { + StringBuilder builder = new StringBuilder(); - builder.Append($"\"{Text}\" timed out"); + builder.Append($"\"{Text}\" timed out"); - if (getFailureMessage != null) - builder.Append($": {getFailureMessage()}"); + if (GetFailureMessage != null) + builder.Append($": {GetFailureMessage()}"); - throw new AssertionException(builder.ToString()); - } + throw ExceptionDispatchInfo.SetRemoteStackTrace(new AssertionException(builder.ToString()), CallStack.ToString()); + } - Action?.Invoke(); - }; + Action?.Invoke(); } public override void Reset() diff --git a/osu.Framework/Testing/TestBrowser.cs b/osu.Framework/Testing/TestBrowser.cs index d0ecc46cf1..8f5b708471 100644 --- a/osu.Framework/Testing/TestBrowser.cs +++ b/osu.Framework/Testing/TestBrowser.cs @@ -324,7 +324,7 @@ public bool OnPressed(KeyBindingPressEvent e) { case TestBrowserAction.Search: if (leftContainer.Width == 0) toggleTestList(); - GetContainingInputManager().ChangeFocus(searchTextBox); + GetContainingFocusManager().AsNonNull().ChangeFocus(searchTextBox); return true; case TestBrowserAction.Reload: @@ -521,9 +521,10 @@ void addSetUpSteps() if (setUpMethods.Any()) { - CurrentTest.AddStep(new SingleStepButton(true) + CurrentTest.AddStep(new SingleStepButton { Text = "[SetUp]", + IsSetupStep = true, LightColour = Color4.Teal, Action = () => setUpMethods.ForEach(s => s.Invoke(CurrentTest, null)) }); @@ -589,7 +590,7 @@ private static IEnumerable getTestCaseSourceValue(MethodInfo testMethod, TestCas private void runTests(Action onCompletion) { int actualStepCount = 0; - CurrentTest.RunAllSteps(onCompletion, e => Logger.Log($@"Error on step: {e}"), s => + CurrentTest.RunAllSteps(onCompletion, (s, e) => Logger.Error(e, $"Step {s} triggered an error"), s => { if (!interactive || RunAllSteps.Value) return false; diff --git a/osu.Framework/Testing/TestScene.cs b/osu.Framework/Testing/TestScene.cs index c0dca8ec2f..545f59161e 100644 --- a/osu.Framework/Testing/TestScene.cs +++ b/osu.Framework/Testing/TestScene.cs @@ -204,7 +204,7 @@ protected TestScene() private ScheduledDelegate stepRunner; private readonly ScrollContainer scroll; - public void RunAllSteps(Action onCompletion = null, Action onError = null, Func stopCondition = null, StepButton startFromStep = null) + public void RunAllSteps(Action onCompletion = null, Action onError = null, Func stopCondition = null, StepButton startFromStep = null) { // schedule once as we want to ensure we have run our LoadComplete before attempting to execute steps. // a user may be adding a step in LoadComplete. @@ -222,7 +222,7 @@ public void RunAllSteps(Action onCompletion = null, Action onError = private StepButton loadableStep => actionIndex >= 0 ? StepsContainer.Children.ElementAtOrDefault(actionIndex) as StepButton : null; - private void runNextStep(Action onCompletion, Action onError, Func stopCondition) + private void runNextStep(Action onCompletion, Action onError, Func stopCondition) { try { @@ -242,7 +242,7 @@ private void runNextStep(Action onCompletion, Action onError, Func onError, Func runNextStep(onCompletion, onError, stopCondition), TimePerAction); } - public void AddStep(StepButton step) => schedule(() => StepsContainer.Add(step)); - - private bool addStepsAsSetupSteps; - public void ChangeBackgroundColour(ColourInfo colour) => backgroundFill.FadeColour(colour, 200, Easing.OutQuint); - public StepButton AddStep(string description, Action action) + private bool addStepsAsSetupSteps; + + public void AddStep(StepButton step) { - var step = new SingleStepButton(addStepsAsSetupSteps) + schedule(() => { - Text = description, - Action = action - }; - - AddStep(step); - - return step; + StepsContainer.Add(step); + }); } - public LabelStep AddLabel(string description) + public void AddStep([NotNull] string description, [NotNull] Action action) { - var step = new LabelStep + AddStep(new SingleStepButton { Text = description, - }; + Action = action, + IsSetupStep = addStepsAsSetupSteps + }); + } - step.Action = () => + public void AddLabel([NotNull] string description) + { + AddStep(new LabelStep { - Logger.Log($@"💨 {this} {description}"); - - // kinda hacky way to avoid this doesn't get triggered by automated runs. - if (step.IsHovered) - RunAllSteps(startFromStep: step, stopCondition: s => s is LabelStep); - }; - - AddStep(step); + Text = description, + IsSetupStep = false, + Action = step => + { + Logger.Log($@"💨 {this} {description}"); - return step; + // kinda hacky way to avoid this doesn't get triggered by automated runs. + if (step.IsHovered) + RunAllSteps(startFromStep: step, stopCondition: s => s is LabelStep, onError: (s, e) => Logger.Error(e, $"Step {s} triggered error")); + }, + }); } - protected void AddRepeatStep(string description, Action action, int invocationCount) => schedule(() => + protected void AddRepeatStep([NotNull] string description, [NotNull] Action action, int invocationCount) { - StepsContainer.Add(new RepeatStepButton(action, invocationCount, addStepsAsSetupSteps) + AddStep(new RepeatStepButton { Text = description, + IsSetupStep = addStepsAsSetupSteps, + Action = action, + Count = invocationCount }); - }); + } - protected void AddToggleStep(string description, Action action) => schedule(() => + protected void AddToggleStep([NotNull] string description, [NotNull] Action action) { - StepsContainer.Add(new ToggleStepButton(action) + AddStep(new ToggleStepButton { - Text = description + Text = description, + IsSetupStep = addStepsAsSetupSteps, + Action = action, }); - }); + } - protected void AddUntilStep(string description, Func waitUntilTrueDelegate) => schedule(() => + protected void AddUntilStep([CanBeNull] string description, [NotNull] Func waitUntilTrueDelegate) { - StepsContainer.Add(new UntilStepButton(waitUntilTrueDelegate, addStepsAsSetupSteps) + AddStep(new UntilStepButton { Text = description ?? @"Until", + IsSetupStep = addStepsAsSetupSteps, + CallStack = new StackTrace(1, true), + Assertion = waitUntilTrueDelegate, }); - }); + } - protected void AddUntilStep(string description, ActualValueDelegate actualValue, Func constraint) => schedule(() => + protected void AddUntilStep([CanBeNull] string description, [NotNull] ActualValueDelegate actualValue, [NotNull] Func constraint) { ConstraintResult lastResult = null; - StepsContainer.Add( - new UntilStepButton( - () => - { - lastResult = constraint().Resolve().ApplyTo(actualValue()); - return lastResult.IsSuccess; - }, - addStepsAsSetupSteps, - () => - { - var writer = new TextMessageWriter(string.Empty); - lastResult.WriteMessageTo(writer); - return writer.ToString().TrimStart(); - }) + AddStep(new UntilStepButton + { + Text = description ?? @"Until", + IsSetupStep = addStepsAsSetupSteps, + CallStack = new StackTrace(1, true), + Assertion = () => { - Text = description ?? @"Until", - }); - }); + lastResult = constraint().Resolve().ApplyTo(actualValue()); + return lastResult.IsSuccess; + }, + GetFailureMessage = () => + { + if (lastResult == null) + return string.Empty; + + var writer = new TextMessageWriter(string.Empty); + lastResult.WriteMessageTo(writer); + return writer.ToString().TrimStart(); + } + }); + } - protected void AddWaitStep(string description, int waitCount) => schedule(() => + protected void AddWaitStep([CanBeNull] string description, int waitCount) { - StepsContainer.Add(new RepeatStepButton(() => { }, waitCount, addStepsAsSetupSteps) + AddStep(new RepeatStepButton { Text = description ?? @"Wait", + IsSetupStep = addStepsAsSetupSteps, + Count = waitCount }); - }); + } - protected void AddSliderStep(string description, T min, T max, T start, Action valueChanged) where T : struct, INumber, IMinMaxValue => schedule(() => + protected void AddSliderStep([NotNull] string description, T min, T max, T start, [NotNull] Action valueChanged) where T : struct, INumber, IMinMaxValue { - StepsContainer.Add(new StepSlider(description, min, max, start) + schedule(() => { - ValueChanged = valueChanged, + StepsContainer.Add(new StepSlider(description, min, max, start) + { + ValueChanged = valueChanged, + }); }); - }); + } - protected void AddAssert(string description, Func assert, string extendedDescription = null) => schedule(() => + protected void AddAssert([NotNull] string description, [NotNull] Func assert, [CanBeNull] string extendedDescription = null) { - StepsContainer.Add(new AssertButton(addStepsAsSetupSteps) + AddStep(new AssertButton { Text = description, + IsSetupStep = addStepsAsSetupSteps, ExtendedDescription = extendedDescription, - CallStack = new StackTrace(1), + CallStack = new StackTrace(1, true), Assertion = assert, }); - }); + } - protected void AddAssert(string description, ActualValueDelegate actualValue, Func constraint, string extendedDescription = null) => schedule(() => + protected void AddAssert([NotNull] string description, [NotNull] ActualValueDelegate actualValue, [NotNull] Func constraint, + [CanBeNull] string extendedDescription = null) { ConstraintResult lastResult = null; - StepsContainer.Add(new AssertButton(addStepsAsSetupSteps, () => - { - if (lastResult == null) - return string.Empty; - - var writer = new TextMessageWriter(string.Empty); - lastResult.WriteMessageTo(writer); - return writer.ToString().TrimStart(); - }) + AddStep(new AssertButton { Text = description, + IsSetupStep = addStepsAsSetupSteps, ExtendedDescription = extendedDescription, - CallStack = new StackTrace(1), + CallStack = new StackTrace(1, true), Assertion = () => { lastResult = constraint().Resolve().ApplyTo(actualValue()); return lastResult.IsSuccess; + }, + GetFailureMessage = () => + { + if (lastResult == null) + return string.Empty; + + var writer = new TextMessageWriter(string.Empty); + lastResult.WriteMessageTo(writer); + return writer.ToString().TrimStart(); } }); - }); + } internal void RunSetUpSteps() { diff --git a/osu.Framework/Testing/TestSceneTestRunner.cs b/osu.Framework/Testing/TestSceneTestRunner.cs index f4e6bf53fc..32f05b7076 100644 --- a/osu.Framework/Testing/TestSceneTestRunner.cs +++ b/osu.Framework/Testing/TestSceneTestRunner.cs @@ -76,7 +76,7 @@ void complete() test.RunAllSteps(() => { Scheduler.AddDelayed(complete, time_between_tests); - }, e => + }, (_, e) => { exception = ExceptionDispatchInfo.Capture(e); complete(); diff --git a/osu.Framework/Threading/GameThread.cs b/osu.Framework/Threading/GameThread.cs index 644221e676..08e111e4af 100644 --- a/osu.Framework/Threading/GameThread.cs +++ b/osu.Framework/Threading/GameThread.cs @@ -142,6 +142,11 @@ public double InactiveHz internal virtual IEnumerable StatisticsCounters => Array.Empty(); + /// + /// The amount of times this thread has run. + /// + internal ulong FrameIndex { get; private set; } + /// /// The main work which is fired on each frame. /// @@ -439,6 +444,8 @@ void runWork() try { + FrameIndex++; + Monitor?.NewFrame(); using (Monitor?.BeginCollecting(PerformanceCollectionType.Scheduler)) diff --git a/osu.Framework/Timing/DecouplingFramedClock.cs b/osu.Framework/Timing/DecouplingFramedClock.cs index 9a96cc4b49..e8b000680c 100644 --- a/osu.Framework/Timing/DecouplingFramedClock.cs +++ b/osu.Framework/Timing/DecouplingFramedClock.cs @@ -155,6 +155,7 @@ public void ChangeSource(IClock? source) adjustableSourceClock = adjustableSource; currentTime = adjustableSource.CurrentTime; shouldBeRunning = adjustableSource.IsRunning; + lastSeekFailed = false; } #endregion diff --git a/osu.Framework/osu.Framework.csproj b/osu.Framework/osu.Framework.csproj index 56274f979b..c4814789f0 100644 --- a/osu.Framework/osu.Framework.csproj +++ b/osu.Framework/osu.Framework.csproj @@ -19,32 +19,31 @@ osu game framework - + - - - - + + - + - + + - + diff --git a/workloads.json b/workloads.json new file mode 100644 index 0000000000..c27e7ead60 --- /dev/null +++ b/workloads.json @@ -0,0 +1,3 @@ +{ + "microsoft.net.sdk.ios": "17.2.8053/8.0.100" +}