diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6399c69..b654b5d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -107,7 +107,7 @@ jobs: cd theseus/third-party/bgfx ../bx/tools/bin/linux/genie --gcc=linux-gcc gmake make -C .build/projects/gmake-linux-gcc -j$(nproc) \ - bx bimg bgfx config=release64 + bx bimg bgfx config=release64 CXXFLAGS=-DBGFX_CONFIG_RENDERER_OPENGL=33 - name: Build libdatachannel # See the macOS job: no lib means xCloud quietly compiles out. @@ -175,7 +175,7 @@ jobs: find .build/projects/gmake-linux-gcc -name '*.make' -exec \ sed -i -e 's/-m64//g' -e 's/-mfpmath=sse//g' -e 's/-msse4.2//g' {} + make -C .build/projects/gmake-linux-gcc -j$(nproc) \ - bx bimg bgfx config=release64 + bx bimg bgfx config=release64 CXXFLAGS=-DBGFX_CONFIG_RENDERER_OPENGL=33 - name: Build libdatachannel # See the macOS job: no lib means xCloud quietly compiles out. @@ -281,7 +281,8 @@ jobs: ../bx/tools/bin/linux/genie --gcc=mingw-gcc gmake make -C .build/projects/gmake-mingw-gcc -j$(nproc) \ bx bimg bgfx config=release64 \ - CC=x86_64-w64-mingw32-gcc CXX=x86_64-w64-mingw32-g++ + CC=x86_64-w64-mingw32-gcc CXX=x86_64-w64-mingw32-g++ \ + CXXFLAGS=-DBGFX_CONFIG_RENDERER_OPENGL=33 - name: Fetch mingw deps (openssl, opus, ffmpeg) # OpenSSL and opus come prebuilt from MSYS2; ffmpeg is built here, diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ffbad30..cd6f216 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,32 +1,98 @@ name: release +# Channels, picked by the tag: +# vX.Y.Z a release, marked latest +# vX.Y.Z-testing a pre-release +# nightly main's head as the `nightly` pre-release (run by hand, +# replaces the last one) +# Only commits on main get released. Each job builds and hands its files to +# `publish`, which writes SHA256SUMS.txt and makes the release in one go once +# every platform built. The notes come from release-notes/.md +# (release-notes/0.5.0.md for v0.5.0) when that commit has one, otherwise +# GitHub writes them. + on: push: tags: ['v*'] workflow_dispatch: inputs: tag: - description: 'Tag to release as (e.g. v0.5.0)' + description: 'Tag to release as (v0.5.0, v0.5.0-testing), or nightly' required: true -# Required so softprops/action-gh-release can create / update the -# release and upload assets via the workflow's GITHUB_TOKEN. permissions: contents: write +concurrency: + group: release-${{ github.event.inputs.tag || github.ref_name }} + cancel-in-progress: false + # Per-platform: build, package binary + Configs/Data/Library + runtime libs into a -# platform-named archive, attach to the GitHub Release. All desktop targets ship -# the bgfx backend (Metal on macOS, Vulkan on Linux / Windows). Shader .bin files -# live in-tree under Data/shaders/; shaderc only runs offline when authoring new -# shaders, not during CI. +# platform-named archive. All desktop targets ship the bgfx backend (Metal on +# macOS, Vulkan on Linux / Windows). Shader .bin files live in-tree under +# Data/shaders/; shaderc only runs offline when authoring new shaders, not +# during CI. jobs: + gate: + name: Gate + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.pick.outputs.tag }} + channel: ${{ steps.pick.outputs.channel }} + sha: ${{ steps.pick.outputs.sha }} + steps: + - uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + filter: blob:none + + - name: Pick the channel and check the commit is on main + id: pick + env: + TAG: ${{ github.event.inputs.tag || github.ref_name }} + run: | + if [ "$TAG" = nightly ]; then + CHANNEL=nightly + elif [[ "$TAG" == v*-testing ]]; then + CHANNEL=testing + elif [[ "$TAG" =~ ^v[0-9]+(\.[0-9]+)+$ ]]; then + CHANNEL=release + else + echo "::error::$TAG is not vX.Y.Z, vX.Y.Z-testing or nightly" + exit 1 + fi + + if [ "$CHANNEL" = nightly ]; then + SHA=$(git rev-parse origin/main) + elif SHA=$(git rev-parse -q --verify "refs/tags/$TAG^{commit}"); then + : + else + SHA=$GITHUB_SHA + fi + + if ! git merge-base --is-ancestor "$SHA" origin/main; then + echo "::error::$SHA is not on main, so it doesn't get released" + exit 1 + fi + echo "$TAG ($CHANNEL) from $SHA" + { + echo "tag=$TAG" + echo "channel=$CHANNEL" + echo "sha=$SHA" + } >> "$GITHUB_OUTPUT" + release-macos: name: Release / macOS + needs: gate runs-on: macos-latest + env: + TAG: ${{ needs.gate.outputs.tag }} steps: - uses: actions/checkout@v4 with: + ref: ${{ needs.gate.outputs.sha }} submodules: recursive - name: Install deps @@ -67,7 +133,6 @@ jobs: # dylibs come from brew. projectM dylibs come bundled in lib/ next to the # binary (Makefile drops them there for @loader_path/lib). run: | - TAG="${{ github.event.inputs.tag || github.ref_name }}" DROP="Theseus-${TAG}-macos" mkdir -p "${DROP}" cp ~/builds/theseus/desktop/theseus "${DROP}/" @@ -82,31 +147,43 @@ jobs: # Ad-hoc signed, which is enough to run but not to hand around # unnotarized: Gatekeeper still wants a right click Open. run: | - TAG="${{ github.event.inputs.tag || github.ref_name }}" chmod +x build/mk-macapp.sh ./build/mk-macapp.sh ditto -c -k --keepParent ~/builds/theseus/Theseus.app "Theseus-${TAG}-macos-app.zip" echo "APPZIP=Theseus-${TAG}-macos-app.zip" >> "$GITHUB_ENV" - - name: Upload .app to release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ github.event.inputs.tag || github.ref_name }} - files: ${{ env.APPZIP }} - - - name: Upload to release - uses: softprops/action-gh-release@v2 + - name: Hand the files to publish + uses: actions/upload-artifact@v4 with: - tag_name: ${{ github.event.inputs.tag || github.ref_name }} - files: ${{ env.ARCHIVE }} - fail_on_unmatched_files: true + name: release-macos + path: | + ${{ env.ARCHIVE }} + ${{ env.APPZIP }} + if-no-files-found: error release-linux: name: Release / Linux x64 + needs: gate + # Debian 12 has glibc 2.36, so the build and the libraries the AppImage + # carries start on SteamOS 3.5 and up. Ubuntu's glibc is newer than that, + # and 22.04's SDL is too old to build against. runs-on: ubuntu-latest + container: debian:bookworm + env: + TAG: ${{ needs.gate.outputs.tag }} + DEBIAN_FRONTEND: noninteractive steps: + - name: Container tools + # checkout wants git in the container for submodules, and the version + # stamp runs git on a tree the container user doesn't own. + run: | + apt-get update + apt-get install -y git ca-certificates + git config --global --add safe.directory '*' + - uses: actions/checkout@v4 with: + ref: ${{ needs.gate.outputs.sha }} submodules: recursive - name: Install deps @@ -114,8 +191,8 @@ jobs: # though the running binary uses bgfx/Vulkan. # cmake: GitHub runners preinstall it, but make it explicit. run: | - sudo apt update - sudo apt install -y build-essential pkg-config cmake \ + apt-get update + apt-get install -y build-essential pkg-config cmake \ libsdl2-dev libsdl2-mixer-dev \ libvulkan-dev libx11-dev libgl-dev \ libmpv-dev libcurl4-openssl-dev libssl-dev \ @@ -124,12 +201,19 @@ jobs: - name: Stub Configs/Data/Library run: mkdir -p Configs Data Library + - name: Build GENie from source + # The genie checked into bx wants glibc 2.38, newer than Debian 12's. + run: | + git clone --depth 1 https://github.com/bkaradzic/GENie.git /tmp/genie + make -C /tmp/genie + cp /tmp/genie/bin/linux/genie theseus/third-party/bx/tools/bin/linux/genie + - name: Build bgfx libs run: | cd theseus/third-party/bgfx ../bx/tools/bin/linux/genie --gcc=linux-gcc gmake make -C .build/projects/gmake-linux-gcc -j$(nproc) \ - bx bimg bgfx config=release64 + bx bimg bgfx config=release64 CXXFLAGS=-DBGFX_CONFIG_RENDERER_OPENGL=33 - name: Build libdatachannel # See the macOS job: no lib means xCloud quietly compiles out. @@ -146,7 +230,6 @@ jobs: - name: Package run: | - TAG="${{ github.event.inputs.tag || github.ref_name }}" DROP="Theseus-${TAG}-linux-x64" mkdir -p "${DROP}" cp ~/builds/theseus/desktop/theseus "${DROP}/" @@ -159,39 +242,81 @@ jobs: # One file for Debian and Arch alike, so the exclusion list in the # script only names libraries both actually ship. run: | - TAG="${{ github.event.inputs.tag || github.ref_name }}" - sudo apt-get install -y -qq patchelf wget file + apt-get install -y -qq patchelf wget file chmod +x build/mk-appimage.sh ./build/mk-appimage.sh ~/builds/theseus/desktop "UIX-Desktop-${TAG}-x86_64.AppImage" echo "APPIMAGE=UIX-Desktop-${TAG}-x86_64.AppImage" >> "$GITHUB_ENV" - - name: Upload AppImage to release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ github.event.inputs.tag || github.ref_name }} - files: ${{ env.APPIMAGE }} - - - name: Upload to release - uses: softprops/action-gh-release@v2 + - name: glibc baseline + # Fails if the binary or anything in the AppImage wants a newer glibc + # than SteamOS 3.5's 2.37 (3.6 has 2.39, 3.7 2.40, 3.8 2.41). + shell: bash + run: | + MAX=2.37 + GLIBC_LIBS=$(find AppDir/usr/lib -regextype posix-extended \ + -regex '.*/(libc|libm|libmvec|libdl|libpthread|librt|libresolv)\.so.*') + if [ -n "$GLIBC_LIBS" ]; then + echo "::error::the AppImage bundles part of glibc: $GLIBC_LIBS" + exit 1 + fi + BAD=0 + NEWEST=0 + while IFS= read -r -d '' F; do + head -c4 "$F" | cmp -s - <(printf '\177ELF') || continue + V=$(readelf -V -W "$F") + if grep -q GLIBC_PRIVATE <<< "$V"; then + echo "::error::$F uses GLIBC_PRIVATE" + BAD=1 + fi + NEED=$(grep -oE 'GLIBC_[0-9]+(\.[0-9]+)+' <<< "$V" | sed 's/GLIBC_//' | sort -uV | tail -1 || true) + [ -n "$NEED" ] || continue + NEWEST=$(printf '%s\n%s\n' "$NEWEST" "$NEED" | sort -V | tail -1) + if [ "$(printf '%s\n%s\n' "$NEED" "$MAX" | sort -V | tail -1)" != "$MAX" ]; then + echo "::error::$F needs GLIBC_$NEED, newer than $MAX" + BAD=1 + fi + done < <(find ~/builds/theseus/desktop/theseus ~/builds/theseus/desktop/lib AppDir -type f -print0 2>/dev/null) + echo "newest glibc needed: $NEWEST (limit $MAX)" + exit $BAD + + - name: Hand the files to publish + uses: actions/upload-artifact@v4 with: - tag_name: ${{ github.event.inputs.tag || github.ref_name }} - files: ${{ env.ARCHIVE }} - fail_on_unmatched_files: true + name: release-linux-x64 + path: | + ${{ env.ARCHIVE }} + ${{ env.APPIMAGE }} + if-no-files-found: error release-linux-arm64: name: Release / Linux ARM64 + needs: gate + # Debian 12 for the same glibc reason as the x64 job. runs-on: ubuntu-24.04-arm + container: debian:bookworm + env: + TAG: ${{ needs.gate.outputs.tag }} + DEBIAN_FRONTEND: noninteractive steps: + - name: Container tools + # checkout wants git in the container for submodules, and the version + # stamp runs git on a tree the container user doesn't own. + run: | + apt-get update + apt-get install -y git ca-certificates + git config --global --add safe.directory '*' + - uses: actions/checkout@v4 with: + ref: ${{ needs.gate.outputs.sha }} submodules: recursive - name: Install deps # libgl-dev: needed by projectM's cmake FindOpenGL probe. # cmake: GitHub runners preinstall it, but make it explicit. run: | - sudo apt update - sudo apt install -y build-essential pkg-config cmake \ + apt-get update + apt-get install -y build-essential pkg-config cmake \ libsdl2-dev libsdl2-mixer-dev \ libvulkan-dev libx11-dev libgl-dev \ libmpv-dev libcurl4-openssl-dev libssl-dev \ @@ -218,7 +343,7 @@ jobs: find .build/projects/gmake-linux-gcc -name '*.make' -exec \ sed -i -e 's/-m64//g' -e 's/-mfpmath=sse//g' -e 's/-msse4.2//g' {} + make -C .build/projects/gmake-linux-gcc -j$(nproc) \ - bx bimg bgfx config=release64 + bx bimg bgfx config=release64 CXXFLAGS=-DBGFX_CONFIG_RENDERER_OPENGL=33 - name: Build libdatachannel # See the macOS job: no lib means xCloud quietly compiles out. @@ -235,7 +360,6 @@ jobs: - name: Package run: | - TAG="${{ github.event.inputs.tag || github.ref_name }}" DROP="Theseus-${TAG}-linux-arm64" mkdir -p "${DROP}" cp ~/builds/theseus/desktop/theseus "${DROP}/" @@ -248,34 +372,64 @@ jobs: # The bare binary needs the runner's exact FFmpeg sonames, which # newer distros like Debian trixie don't have. The AppImage carries them. run: | - TAG="${{ github.event.inputs.tag || github.ref_name }}" - sudo apt-get install -y -qq patchelf wget file + apt-get install -y -qq patchelf wget file chmod +x build/mk-appimage.sh ./build/mk-appimage.sh ~/builds/theseus/desktop "UIX-Desktop-${TAG}-aarch64.AppImage" echo "APPIMAGE=UIX-Desktop-${TAG}-aarch64.AppImage" >> "$GITHUB_ENV" - - name: Upload AppImage to release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ github.event.inputs.tag || github.ref_name }} - files: ${{ env.APPIMAGE }} - - - name: Upload to release - uses: softprops/action-gh-release@v2 + - name: glibc baseline + # Fails if the binary or anything in the AppImage wants a newer glibc + # than SteamOS 3.5's 2.37 (3.6 has 2.39, 3.7 2.40, 3.8 2.41). + shell: bash + run: | + MAX=2.37 + GLIBC_LIBS=$(find AppDir/usr/lib -regextype posix-extended \ + -regex '.*/(libc|libm|libmvec|libdl|libpthread|librt|libresolv)\.so.*') + if [ -n "$GLIBC_LIBS" ]; then + echo "::error::the AppImage bundles part of glibc: $GLIBC_LIBS" + exit 1 + fi + BAD=0 + NEWEST=0 + while IFS= read -r -d '' F; do + head -c4 "$F" | cmp -s - <(printf '\177ELF') || continue + V=$(readelf -V -W "$F") + if grep -q GLIBC_PRIVATE <<< "$V"; then + echo "::error::$F uses GLIBC_PRIVATE" + BAD=1 + fi + NEED=$(grep -oE 'GLIBC_[0-9]+(\.[0-9]+)+' <<< "$V" | sed 's/GLIBC_//' | sort -uV | tail -1 || true) + [ -n "$NEED" ] || continue + NEWEST=$(printf '%s\n%s\n' "$NEWEST" "$NEED" | sort -V | tail -1) + if [ "$(printf '%s\n%s\n' "$NEED" "$MAX" | sort -V | tail -1)" != "$MAX" ]; then + echo "::error::$F needs GLIBC_$NEED, newer than $MAX" + BAD=1 + fi + done < <(find ~/builds/theseus/desktop/theseus ~/builds/theseus/desktop/lib AppDir -type f -print0 2>/dev/null) + echo "newest glibc needed: $NEWEST (limit $MAX)" + exit $BAD + + - name: Hand the files to publish + uses: actions/upload-artifact@v4 with: - tag_name: ${{ github.event.inputs.tag || github.ref_name }} - files: ${{ env.ARCHIVE }} - fail_on_unmatched_files: true + name: release-linux-arm64 + path: | + ${{ env.ARCHIVE }} + ${{ env.APPIMAGE }} + if-no-files-found: error release-win64: name: Release / Win64 + needs: gate runs-on: ubuntu-latest env: + TAG: ${{ needs.gate.outputs.tag }} SDL2_VER: 2.32.10 SDL2_MIXER_VER: 2.8.1 steps: - uses: actions/checkout@v4 with: + ref: ${{ needs.gate.outputs.sha }} submodules: recursive - name: Install mingw-w64 (posix thread model) @@ -342,7 +496,8 @@ jobs: ../bx/tools/bin/linux/genie --gcc=mingw-gcc gmake make -C .build/projects/gmake-mingw-gcc -j$(nproc) \ bx bimg bgfx config=release64 \ - CC=x86_64-w64-mingw32-gcc CXX=x86_64-w64-mingw32-g++ + CC=x86_64-w64-mingw32-gcc CXX=x86_64-w64-mingw32-g++ \ + CXXFLAGS=-DBGFX_CONFIG_RENDERER_OPENGL=33 - name: Fetch mingw deps (openssl, opus, ffmpeg) # OpenSSL and opus come prebuilt from MSYS2; ffmpeg is built here, @@ -380,7 +535,6 @@ jobs: # dir (SDL2.dll, SDL2_mixer.dll, libmpv-2.dll, libcurl-x64.dll, # libwinpthread-1.dll). Ship the whole directory. run: | - TAG="${{ github.event.inputs.tag || github.ref_name }}" DROP="Theseus-${TAG}-windows-x64" mkdir -p "${DROP}" cp -R ~/builds/theseus/desktop-win64/. "${DROP}/" @@ -391,9 +545,64 @@ jobs: zip -r "${DROP}.zip" "${DROP}" echo "ARCHIVE=${DROP}.zip" >> "$GITHUB_ENV" - - name: Upload to release - uses: softprops/action-gh-release@v2 + - name: Hand the files to publish + uses: actions/upload-artifact@v4 with: - tag_name: ${{ github.event.inputs.tag || github.ref_name }} - files: ${{ env.ARCHIVE }} - fail_on_unmatched_files: true + name: release-win64 + path: | + ${{ env.ARCHIVE }} + if-no-files-found: error + + publish: + name: Publish + needs: [gate, release-macos, release-linux, release-linux-arm64, release-win64] + runs-on: ubuntu-latest + env: + TAG: ${{ needs.gate.outputs.tag }} + CHANNEL: ${{ needs.gate.outputs.channel }} + SHA: ${{ needs.gate.outputs.sha }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.gate.outputs.sha }} + + - uses: actions/download-artifact@v4 + with: + pattern: release-* + path: dist + merge-multiple: true + + - name: SHA256SUMS + run: | + cd dist + sha256sum -- * > ../SHA256SUMS.txt + mv ../SHA256SUMS.txt . + cat SHA256SUMS.txt + + - name: Publish + # nightly is one moving pre-release: drop the old one and its tag so + # the new tag lands on main's head. + run: | + case "$CHANNEL" in + release) FLAGS=(--latest) ;; + *) FLAGS=(--prerelease --latest=false) ;; + esac + TITLE="UIX Desktop ${TAG#v}" + if [ "$CHANNEL" = nightly ]; then + gh release delete nightly --cleanup-tag --yes || true + NOTES=(--notes "Nightly build of main at $SHA. Not a release, expect rough edges.") + elif [ -f "release-notes/${TAG#v}.md" ]; then + NOTES=(--notes-file "release-notes/${TAG#v}.md") + else + NOTES=(--generate-notes) + fi + + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" dist/* --clobber + gh release edit "$TAG" "${FLAGS[@]}" + else + gh release create "$TAG" dist/* --target "$SHA" --title "$TITLE" \ + "${FLAGS[@]}" "${NOTES[@]}" + fi diff --git a/Configs/version b/Configs/version index 085201d..90a7e7c 100644 --- a/Configs/version +++ b/Configs/version @@ -1,2 +1,2 @@ [default] -version=0.4.0 +version=0.4.1 diff --git a/Data/Fonts/NotoSans-Bold.ttf b/Data/Fonts/NotoSans-Bold.ttf new file mode 100644 index 0000000..aae7546 Binary files /dev/null and b/Data/Fonts/NotoSans-Bold.ttf differ diff --git a/Data/Fonts/NotoSans-OFL.txt b/Data/Fonts/NotoSans-OFL.txt new file mode 100644 index 0000000..6843f31 --- /dev/null +++ b/Data/Fonts/NotoSans-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/Data/Fonts/NotoSans-Regular.ttf b/Data/Fonts/NotoSans-Regular.ttf new file mode 100644 index 0000000..f27f4ff Binary files /dev/null and b/Data/Fonts/NotoSans-Regular.ttf differ diff --git a/Data/shaders/dx11/fs_guide.bin b/Data/shaders/dx11/fs_guide.bin new file mode 100644 index 0000000..de7d762 Binary files /dev/null and b/Data/shaders/dx11/fs_guide.bin differ diff --git a/Data/shaders/dx11/vs_guide.bin b/Data/shaders/dx11/vs_guide.bin new file mode 100644 index 0000000..159a1b8 Binary files /dev/null and b/Data/shaders/dx11/vs_guide.bin differ diff --git a/Data/shaders/essl/fs_guide.bin b/Data/shaders/essl/fs_guide.bin new file mode 100644 index 0000000..a027487 Binary files /dev/null and b/Data/shaders/essl/fs_guide.bin differ diff --git a/Data/shaders/essl/vs_guide.bin b/Data/shaders/essl/vs_guide.bin new file mode 100644 index 0000000..858b9b5 Binary files /dev/null and b/Data/shaders/essl/vs_guide.bin differ diff --git a/Data/shaders/glsl/fs_guide.bin b/Data/shaders/glsl/fs_guide.bin new file mode 100644 index 0000000..afe7585 Binary files /dev/null and b/Data/shaders/glsl/fs_guide.bin differ diff --git a/Data/shaders/glsl/vs_guide.bin b/Data/shaders/glsl/vs_guide.bin new file mode 100644 index 0000000..3e0f4a2 Binary files /dev/null and b/Data/shaders/glsl/vs_guide.bin differ diff --git a/Data/shaders/metal/fs_guide.bin b/Data/shaders/metal/fs_guide.bin new file mode 100644 index 0000000..f1fa4d8 Binary files /dev/null and b/Data/shaders/metal/fs_guide.bin differ diff --git a/Data/shaders/metal/vs_guide.bin b/Data/shaders/metal/vs_guide.bin new file mode 100644 index 0000000..2d9f33a Binary files /dev/null and b/Data/shaders/metal/vs_guide.bin differ diff --git a/Data/shaders/spirv/fs_guide.bin b/Data/shaders/spirv/fs_guide.bin new file mode 100644 index 0000000..4e65ca1 Binary files /dev/null and b/Data/shaders/spirv/fs_guide.bin differ diff --git a/Data/shaders/spirv/vs_guide.bin b/Data/shaders/spirv/vs_guide.bin new file mode 100644 index 0000000..7559b84 Binary files /dev/null and b/Data/shaders/spirv/vs_guide.bin differ diff --git a/LICENSE-THIRD-PARTY.md b/LICENSE-THIRD-PARTY.md index 425dcd1..97f5af2 100644 --- a/LICENSE-THIRD-PARTY.md +++ b/LICENSE-THIRD-PARTY.md @@ -50,6 +50,12 @@ Used by the desktop build: All of the above are GPL-3.0-compatible, either by being permissive (MIT, BSD, zlib, public domain) or by being LGPL (linkable from GPL). +## Fonts + +| Font | License | Purpose | +|---|---|---| +| [Noto Sans](https://github.com/notofonts/latin-greek-cyrillic) Regular and Bold (`Data/Fonts/NotoSans-*.ttf`) | SIL Open Font License 1.1, [`Data/Fonts/NotoSans-OFL.txt`](Data/Fonts/NotoSans-OFL.txt) | The desktop guide's text | + ## Xbox Dashboard Assets (`Data/`) The XIPs and skin assets in `Data/` are authored by [UIX Lite](https://github.com/OfficialTeamUIX/UIX-Lite) and contain TeamUIX modifications of dashboard scripts present on every retail Xbox console. The `uixdata` directory layout is a convention established by JbOnE's earlier source-level modification, *User.Interface.X* (UIX); Theseus follows that convention so the same content runs on both projects. UIX Lite's modifications are licensed under GPL-3.0-or-later. The underlying script formats and any unmodified original components are referenced for compatibility and preservation; Microsoft retains its rights to its original work. Every Xbox owner already possesses the equivalent original files on their console. diff --git a/build/Makefile b/build/Makefile index 32ce600..1857dd4 100644 --- a/build/Makefile +++ b/build/Makefile @@ -868,6 +868,13 @@ DESKTOP_SRCS_SYSTEM = \ # Desktop-only platform sources (SDL window, ImGui, audio, tools) DESKTOP_SRCS_PLATFORM = \ $(THESEUS_SRC)/desktop/sdl_main.cpp \ + $(THESEUS_SRC)/desktop/settings_schema.cpp \ + $(THESEUS_SRC)/desktop/desktop_settings.cpp \ + $(THESEUS_SRC)/desktop/guide.cpp \ + $(THESEUS_SRC)/desktop/ui_overlay.cpp \ + $(THESEUS_SRC)/desktop/ui_overlay_style.cpp \ + $(THESEUS_SRC)/desktop/ui_font.cpp \ + $(THESEUS_SRC)/desktop/log_redact.cpp \ $(THESEUS_SRC)/desktop/launch.cpp \ $(THESEUS_SRC)/desktop/app_paths.cpp \ $(THESEUS_SRC)/desktop/osk.cpp \ @@ -875,6 +882,7 @@ DESKTOP_SRCS_PLATFORM = \ $(THESEUS_SRC)/desktop/launchers/registry.cpp \ $(THESEUS_SRC)/desktop/launchers/steam.cpp \ $(THESEUS_SRC)/desktop/launchers/retroarch.cpp \ + $(THESEUS_SRC)/desktop/launchers/xemu.cpp \ $(THESEUS_SRC)/desktop/title_sanitize.cpp \ $(THESEUS_SRC)/desktop/udata_synth.cpp \ $(THESEUS_SRC)/desktop/desktop_stubs.cpp \ @@ -1092,6 +1100,10 @@ $(PROJECTM_MARKER): projectm-clean: rm -rf $(PROJECTM_SRC)/build $(PROJECTM_SRC)/install +# milkdrop_window.cpp includes projectM's headers and the link needs its libs, +# so a parallel first build has to wait for the install. +$(DESKTOP_DIR)/desktop/milkdrop_window.o $(DESKTOP_TARGET): | $(PROJECTM_MARKER) + $(DESKTOP_TARGET): $(DESKTOP_OBJS) @mkdir -p $(DESKTOP_DIR) $(DESKTOP_DIR)/lib $(DESKTOP_CXX) -o $@ $(DESKTOP_OBJS) $(DESKTOP_LDFLAGS) @@ -1356,6 +1368,9 @@ PROJECTM_WIN64_TOOLCHAIN := $(abspath $(THESEUS_SRC)/../build/cmake-toolchains/m projectm-deps-win64: $(PROJECTM_WIN64_MARKER) +# Same race as the native build. +$(WIN64_DIR)/desktop/milkdrop_window.o $(WIN64_TARGET): | $(PROJECTM_WIN64_MARKER) + $(PROJECTM_WIN64_MARKER): @if [ ! -f $(PROJECTM_SRC)/CMakeLists.txt ]; then \ echo "projectM submodule not initialized; running git submodule update..." ; \ diff --git a/build/mk-appimage.sh b/build/mk-appimage.sh index d5a8b8f..14d5f13 100755 --- a/build/mk-appimage.sh +++ b/build/mk-appimage.sh @@ -31,7 +31,9 @@ done [ -d "$REPO/Library" ] && cp -r "$REPO/Library" "$APP/usr/bin/Library" [ -d "$SRC/lib" ] && cp -a "$SRC/lib/." "$APP/usr/lib/" || true -KEEP_HOST="ld-linux|libc\.so|libm\.so|libdl\.so|libpthread|librt\.so|libgcc_s|libstdc\+\+|libGL|libEGL|libGLX|libGLdispatch|libOpenGL|libvulkan|libdrm|libX11|libXext\.|libXau|libXdmcp|libxcb|libwayland|libsystemd|libdbus|libudev|libasound|libpulse" +# All of glibc stays on the host, libmvec and libresolv included: a bundled +# piece of glibc next to the host's libc is a mismatch waiting to happen. +KEEP_HOST="ld-linux|libc\.so|libm\.so|libmvec\.so|libresolv\.so|libanl\.so|libutil\.so|libdl\.so|libpthread|librt\.so|libgcc_s|libstdc\+\+|libGL|libEGL|libGLX|libGLdispatch|libOpenGL|libvulkan|libdrm|libX11|libXext\.|libXau|libXdmcp|libxcb|libwayland|libsystemd|libdbus|libudev|libasound|libpulse" walk() { ldd "$1" 2>/dev/null | awk '{print $1, $3}' | while read -r so path; do diff --git a/docs/desktop/building.md b/docs/desktop/building.md index 2423e3c..ae7d58b 100644 --- a/docs/desktop/building.md +++ b/docs/desktop/building.md @@ -41,14 +41,18 @@ make -C .build/projects/gmake-osx-arm64 -j$(sysctl -n hw.ncpu) bx bimg bgfx conf # Linux ../bx/tools/bin/linux/genie --gcc=linux-gcc gmake -make -C .build/projects/gmake-linux-gcc -j$(nproc) bx bimg bgfx config=release64 +make -C .build/projects/gmake-linux-gcc -j$(nproc) bx bimg bgfx config=release64 \ + CXXFLAGS=-DBGFX_CONFIG_RENDERER_OPENGL=33 # Windows, cross from Linux ../bx/tools/bin/linux/genie --gcc=mingw-gcc gmake make -C .build/projects/gmake-mingw-gcc -j$(nproc) bx bimg bgfx config=release64 \ - CC=x86_64-w64-mingw32-gcc CXX=x86_64-w64-mingw32-g++ + CC=x86_64-w64-mingw32-gcc CXX=x86_64-w64-mingw32-g++ \ + CXXFLAGS=-DBGFX_CONFIG_RENDERER_OPENGL=33 ``` +The OpenGL renderer needs `BGFX_CONFIG_RENDERER_OPENGL=33`. Without it bgfx targets OpenGL 2.1, compiles the shaders as GLSL 1.20, and strict drivers reject them, which leaves a black screen and then a crash. + Always give `-j` a number. A bare `-j` starts every compile at once, and on a small machine that runs it out of memory and can freeze the whole desktop. If a low RAM machine with no swap still struggles, use a smaller number than the core count. The same goes for every `make` and `cmake --build` below. The `genie` that ships prebuilt in bx needs a recent glibc. On an older distro it fails with `GLIBC_2.38 not found`; build it from source and carry on: diff --git a/release-notes/0.4.1.md b/release-notes/0.4.1.md new file mode 100644 index 0000000..34e6384 --- /dev/null +++ b/release-notes/0.4.1.md @@ -0,0 +1,26 @@ +A quick follow up to 0.4.0, mostly for Linux and Steam Deck players. + +## Downloads +| OS | File | +|---|---| +| macOS (Apple Silicon) | `Theseus-v0.4.1-macos-app.zip` (the app), or `Theseus-v0.4.1-macos.zip` (bare binary, needs Homebrew libs) | +| Windows x64 | `Theseus-v0.4.1-windows-x64.zip`. Unzip anywhere, run `theseus.exe` | +| Linux x64 | `UIX-Desktop-v0.4.1-x86_64.AppImage` (Steam Deck too), or the tarball | +| Linux ARM64 | `UIX-Desktop-v0.4.1-aarch64.AppImage`, or the tarball | + +`SHA256SUMS.txt` lists a checksum for every file. + +## Fixed +- **The Linux AppImage didn't start on SteamOS or newer Debian.** 0.4.0 was built against a newer system library than those ship. Linux builds now target SteamOS 3.5 and newer, and every release checks for it. +- **The OpenGL renderer showed a black screen and closed** on some GPUs and drivers (#76). +- **Borderless and resized windows stretched the dashboard** until you resized it (#41, and the rest of #47). +- **Games in folders with spaces or (x86) didn't launch** on Windows, and .exe games now start directly (#38). +- **xemu ISOs with long paths didn't launch**, and Flatpak or snap xemu is found on its own (#84). +- **Big Steam libraries** now import completely, with icons fetched in the background (#28). +- **Folders showed up as movies** (#40). A folder counts as a movie when the video is right in it; collection folders like Action/Die Hard still work. +- Deleting a title no longer flattens grouped titles, and startup flags like `--dashboard` no longer change your saved startup choice. + +## New: the guide +Press the Guide button, LT+B or F7 anywhere for a controller friendly guide: settings, accounts (Plex, Jellyfin, Xbox), adding games, logs and help, all without the old tool windows. It has its own on screen keyboard for paths and keys. Dashboard Green by default, with blue and red under Settings > Display > Guide Color. The old menu bar now only shows in development mode. + +On first launch after updating you'll see a short Welcome screen once. diff --git a/theseus/desktop/dashapp.cpp b/theseus/desktop/dashapp.cpp index beb3200..8301bde 100755 --- a/theseus/desktop/dashapp.cpp +++ b/theseus/desktop/dashapp.cpp @@ -16,6 +16,7 @@ #include "audio_sdl.h" #include "cdaudio.h" #include "settingsfile.h" +#include "display.h" #include extern void PreloadSkinTextures(); @@ -415,9 +416,12 @@ bool InitD3D() uint32_t dwBehavior = D3DCREATE_HARDWARE_VERTEXPROCESSING; - // Desktop: resolution follows SDL window size, updated on resize - g_pp.BackBufferWidth = 1280; - g_pp.BackBufferHeight = 720; + // Desktop: resolution follows the window. Read it here, since a saved + // borderless or 1080 mode was already applied before we got this far. + int drawW = 0, drawH = 0; + Plat_GetDrawableSize(&drawW, &drawH); + g_pp.BackBufferWidth = drawW > 0 ? drawW : 1280; + g_pp.BackBufferHeight = drawH > 0 ? drawH : 720; g_pp.Flags = D3DPRESENTFLAG_PROGRESSIVE | D3DPRESENTFLAG_WIDESCREEN; g_bStretchWidescreen = false; diff --git a/theseus/desktop/desktop_nodes.cpp b/theseus/desktop/desktop_nodes.cpp index eba0dde..e9e4ecf 100644 --- a/theseus/desktop/desktop_nodes.cpp +++ b/theseus/desktop/desktop_nodes.cpp @@ -1357,6 +1357,7 @@ END_NODE_FUN() #include "media_player.h" #include "panel_shared.h" // for DashAudio_MuteAll/UnmuteAll #include +#include #define XBOX_BLOCK_SIZE 16384 #define SAVED_GAME_MAX_TITLES 256 @@ -1909,7 +1910,9 @@ struct DesktopIconEntry { char id[128]; // lowercased ID from icons.ini value char iconPath[MAX_PATH]; // Xbox path: "E:\Games\FolderName\icon.jpg" }; -static DesktopIconEntry s_iconEntries[512]; +// Grows and never shrinks: the selected title's image path points in here, +// and a deque keeps that pointer good while it grows. +static std::deque s_iconEntries; static int s_iconCount = 0; bool s_iconsLoaded = false; // non-static: reset by Title Maker when games change @@ -1924,7 +1927,7 @@ static void LoadDesktopIcons() extern void VGames_Load(); VGames_Load(); - for (int i = 0; i < g_vgames.count && s_iconCount < 512; i++) { + for (int i = 0; i < g_vgames.count; i++) { if (!g_vgames.games[i].valid) continue; if (!g_vgames.games[i].titleID[0]) continue; @@ -1939,6 +1942,7 @@ static void LoadDesktopIcons() } // Store the Xbox path so xbox_path.h can redirect it to the cached icon. + if ((int)s_iconEntries.size() <= s_iconCount) s_iconEntries.emplace_back(); snprintf(s_iconEntries[s_iconCount].iconPath, MAX_PATH, "%s:\\%s\\%s\\icon.jpg", g_vgames.games[i].drive, diff --git a/theseus/desktop/desktop_settings.cpp b/theseus/desktop/desktop_settings.cpp new file mode 100644 index 0000000..7c141e0 --- /dev/null +++ b/theseus/desktop/desktop_settings.cpp @@ -0,0 +1,200 @@ +// desktop_settings.cpp: the desktop.ini table. Every setting the desktop +// keeps is one row here; Load/SaveDesktopSettings and the settings screens +// all read it. + +#include "std.h" +#include "dashapp.h" +#include "app_paths.h" +#include "panel_shared.h" +#include "settings_schema.h" +#include "audio_sdl.h" +#include "media_player.h" + +extern char s_xemuPath[512]; +extern char g_qcowPath[512]; +extern char s_steamPath[512]; +extern char s_retroarchPath[512]; +extern char s_esdePath[512]; +extern bool g_showRetroArchTab, g_showSteamTab, g_showEsdeTab; +extern bool g_retroarchFullscreen; +extern int g_audioBufferFrames; +extern int g_startupMode; +extern bool g_bUseOnScreenKeyboard, g_bShowBootAnimation; +extern bool g_menuBarAutoHide, g_padModeEnabled; +extern bool g_welcomeDone; +extern bool g_bEmulateXboxRes; +extern float g_masterVolume; +extern bool g_useMilkdropViz, g_showAlbumCover; +extern char g_musicRoot[512], g_moviesRoot[512], g_tvRoot[512]; +extern char g_tmdbKey[128], g_romsDir[512]; +extern char g_plexToken[256], g_plexClientId[64]; +extern char g_xcloudRefreshToken[2048]; +extern char g_jellyfinUrl[512], g_jellyfinToken[256], g_jellyfinUserId[64]; +extern char g_jellyfinClientId[64], g_jellyfinUserName[128]; + +// The dashboard writes these itself through Q:\System\config.ini, which is +// this same file. The skin editor sets the skin too. +static char s_use720p[8] = "Yes"; +static char s_useProgressive[8] = "Yes"; +char g_currentSkin[64] = "Stock"; + +// What desktop.ini says. g_startupMode is this run's mode, which +// --dashboard / --development can override without it being saved. +static int s_startupModeSaved = 0; +static void ApplyStartupMode() { g_startupMode = s_startupModeSaved; } + +static void ApplyMsaa() { g_msaaChangeRequested = true; } +static void ApplyVsync() { g_vsyncChangeRequested = true; } +static void ApplyDisplay() { g_displayChangeRequested = true; } +static void ApplyProjection() { TheseusSetProjectionDirty(); } +// A new library folder is read right away, like the Refresh buttons. +extern "C" void MediaDB_ScanAndCache(); +static void ApplyMusic() { DashMusic_Scan(DashMusic_GetConfiguredRoot()); } +static void ApplyLibrary() { MediaDB_ScanAndCache(); } +static void ApplyVolume() +{ + DashAudio_SetMasterVolume(g_masterVolume); + MediaPlayer_SetMasterVolume(g_masterVolume); +} + +static const int kStartupVals[] = { 0, 1, 2 }; +static const char* kStartupLbl[] = { "Ask on launch", "Dashboard", "Development" }; +static const char* kStartupTok[] = { "", "dashboard", "development" }; + +static const int kMsaaVals[] = { 0, 2, 4, 8 }; +static const char* kMsaaLbl[] = { "Off", "2x", "4x", "8x" }; + +// bgfx has no adaptive vsync; 1 is an old "on" and still loads. +static const int kVsyncVals[] = { 0, 2 }; +static const char* kVsyncLbl[] = { "On", "Off" }; + +static const int kFpsVals[] = { 0, 30, 60, 90, 120, 144, 240 }; +static const char* kFpsLbl[] = { "Unlimited", "30", "60", "90", "120", "144", "240" }; + +static const int kRendVals[] = { 0, 1, 2, 3, 4, 5 }; +static const char* kRendLbl[] = { "Auto", "Direct3D 11", "Vulkan", "OpenGL", "Metal", "OpenGL ES" }; +static const char* kRendTok[] = { "auto", "d3d11", "vulkan", "opengl", "metal", "opengles|gles" }; + +static const int kResVals[] = { 0, 720, 1080, 1440, 2160 }; +static const char* kResLbl[] = { "Native", "720p", "1080p", "1440p (2K)", "2160p (4K)" }; + +// The guide's palette (ui_overlay_style.cpp's themes, in their order). +int g_guideTheme = 0; +static const int kThemeVals[] = { 0, 1, 2 }; +static const char* kThemeLbl[] = { "Dashboard Green", "Blue", "Red" }; +static const char* kThemeTok[] = { "green", "blue", "red" }; + +static const int kModeVals[] = { 0, 1, 2 }; +static const char* kModeLbl[] = { "Windowed", "Borderless", "Fullscreen" }; + +#define ENUM(v, l, t) (int)(sizeof(v) / sizeof(v[0])), v, l, t +#define NOENUM 0, NULL, NULL, NULL + +#define S_BOOL(key, sec, page, label, var, def, fl) \ + { key, sec, page, label, SK_BOOL, &var, 0, def, fl, NOENUM, 0, 0, 0, NULL, NULL } +#define S_PATH(key, sec, page, label, var, fl) \ + { key, sec, page, label, SK_PATH, var, (int)sizeof(var), "", fl, NOENUM, 0, 0, 0, NULL, NULL } +#define S_FOLDER(key, sec, page, label, var, fn) \ + { key, sec, page, label, SK_PATH, var, (int)sizeof(var), "", 0, NOENUM, 0, 0, 0, NULL, fn } +#define S_STR(key, sec, page, label, var, def, fl) \ + { key, sec, page, label, SK_STRING, var, (int)sizeof(var), def, fl, NOENUM, 0, 0, 0, NULL, NULL } +#define S_CRT(key, label, field, def, lo, hi) \ + { key, "CRT", "Display", label, SK_FLOAT, &g_crt.field, 0, def, 0, NOENUM, lo, hi, 0.05f, NULL, NULL } + +static const SettingDef kDefs[] = { + S_PATH("XemuPath", "Desktop", "General", "xemu Path", s_xemuPath, 0), + S_PATH("QcowPath", "Desktop", "General", "qcow2 HDD Image", g_qcowPath, 0), + S_PATH("SteamPath", "Desktop", "Games", "Steam Folder", s_steamPath, 0), + S_PATH("RetroArchPath", "Desktop", "Games", "RetroArch Folder", s_retroarchPath, 0), + S_PATH("EsdePath", "Desktop", "Games", "ES-DE Folder", s_esdePath, 0), + S_BOOL("ShowEsdeTab", "Desktop", "Games", "ES-DE Import Tab", g_showEsdeTab, "1", 0), + S_BOOL("RetroArchFullscreen", "Desktop", "Games", "RetroArch Fullscreen", g_retroarchFullscreen, "1", 0), + { "AudioBufferFrames", "Desktop", "Audio", "Audio Buffer", SK_INT, &g_audioBufferFrames, 0, "1024", + SF_RESTART | SF_HIDDEN, NOENUM, 0, 0, 256, NULL, NULL }, + S_BOOL("ShowRetroArchTab", "Desktop", "Games", "RetroArch Tab", g_showRetroArchTab, "1", 0), + S_BOOL("ShowSteamTab", "Desktop", "Games", "Steam Tab", g_showSteamTab, "1", 0), + { "StartupMode", "Desktop", "General", "When UIX Desktop Starts", SK_ENUM, &s_startupModeSaved, 0, "", 0, + ENUM(kStartupVals, kStartupLbl, kStartupTok), 0, 0, 0, NULL, ApplyStartupMode }, + S_BOOL("UseOnScreenKeyboard", "Desktop", "General", "On-Screen Keyboard Only", g_bUseOnScreenKeyboard, "0", 0), + S_BOOL("ShowBootAnimation", "Desktop", "General", "Boot Animation", g_bShowBootAnimation, "1", 0), + { "MSAA", "Desktop", "Display", "MSAA", SK_ENUM, &g_msaaSamples, 0, "4", SF_STRICT, + ENUM(kMsaaVals, kMsaaLbl, NULL), 0, 0, 0, NULL, ApplyMsaa }, + { "Vsync", "Desktop", "Display", "VSync", SK_ENUM, &g_vsyncMode, 0, "0", 0, + ENUM(kVsyncVals, kVsyncLbl, NULL), 0, 2, 0, NULL, ApplyVsync }, + S_BOOL("MenuBarAutoHide", "Desktop", "General", "Auto-hide Menu Bar", g_menuBarAutoHide, "1", 0), + S_BOOL("PadMode", "Desktop", "General", "Controller Tools", g_padModeEnabled, "1", SF_HIDDEN), + S_BOOL("WelcomeDone", "Desktop", "General", "Welcome Shown", g_welcomeDone, "0", SF_HIDDEN), + { "EmulateXboxRes", "Desktop", "Display", "Emulate Xbox Resolution", SK_BOOL, &g_bEmulateXboxRes, 0, "1", 0, + NOENUM, 0, 0, 0, NULL, ApplyProjection }, + { "FpsCap", "Desktop", "Display", "FPS Cap", SK_ENUM, &g_fpsCap, 0, "0", 0, + ENUM(kFpsVals, kFpsLbl, NULL), 0, 100000, 0, NULL, NULL }, + S_BOOL("Hwdec", "Desktop", "Display", "Hardware Video Decode", g_hwdec, "0", 0), + { "MasterVolume", "Desktop", "Audio", "Master Volume", SK_FLOAT, &g_masterVolume, 0, "1.000", + SF_PERCENT | SF_CLAMP, NOENUM, 0.0f, 1.0f, 0.05f, NULL, ApplyVolume }, + S_BOOL("UseMilkdropViz", "Desktop", "Audio", "projectM Visualizer", g_useMilkdropViz, "0", 0), + S_BOOL("ShowAlbumCover", "Desktop", "Audio", "Album Cover in Orb", g_showAlbumCover, "0", 0), + { "Renderer", "Desktop", "Display", "Renderer", SK_ENUM, &g_rendererPref, 0, "auto", SF_RESTART, + ENUM(kRendVals, kRendLbl, kRendTok), 0, 0, 0, NULL, NULL }, + { "Resolution", "Desktop", "Display", "Resolution", SK_ENUM, &g_windowResolution, 0, "720", SF_STRICT, + ENUM(kResVals, kResLbl, NULL), 0, 0, 0, NULL, ApplyDisplay }, + { "DisplayMode", "Desktop", "Display", "Display Mode", SK_ENUM, &g_windowMode, 0, "0", SF_STRICT, + ENUM(kModeVals, kModeLbl, NULL), 0, 0, 0, NULL, ApplyDisplay }, + + { "GuideTheme", "Desktop", "Display", "Guide Color", SK_ENUM, &g_guideTheme, 0, "green", SF_STRICT, + ENUM(kThemeVals, kThemeLbl, kThemeTok), 0, 0, 0, NULL, NULL }, + + S_BOOL("CRT_Enabled", "CRT", "Display", "CRT Effect", g_crt.enabled, "0", 0), + S_CRT("CRT_Scanlines", "Scanlines", scanlineIntensity, "0.500", 0.0f, 1.0f), + S_CRT("CRT_Curvature", "Curvature", curvature, "0.400", 0.0f, 2.0f), + S_CRT("CRT_Phosphor", "Phosphor Mask", phosphorMask, "0.300", 0.0f, 1.0f), + S_CRT("CRT_Vignette", "Vignette", vignette, "0.300", 0.0f, 2.0f), + S_CRT("CRT_Bloom", "Bloom", bloom, "0.150", 0.0f, 1.0f), + S_CRT("CRT_Flicker", "Flicker", flickerAmount, "0.200", 0.0f, 1.0f), + S_CRT("CRT_ColorBleed", "Color Bleed", colorBleed, "1.000", 0.0f, 4.0f), + S_CRT("CRT_Brightness", "Brightness", brightness, "1.050", 0.8f, 1.3f), + + S_FOLDER("MusicRoot", "Library", "Media", "Music Folder", g_musicRoot, ApplyMusic), + S_FOLDER("MoviesRoot", "Library", "Media", "Movies Folder", g_moviesRoot, ApplyLibrary), + S_FOLDER("TvRoot", "Library", "Media", "TV Shows Folder", g_tvRoot, ApplyLibrary), + S_STR("TMDBKey", "Library", "Media", "TMDB API Key", g_tmdbKey, "", SF_SECRET), + S_PATH("RomsDir", "Library", "Games", "ROMs Folder", g_romsDir, 0), + S_STR("PlexToken", "Library", "Media", "Plex Token", g_plexToken, "", SF_SECRET | SF_HIDDEN), + S_STR("PlexClientId", "Library", "Media", "Plex Client", g_plexClientId, "", SF_SECRET | SF_HIDDEN), + S_STR("XcloudRefreshToken", "Library", "Media", "Xbox Sign-in", g_xcloudRefreshToken, "", SF_SECRET | SF_HIDDEN), + S_STR("JellyfinUrl", "Library", "Media", "Jellyfin Server", g_jellyfinUrl, "", SF_HIDDEN), + S_STR("JellyfinToken", "Library", "Media", "Jellyfin Token", g_jellyfinToken, "", SF_SECRET | SF_HIDDEN), + S_STR("JellyfinUserId", "Library", "Media", "Jellyfin User", g_jellyfinUserId, "", SF_SECRET | SF_HIDDEN), + S_STR("JellyfinClientId", "Library", "Media", "Jellyfin Client", g_jellyfinClientId, "", SF_SECRET | SF_HIDDEN), + S_STR("JellyfinUserName", "Library", "Media", "Jellyfin Name", g_jellyfinUserName, "", SF_HIDDEN), + + S_STR("Use 720p", "Progressive", "Display", "720p", s_use720p, "Yes", SF_SHARED | SF_HIDDEN), + S_STR("Use Progressive", "Progressive", "Display", "Progressive", s_useProgressive, "Yes", SF_SHARED | SF_HIDDEN), + S_STR("Current Skin", "Dashboard Settings", "General", "Skin", g_currentSkin, "Stock", SF_SHARED | SF_HIDDEN), +}; + +const SettingsTable& DesktopSettings_Table() +{ + static const SettingsTable t = { kDefs, (int)(sizeof(kDefs) / sizeof(kDefs[0])) }; + return t; +} + +const SettingDef* DesktopSettings_Find(const char* key) +{ + const SettingsTable& t = DesktopSettings_Table(); + for (int i = 0; i < t.count; i++) + if (strcmp(t.defs[i].key, key) == 0) return &t.defs[i]; + return NULL; +} + +void LoadDesktopSettings() +{ + Settings_Load(DesktopSettings_Table(), AppPath("Configs/desktop.ini")); + ApplyStartupMode(); + TheseusSetProjectionDirty(); +} + +void SaveDesktopSettings() +{ + Plat_MkdirP(AppPath_Tree("Configs")); + Settings_Save(DesktopSettings_Table(), AppPath("Configs/desktop.ini")); +} diff --git a/theseus/desktop/guide.cpp b/theseus/desktop/guide.cpp new file mode 100644 index 0000000..c0a5971 --- /dev/null +++ b/theseus/desktop/guide.cpp @@ -0,0 +1,1509 @@ +// guide.cpp: the guide. One panel over the dashboard, drawn natively with +// the overlay (ui_overlay.h): sections down the left, the open one's rows on +// the right, the prompts along the bottom. The dashboard keeps drawing +// underneath but gets no input while it's up. +// +// Sections: Settings (pages from the settings table, LB and RB switch +// them), Accounts, Games & Playlists, Logs & Help, Shortcuts, About, +// Desktop Tools (the ImGui windows) and Exit. B backs out a level, to the +// sections, then closes. On a first run, or with Startup set to ask, it +// opens on the Welcome screen instead, which offers and never insists. +// Folders, keys and names are typed on its own keyboard, or straight in. + +#include "std.h" +#include "dashapp.h" +#include "panel_shared.h" +#include "settings_schema.h" +#include "host.h" +#include "app_paths.h" +#include "display.h" +#include "preloader.h" +#include "log_redact.h" +#include "virtual_games.h" +#include "playlist.h" +#include "esde_vgames.h" +#include "udata_synth.h" +#include "plex_client.h" +#include "jellyfin_client.h" +#include "xcloud_client.h" +#include "guide.h" +#include "ui_overlay.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include "imgui.h" +#include +#include + +class CLevel; +extern CLevel* g_pCurLevel; +extern bool g_padModeEnabled, g_padModeActive; +extern bool g_mediaFullscreen, g_xcloudFullscreen; +extern void* g_pActiveKeyboard_void(); +extern int g_guideTheme; + +bool g_guideRequested = false; +bool g_welcomeDone = false; + +extern int g_argc; +extern char** g_argv; + +// Starts this program over with one mode flag in place of any other. +static void RestartWithMode(const char* flag) +{ + std::vector args; + args.push_back(g_argv[0]); + for (int i = 1; i < g_argc; i++) { + if (!strcmp(g_argv[i], "--dashboard") || !strcmp(g_argv[i], "--development") || + !strcmp(g_argv[i], "--preview")) continue; + args.push_back(g_argv[i]); + } + args.push_back((char*)flag); + args.push_back(NULL); + execv(g_argv[0], args.data()); + fprintf(stderr, "[Guide] restart failed: %s\n", strerror(errno)); +} + +static const char* ShippedVersion() +{ + static char s_ver[64] = ""; + if (s_ver[0]) return s_ver; + char path[1024]; + snprintf(path, sizeof(path), "%s/Configs/version", Plat_ShippedDir()); + FILE* vf = fopen(path, "r"); + if (vf) { + char line[128]; + while (fgets(line, sizeof(line), vf)) { + if (strncmp(line, "version=", 8) == 0) { + line[strcspn(line, "\r\n")] = 0; + snprintf(s_ver, sizeof(s_ver), "%s", line + 8); + break; + } + } + fclose(vf); + } + if (!s_ver[0]) snprintf(s_ver, sizeof(s_ver), "unknown"); + return s_ver; +} + +static std::string NameOnly(const std::string& path) +{ + size_t slash = path.find_last_of("/\\"); + return slash == std::string::npos ? path : path.substr(slash + 1); +} + +static std::string VersionLine() +{ + char line[256]; + const bgfx::Caps* caps = bgfx::getCaps(); + snprintf(line, sizeof(line), "UIX Desktop %s (%s, %s)", ShippedVersion(), Host_Name(), + caps ? bgfx::getRendererName(caps->rendererType) : "no renderer"); + return line; +} + +static const char* HomeDir() +{ + const char* h = getenv("HOME"); + if (!h || !*h) h = getenv("USERPROFILE"); + return h; +} + +// This run's log, home folder as ~ and addresses masked, onto the clipboard. +static bool CopyLog() +{ + char path[1024]; + snprintf(path, sizeof(path), "%s/theseus.log", Plat_UserDataDir()); + FILE* f = fopen(path, "rb"); + if (!f) return false; + // The tail is what matters, and a clipboard is no place for megabytes. + const long kMax = 512 * 1024; + fseek(f, 0, SEEK_END); + long size = ftell(f); + long from = size > kMax ? size - kMax : 0; + fseek(f, from, SEEK_SET); + std::string text((size_t)(size - from), '\0'); + size_t got = fread(&text[0], 1, text.size(), f); + fclose(f); + text.resize(got); + std::string out = VersionLine() + "\n\n" + Log_Redact(text, HomeDir()); + return SDL_SetClipboardText(out.c_str()) == 0; +} + +int TitleMaker_ImportSteam(int* outSkipped); +int TitleMaker_ImportRetroArch(int* outSkipped); +extern int Title_SanitizeName(const char*, char*, size_t); +extern char s_esdePath[512]; + +static const char* kCategories[] = { "Games", "Applications", "Homebrew", "Emulators", "Dashboards" }; +static const int kCategoryCount = (int)(sizeof(kCategories) / sizeof(kCategories[0])); + +// A file browser entry. macOS apps are folders, but you pick them like files. +struct BrowseEntry { std::string name; bool dir; }; + +static bool IsAppBundle(const std::string& n) +{ + return n.size() > 4 && strcasecmp(n.c_str() + n.size() - 4, ".app") == 0; +} + +static void ListDir(const std::string& dir, std::vector& out) +{ + out.clear(); + HostDir* d = Host_OpenDir(dir.c_str()); + if (!d) return; + char name[512]; + bool isDir = false; + while (out.size() < 4000 && Host_ReadDirType(d, name, sizeof(name), &isDir)) { + if (name[0] == '.') continue; + BrowseEntry e = { name, isDir && !IsAppBundle(name) }; + out.push_back(e); + } + Host_CloseDir(d); + std::sort(out.begin(), out.end(), [](const BrowseEntry& a, const BrowseEntry& b) { + if (a.dir != b.dir) return a.dir; + return strcasecmp(a.name.c_str(), b.name.c_str()) < 0; + }); +} + +static std::string ParentDir(const std::string& dir) +{ + size_t end = dir.size(); + while (end > 1 && (dir[end - 1] == '/' || dir[end - 1] == '\\')) end--; + size_t cut = dir.find_last_of("/\\", end - 1); + if (cut == std::string::npos) return dir; + if (cut == 0) return "/"; + if (cut == 2 && dir[1] == ':') return dir.substr(0, 3); + return dir.substr(0, cut); +} + +// "Some Game.app" or "/x/y/Game.exe" -> "Some Game" / "Game", made safe for +// the dashboard's font. +static std::string NameFromPath(const std::string& path) +{ + size_t slash = path.find_last_of("/\\"); + std::string base = slash == std::string::npos ? path : path.substr(slash + 1); + size_t dot = base.find_last_of('.'); + if (dot != std::string::npos && dot > 0) base = base.substr(0, dot); + char clean[128]; + Title_SanitizeName(base.c_str(), clean, sizeof(clean)); + return clean; +} + +// Somewhere to put things on a fresh install: Movies, TV Shows and ROMs next +// to the bundled Music folder. Only fills roots that are still empty. +static void SetUpFolders() +{ + extern char g_musicRoot[512], g_moviesRoot[512], g_tvRoot[512], g_romsDir[512]; + struct { char* root; size_t cch; const char* name; } kFolders[] = { + { g_musicRoot, sizeof(g_musicRoot), "Music" }, + { g_moviesRoot, sizeof(g_moviesRoot), "Movies" }, + { g_tvRoot, sizeof(g_tvRoot), "TV Shows" }, + { g_romsDir, sizeof(g_romsDir), "ROMs" }, + }; + for (size_t i = 0; i < sizeof(kFolders) / sizeof(kFolders[0]); i++) { + char dir[1024]; + snprintf(dir, sizeof(dir), "%s/%s", AppPath_Tree("Library"), kFolders[i].name); + Host_MakeDir(dir); + if (!kFolders[i].root[0]) snprintf(kFolders[i].root, kFolders[i].cch, "%s", dir); + } + SaveDesktopSettings(); +} + +// A few rows need more than their label says. +static const char* SettingHelp(const SettingDef& d) +{ + static const struct { const char* key; const char* help; } kHelp[] = { + { "StartupMode", "What happens when UIX Desktop starts." }, + { "UseOnScreenKeyboard", "Text fields ignore the physical keyboard and use the pad keyboard." }, + { "ShowBootAnimation", "Play the Xbox boot animation once at startup." }, + { "MenuBarAutoHide", "In Development Mode the tool bar shows when the pointer reaches the top." }, + { "EmulateXboxRes", "Keep scenes framed the way they were authored." }, + { "Hwdec", "Faster on AMD and NVIDIA. Applies to the next video." }, + { "MasterVolume", "Dashboard sounds, music and videos." }, + { "UseMilkdropViz", "Use projectM for the music visualizer." }, + { "ShowAlbumCover", "Show album art in the orb when a soundtrack has it." }, + { "CRT_Enabled", "Old TV look over the whole dashboard." }, + { "GuideTheme", "The guide's colors." }, + }; + for (size_t i = 0; i < sizeof(kHelp) / sizeof(kHelp[0]); i++) + if (strcmp(kHelp[i].key, d.key) == 0) return kHelp[i].help; + if (d.flags & SF_RESTART) return "Takes effect the next time UIX Desktop starts."; + if (d.kind == SK_PATH) return "Select it to type or paste the full path."; + if (d.flags & SF_SECRET) return "Select it to type or paste it. It stays hidden."; + if (d.kind == SK_STRING) return "Select it to type or paste it."; + return "Left and right change it."; +} + +static const char* kPages[] = { "General", "Display", "Audio", "Media", "Games" }; +static const int kPageCount = (int)(sizeof(kPages) / sizeof(kPages[0])); + +enum Section { SEC_SETTINGS, SEC_ACCOUNTS, SEC_LIBRARY, SEC_LOGS, SEC_SHORTCUTS, SEC_ABOUT, SEC_TOOLS, SEC_EXIT, + SEC_COUNT }; +static const char* kSections[SEC_COUNT] = { + "Settings", "Accounts", "Games & Playlists", "Logs & Help", "Shortcuts", "About", "Desktop Tools", "Exit", +}; + +enum View { GV_PAGE, GV_ACCOUNTS, GV_LIBRARY, GV_ADDGAME, GV_BROWSE, GV_PLAYLISTS, GV_PLAYLIST, GV_LOGS, + GV_SHORTCUTS, GV_ABOUT, GV_TOOLS, GV_EXIT, GV_WELCOME }; + +static const View kSectionViews[SEC_COUNT] = { + GV_PAGE, GV_ACCOUNTS, GV_LIBRARY, GV_LOGS, GV_SHORTCUTS, GV_ABOUT, GV_TOOLS, GV_EXIT, +}; + +enum Act { + GA_NONE, GA_VIEW, GA_SETTING, GA_PADTOOLS, GA_WINDOW, GA_RESUME, GA_QUIT, + GA_DEVMODE, GA_FOLDERS, GA_OPENFOLDERS, + GA_OPENLOGS, GA_COPYVERSION, GA_COPYLOG, + GA_STEAM, GA_RETROARCH, GA_ESDE, GA_CHOOSEFILE, GA_CATEGORY, GA_ADDGAME, + GA_BROWSEUP, GA_BROWSE, GA_PLAYLIST, + GA_PLEX, GA_PLEXOUT, GA_JELLYFIN, GA_JELLYFINOUT, GA_XBOX, GA_XBOXOUT, + GA_TEXT, GA_NAME, GA_JELLYFINURL, + GA_COUNT +}; + +enum Window { WIN_TITLEMAKER, WIN_HDD, WIN_PLAYLISTS, WIN_SETTINGS, WIN_INSPECTOR, WIN_XAPEDITOR }; + +struct Row { + std::string label, value, help; + int act; + int arg; // view to open, setting index, window... +}; + +// What the pointer can land on. +enum Target { T_SECTION, T_ROW, T_TAB, T_PROMPT, T_KEY }; +struct HitTarget { float x0, y0, x1, y1; int kind, index; }; + +// The panel's place in the 640x480 layout. +enum { + PANEL_X = 40, PANEL_Y = 30, PANEL_W = 560, PANEL_H = 420, + PANEL_R = PANEL_X + PANEL_W, PANEL_B = PANEL_Y + PANEL_H, + INSET = 16, + CONTENT_L = PANEL_X + INSET, CONTENT_R = PANEL_R - INSET, + HEADER_Y = PANEL_Y + 12, RULE_Y = PANEL_Y + 46, + BODY_Y = PANEL_Y + 58, + PROMPTS_Y = PANEL_B - 28, BODY_B = PROMPTS_Y - 30, + SECTIONS_W = 152, SECTION_H = 26, SECTION_GAP = 4, + LIST_L = CONTENT_L + SECTIONS_W + 14, + ROW_H = 24, ROW_GAP = 4, + REPEAT_DELAY = 400, REPEAT_INTERVAL = 110, + FIELD_Y = BODY_Y + 4, FIELD_H = 32, KEYS_Y = BODY_Y + 50, KEY_H = 34, KEY_GAP = 5, +}; + +// The keyboard: four rows of ten characters, then the wide keys. +enum { KEY_CHARS = 40, K_SHIFT = KEY_CHARS, K_SYMBOLS, K_SPACE, K_DELETE, K_PASTE, K_DONE, KEY_COUNT }; +static const char* const kKeyRows[3][4] = { + { "1234567890", "qwertyuiop", "asdfghjkl:", "zxcvbnm./-" }, + { "1234567890", "QWERTYUIOP", "ASDFGHJKL:", "ZXCVBNM./-" }, + { "1234567890", "!@#$%^&*()", "~`_=+[]{}\\", "|;'\",<>?/:" }, +}; +// The wide keys' left edges and widths, in keys. +static const float kWideKeys[KEY_COUNT - KEY_CHARS][2] = { + { 0, 1.5f }, { 1.5f, 1.5f }, { 3, 3 }, { 6, 1.5f }, { 7.5f, 1.25f }, { 8.75f, 1.25f }, +}; + +enum EditTarget { ED_SETTING, ED_NAME, ED_JELLYFIN }; + +struct Editor { + bool on, secret, shift, symbols; + int what, arg, key; + std::string title, text; + size_t caret, limit; // bytes + Uint32 since; +}; + +static struct Guide { + bool open, welcome, inPanel, startChecked, swallow, foldersDone, settle; + int section, page, cursor, scroll; + View view; + struct Frame { View view; int cursor, scroll; }; + std::vector stack; + std::vector rows; + + unsigned held, repeatHeld; + Uint32 repeatTime; + int device; + bool pointerMoved, clickMoved; + float pointerX, pointerY, wheel; + + int copied; + std::string result[GA_COUNT]; + std::string browseDir, pickedPath, pickedName, added; + std::vector entries; + bool browseStale; + int category, playlist; + + std::vector targets; + int promptButtons[6]; + Editor edit; +} g; + +static void Add(const char* label, const char* value, const char* help, int act = GA_NONE, int arg = 0) +{ + Row r; + r.label = label; r.value = value ? value : ""; r.help = help ? help : ""; + r.act = act; r.arg = arg; + g.rows.push_back(r); +} + +static void Build() +{ + g.rows.clear(); + switch (g.view) { + case GV_PAGE: { + const SettingsTable& t = DesktopSettings_Table(); + char val[512]; + for (int i = 0; i < t.count; i++) { + const SettingDef& d = t.defs[i]; + if (d.flags & SF_HIDDEN) continue; + if (strcmp(d.page, kPages[g.page]) != 0) continue; + Setting_Display(d, val, sizeof(val)); + bool typed = d.kind == SK_PATH || d.kind == SK_STRING; + Add(d.label, val, SettingHelp(d), typed ? GA_TEXT : GA_SETTING, i); + } + break; + } + case GV_ABOUT: + Add("Version", ShippedVersion(), "The build you're running."); + Add("Platform", Host_Name(), NULL); + Add("Created by", "Milenko", NULL); + Add("UIX Lite references", "BigJx, ImOkRuOk", NULL); + Add("", "Odb718, Rocky5, TeamUIX", NULL); + Add("PrometheOS, Toolbox", "Team-Resurgent", NULL); + Add("Original dashboard", "Microsoft", NULL); + Add("Community", "Xbox-Scene", NULL); + Add("Guide font", "Noto Sans, SIL OFL 1.1", NULL); + Add("And everyone who contributed", "", NULL); + break; + case GV_WELCOME: { + const char* intro = g_welcomeDone ? "A or B starts the dashboard." + : "Nothing here is required. It's all in the guide later too."; + Add("Start the Dashboard", "", intro, GA_RESUME); + Add("Development Mode", "", "Edit the scenes live. Restarts UIX Desktop.", GA_DEVMODE); + if (!g_welcomeDone || g.foldersDone) + Add("Set Up My Folders", g.foldersDone ? "Done" : "", + "Makes Music, Movies, TV Shows and ROMs folders. Fill them when you like.", GA_FOLDERS); + Add("Open My Folders", "", "Shows them in your file manager.", GA_OPENFOLDERS); + if (const SettingDef* mode = DesktopSettings_Find("StartupMode")) { + char val[64]; + Setting_Display(*mode, val, sizeof(val)); + Add(mode->label, val, "Ask on launch brings this screen back each time.", GA_SETTING, + (int)(mode - DesktopSettings_Table().defs)); + } + break; + } + case GV_LIBRARY: { + Add("Add a Game", "", "Pick a program or file and it shows up in the launcher.", GA_VIEW, GV_ADDGAME); + Add("Import Steam Library", g.result[GA_STEAM].c_str(), "Adds installed Steam games with their icons.", GA_STEAM); + Add("Import RetroArch Recent", g.result[GA_RETROARCH].c_str(), "Adds what RetroArch's playlists list.", GA_RETROARCH); + Add("Refresh ES-DE", g.result[GA_ESDE].c_str(), "Reads your ES-DE game lists again.", GA_ESDE); + char n[32]; + snprintf(n, sizeof(n), "%d", Playlist_Count()); + Add("Playlists", n, "Made in the Playlist Maker.", GA_VIEW, GV_PLAYLISTS); + break; + } + case GV_ADDGAME: { + std::string file = g.pickedPath.empty() ? "Choose" : NameOnly(g.pickedPath); + Add("File", file.c_str(), g.pickedPath.empty() ? "A program, a shortcut or a ROM." : g.pickedPath.c_str(), + GA_CHOOSEFILE); + Add("Name", g.pickedName.c_str(), + g.pickedPath.empty() ? "Taken from the file name." : "Taken from the file name. Select it to rename it.", + g.pickedPath.empty() ? GA_NONE : GA_NAME); + Add("Category", kCategories[g.category], "Left and right change it.", GA_CATEGORY); + Add("Add to Launcher", g.added.c_str(), + g.pickedPath.empty() ? "Choose a file first." : "Adds it under the category above.", GA_ADDGAME); + break; + } + case GV_BROWSE: + if (g.browseStale) { ListDir(g.browseDir, g.entries); g.browseStale = false; } + Add("Up a Folder", "", g.browseDir.c_str(), GA_BROWSEUP); + for (size_t i = 0; i < g.entries.size(); i++) + Add(g.entries[i].name.c_str(), g.entries[i].dir ? "Folder" : "", g.browseDir.c_str(), GA_BROWSE, (int)i); + break; + case GV_PLAYLISTS: + for (int i = 0; i < Playlist_Count(); i++) { + const Playlist* pl = Playlist_Get(i); + if (!pl) continue; + char n[32]; + snprintf(n, sizeof(n), "%d", (int)pl->items.size()); + Add(pl->name.c_str(), n, "Edit playlists in the Playlist Maker (F6).", GA_PLAYLIST, i); + } + if (g.rows.empty()) Add("No playlists yet", "", "Make one in the Playlist Maker (F6)."); + break; + case GV_PLAYLIST: { + const Playlist* pl = Playlist_Get(g.playlist); + if (pl) + for (size_t i = 0; i < pl->items.size(); i++) + Add(pl->items[i].title.empty() ? NameOnly(pl->items[i].path).c_str() : pl->items[i].title.c_str(), + "", NULL); + if (g.rows.empty()) Add("Empty", "", NULL); + break; + } + case GV_ACCOUNTS: { + std::string help; + if (Plex_HasToken()) { + Add("Plex", "Signed in", NULL); + Add("Sign Out of Plex", "", NULL, GA_PLEXOUT); + } else if (Plex_PinAuthInFlight()) { + std::string code = Plex_GetPinCode(); + Add("Plex", code.empty() ? "Asking..." : code.c_str(), + "On any device go to plex.tv/link and enter the code. A cancels.", GA_PLEX); + } else { + Add("Plex", "Sign In", "Gives you a code to enter at plex.tv/link.", GA_PLEX); + } + + if (Jellyfin_HasToken()) { + help = "Signed in as " + Jellyfin_GetUserName() + "."; + Add("Jellyfin", "Signed in", help.c_str()); + Add("Sign Out of Jellyfin", "", NULL, GA_JELLYFINOUT); + } else { + std::string url = Jellyfin_GetServerUrl(); + Add("Jellyfin Server", url.empty() ? "Not set" : url.c_str(), + "Your server's address, like 192.168.1.20:8096.", GA_JELLYFINURL); + if (!url.empty() && Jellyfin_QuickConnectInFlight()) { + std::string code = Jellyfin_GetQuickConnectCode(); + Add("Jellyfin", code.empty() ? "Asking..." : code.c_str(), + "In Jellyfin's web page open your account, then Quick Connect, and enter the code.", GA_JELLYFIN); + } else if (!url.empty()) { + Add("Jellyfin", "Sign In", "Quick Connect gives you a code to approve on the server.", GA_JELLYFIN); + } + } + + if (Xcloud_HasToken()) { + std::string gt = Xcloud_GetGamertag(); + Add("Xbox", gt.empty() ? "Signed in" : gt.c_str(), NULL); + Add("Sign Out of Xbox", "", NULL, GA_XBOXOUT); + } else if (Xcloud_LoginInFlight()) { + std::string code = Xcloud_GetUserCode(); + help = "Go to " + Xcloud_GetVerificationUri() + " and enter the code. A cancels."; + Add("Xbox", code.empty() ? "Asking..." : code.c_str(), help.c_str(), GA_XBOX); + } else { + Add("Xbox", "Sign In", "For cloud gaming and your consoles.", GA_XBOX); + } + break; + } + case GV_LOGS: + Add("Copy Version Info", g.copied == GA_COPYVERSION ? "Copied" : "", + "Version, platform and renderer, ready to paste.", GA_COPYVERSION); + Add("Copy Log", g.copied == GA_COPYLOG ? "Copied" : "", + "This run's log. Your home folder shows as ~ and IP addresses are hidden.", GA_COPYLOG); + Add("Open Logs Folder", "", "theseus.log is this run, theseus.prev.log the one before.", GA_OPENLOGS); + Add("Report a Problem", "", "Paste the version and the log into an issue on GitHub."); + break; + case GV_SHORTCUTS: + Add("Guide button, LT+B or F7", "Guide", "Opens and closes this guide."); + Add("F3", "Title Maker", NULL); + Add("F4", "Settings Window", NULL); + Add("F5", "HDD Browser", NULL); + Add("F6", "Playlist Maker", NULL); + Add("F11", "Fullscreen", NULL); + Add("F12", "Borderless Window", NULL); + Add("Ctrl+M", "Mute", NULL); + Add("Ctrl+R", "Restart Dashboard", NULL); + Add("Ctrl+Q", "Quit", NULL); + if (g_startupMode == 2 || g_extractedMode) { + Add("F1", "Inspector", "Development mode."); + Add("F2", "XAP Editor", "Development mode."); + Add("F10", "Hide Menu Bar", "Development mode."); + } + break; + case GV_TOOLS: + Add("Title Maker", "F3", "Names, icons and categories for the launcher.", GA_WINDOW, WIN_TITLEMAKER); + Add("Settings Window", "F4", "Every setting, in a desktop window.", GA_WINDOW, WIN_SETTINGS); + Add("HDD Browser", "F5", "Look through the dashboard's drives.", GA_WINDOW, WIN_HDD); + Add("Playlist Maker", "F6", "Make and edit playlists.", GA_WINDOW, WIN_PLAYLISTS); + if (g_padModeEnabled) + Add("Use the Tools with the Pad", "", "The tool windows' own controller menu.", GA_PADTOOLS); + if (g_startupMode == 2 || g_extractedMode) { + Add("Inspector", "F1", "Development mode.", GA_WINDOW, WIN_INSPECTOR); + Add("XAP Editor", "F2", "Development mode.", GA_WINDOW, WIN_XAPEDITOR); + } + break; + case GV_EXIT: + Add("Back to the Dashboard", "", "Closes the guide.", GA_RESUME); + Add("Exit UIX Desktop", "", "Quit to your desktop.", GA_QUIT); + break; + } + if (g.cursor >= (int)g.rows.size()) g.cursor = g.rows.empty() ? 0 : (int)g.rows.size() - 1; + if (g.cursor < 0) g.cursor = 0; +} + +static const char* ViewTitle() +{ + switch (g.view) { + case GV_ADDGAME: return "Add a Game"; + case GV_BROWSE: return "Choose a File"; + case GV_PLAYLISTS: return "Playlists"; + case GV_PLAYLIST: { + const Playlist* pl = Playlist_Get(g.playlist); + return pl ? pl->name.c_str() : "Playlist"; + } + case GV_WELCOME: return g_welcomeDone ? "Start" : "Welcome"; + default: return kSections[g.section]; + } +} + +// Opening and closing + +static void ShowSection(int section) +{ + g.section = section; + g.view = kSectionViews[section]; + g.stack.clear(); + g.cursor = g.scroll = 0; + Build(); +} + +static void Open(bool welcome) +{ + g.open = true; + g.welcome = welcome; + g.inPanel = welcome; + g.copied = GA_NONE; + // What's held as it opens (the B of LT+B) isn't a press. + g.held = Joy_GuideButtons(&g.device); + g.repeatHeld = 0; + g_padModeActive = false; + if (welcome) { + g.view = GV_WELCOME; + g.stack.clear(); + g.cursor = g.scroll = 0; + Build(); + } else { + ShowSection(g.section); + } +} + +static void Close() +{ + if (g.welcome && !g_welcomeDone) { + g_welcomeDone = true; + SaveDesktopSettings(); + } + g.open = false; + g.welcome = false; + g.edit.on = false; + g.swallow = true; +} + +bool Guide_IsOpen() { return g.open; } + +bool Guide_TakesInput() { return g.open || g.swallow; } + +// Acting + +static void Push(View v) +{ + Guide::Frame f = { g.view, g.cursor, g.scroll }; + g.stack.push_back(f); + g.view = v; + g.cursor = g.scroll = 0; + Build(); +} + +static bool Back() +{ + if (g.stack.empty()) return false; + Guide::Frame f = g.stack.back(); + g.stack.pop_back(); + g.view = f.view; + g.cursor = f.cursor; + g.scroll = f.scroll; + Build(); + return true; +} + +static void OpenWindow(int which) +{ + switch (which) { + case WIN_TITLEMAKER: g_titleMakerOpen = true; break; + case WIN_HDD: g_hddBrowserOpen = true; break; + case WIN_PLAYLISTS: { + extern bool g_playlistMakerOpen; + g_playlistMakerOpen = true; + break; + } + case WIN_SETTINGS: { + extern bool g_settingsOpen; + g_settingsOpen = true; + break; + } + case WIN_INSPECTOR: g_inspectorOpen = true; break; + case WIN_XAPEDITOR: g_xapEditorOpen = true; break; + } +} + +// Typing + +static size_t PrevChar(const std::string& t, size_t i) +{ + if (i == 0) return 0; + i--; + while (i > 0 && ((unsigned char)t[i] & 0xC0) == 0x80) i--; + return i; +} + +static size_t NextChar(const std::string& t, size_t i) +{ + if (i >= t.size()) return t.size(); + i++; + while (i < t.size() && ((unsigned char)t[i] & 0xC0) == 0x80) i++; + return i; +} + +bool Guide_Typing() { return g.open && g.edit.on; } + +static void EditOpen(int what, int arg, const char* title, const std::string& text, size_t limit, bool secret) +{ + Editor& e = g.edit; + e.on = true; + e.what = what; + e.arg = arg; + e.title = title; + e.text = text.size() > limit ? text.substr(0, limit) : text; + e.caret = e.text.size(); + e.limit = limit; + e.secret = secret; + e.shift = e.symbols = false; + e.key = 10; + e.since = SDL_GetTicks(); + SDL_StartTextInput(); +} + +static void EditClose() +{ + g.edit.on = false; + // Whatever closed it is still held; it isn't a press on the row. + g.settle = true; + Build(); +} + +// Trimmed, and a path copied from a file manager loses its quotes. +static std::string Tidy(std::string s, bool path) +{ + size_t a = s.find_first_not_of(" \t"), b = s.find_last_not_of(" \t"); + s = a == std::string::npos ? "" : s.substr(a, b - a + 1); + if (path && s.size() >= 2 && s[0] == '"' && s[s.size() - 1] == '"') s = s.substr(1, s.size() - 2); + return s; +} + +static void EditDone() +{ + const Editor& e = g.edit; + switch (e.what) { + case ED_SETTING: { + const SettingDef& d = DesktopSettings_Table().defs[e.arg]; + if (Setting_SetText(d, Tidy(e.text, d.kind == SK_PATH).c_str())) { + if (d.apply) d.apply(); + SaveDesktopSettings(); + } + break; + } + case ED_NAME: { + char clean[128]; + Title_SanitizeName(Tidy(e.text, false).c_str(), clean, sizeof(clean)); + if (clean[0]) { g.pickedName = clean; g.added.clear(); } + break; + } + case ED_JELLYFIN: + Jellyfin_SetServerUrl(Tidy(e.text, false)); + break; + } + EditClose(); +} + +static void EditInsert(const char* s) +{ + Editor& e = g.edit; + std::string add; + for (const char* p = s; *p; p++) + if ((unsigned char)*p >= 0x20 && *p != 0x7F) add += *p; + if (add.empty() || e.text.size() + add.size() > e.limit) return; + e.text.insert(e.caret, add); + e.caret += add.size(); +} + +static void EditDelete(bool forward) +{ + Editor& e = g.edit; + size_t from = forward ? e.caret : PrevChar(e.text, e.caret); + size_t to = forward ? NextChar(e.text, e.caret) : e.caret; + e.text.erase(from, to - from); + e.caret = from; +} + +static void EditPaste() +{ + char* clip = SDL_GetClipboardText(); + if (!clip) return; + std::string line = clip; + SDL_free(clip); + EditInsert(line.substr(0, line.find_first_of("\r\n")).c_str()); +} + +static int KeyLayout() { return g.edit.symbols ? 2 : g.edit.shift ? 1 : 0; } + +// A key's row, left edge and width, in keys. +static void KeySpan(int key, int* row, float* x, float* w) +{ + if (key < KEY_CHARS) { *row = key / 10; *x = (float)(key % 10); *w = 1.0f; return; } + *row = 4; + *x = kWideKeys[key - KEY_CHARS][0]; + *w = kWideKeys[key - KEY_CHARS][1]; +} + +static void PressKey(int key) +{ + Editor& e = g.edit; + if (key < KEY_CHARS) { + char c[2] = { kKeyRows[KeyLayout()][key / 10][key % 10], 0 }; + EditInsert(c); + e.shift = false; // for one letter + return; + } + switch (key) { + case K_SHIFT: e.shift = !e.shift; e.symbols = false; break; + case K_SYMBOLS: e.symbols = !e.symbols; e.shift = false; break; + case K_SPACE: EditInsert(" "); break; + case K_DELETE: EditDelete(false); break; + case K_PASTE: EditPaste(); break; + case K_DONE: EditDone(); break; + } +} + +// Sideways wraps in the row; up and down land on the key under this one's middle. +static void MoveKey(int dx, int dy) +{ + Editor& e = g.edit; + int row; + float x, w; + KeySpan(e.key, &row, &x, &w); + if (dx) { + if (row < 4) e.key = row * 10 + (e.key % 10 + dx + 10) % 10; + else e.key = KEY_CHARS + (e.key - KEY_CHARS + dx + KEY_COUNT - KEY_CHARS) % (KEY_COUNT - KEY_CHARS); + return; + } + float mid = x + w * 0.5f; + row = (row + dy + 5) % 5; + if (row < 4) { e.key = row * 10 + std::min(9, (int)mid); return; } + e.key = K_DONE; + for (int k = K_SHIFT; k < KEY_COUNT; k++) + if (mid >= kWideKeys[k - KEY_CHARS][0] && mid < kWideKeys[k - KEY_CHARS][0] + kWideKeys[k - KEY_CHARS][1]) { + e.key = k; + break; + } +} + +// The pad on the keyboard: A types the key, X deletes, Y is a space, the +// bumpers move the cursor, Start saves and B cancels. +static void EditHandle(unsigned pressed) +{ + Editor& e = g.edit; + if (pressed & GUIDE_B) { EditClose(); return; } + if (pressed & GUIDE_START) { EditDone(); return; } + if (pressed & GUIDE_UP) MoveKey(0, -1); + if (pressed & GUIDE_DOWN) MoveKey(0, 1); + if (pressed & GUIDE_LEFT) MoveKey(-1, 0); + if (pressed & GUIDE_RIGHT) MoveKey(1, 0); + if (pressed & GUIDE_LB) e.caret = PrevChar(e.text, e.caret); + if (pressed & GUIDE_RB) e.caret = NextChar(e.text, e.caret); + if (pressed & GUIDE_X) EditDelete(false); + if (pressed & GUIDE_Y) EditInsert(" "); + if (pressed & GUIDE_A) PressKey(e.key); +} + +// A physical keyboard types straight in. +static void EditEvent(const SDL_Event* ev) +{ + Editor& e = g.edit; + // Keys from before it opened belong to whatever opened it. + if (ev->common.timestamp < e.since) return; + g.device = 0; + if (ev->type == SDL_TEXTINPUT) { EditInsert(ev->text.text); return; } + const bool repeat = ev->key.repeat != 0; + switch (ev->key.keysym.sym) { + case SDLK_RETURN: + case SDLK_KP_ENTER: if (!repeat) EditDone(); break; + case SDLK_ESCAPE: if (!repeat) EditClose(); break; + case SDLK_BACKSPACE: EditDelete(false); break; + case SDLK_DELETE: EditDelete(true); break; + case SDLK_LEFT: e.caret = PrevChar(e.text, e.caret); break; + case SDLK_RIGHT: e.caret = NextChar(e.text, e.caret); break; + case SDLK_HOME: e.caret = 0; break; + case SDLK_END: e.caret = e.text.size(); break; + case SDLK_v: + if (ev->key.keysym.mod & (KMOD_CTRL | KMOD_GUI)) EditPaste(); + break; + } +} + +static bool Adjust(int dir) +{ + if (g.cursor < 0 || g.cursor >= (int)g.rows.size()) return false; + const Row& r = g.rows[g.cursor]; + if (r.act == GA_CATEGORY) { + g.category = (g.category + (dir < 0 ? kCategoryCount - 1 : 1)) % kCategoryCount; + Build(); + return true; + } + if (r.act != GA_SETTING) return false; + const SettingDef& d = DesktopSettings_Table().defs[r.arg]; + if (!Setting_Cycle(d, dir < 0 ? -1 : 1)) return false; + if (d.apply) d.apply(); + SaveDesktopSettings(); + Build(); + return true; +} + +static void Select() +{ + if (g.cursor < 0 || g.cursor >= (int)g.rows.size()) return; + const Row r = g.rows[g.cursor]; + switch (r.act) { + case GA_VIEW: + Push((View)r.arg); + break; + case GA_SETTING: + case GA_CATEGORY: + Adjust(1); + break; + case GA_TEXT: { + const SettingDef& d = DesktopSettings_Table().defs[r.arg]; + EditOpen(ED_SETTING, r.arg, d.label, (const char*)d.ptr, (size_t)d.size - 1, (d.flags & SF_SECRET) != 0); + break; + } + case GA_NAME: + EditOpen(ED_NAME, 0, "Name", g.pickedName, 100, false); + break; + case GA_JELLYFINURL: + EditOpen(ED_JELLYFIN, 0, "Jellyfin Server", Jellyfin_GetServerUrl(), 500, false); + break; + case GA_PADTOOLS: + Close(); + g_padModeActive = true; + break; + case GA_WINDOW: + Close(); + OpenWindow(r.arg); + break; + case GA_RESUME: + Close(); + break; + case GA_DEVMODE: + g_welcomeDone = true; + SaveDesktopSettings(); + if (Preloader_EnsureExtracted()) RestartWithMode("--development"); + break; + case GA_FOLDERS: + SetUpFolders(); + g.foldersDone = true; + Build(); + break; + case GA_OPENFOLDERS: + Plat_OpenInFileManager(AppPath_Tree("Library")); + break; + case GA_STEAM: + case GA_RETROARCH: { + int skipped = 0; + int added = r.act == GA_STEAM ? TitleMaker_ImportSteam(&skipped) : TitleMaker_ImportRetroArch(&skipped); + char msg[64]; + if (added < 0) snprintf(msg, sizeof(msg), "Not found"); + else snprintf(msg, sizeof(msg), "%d new, %d already in", added, skipped); + g.result[r.act] = msg; + Build(); + break; + } + case GA_ESDE: + if (!s_esdePath[0]) { + g.result[GA_ESDE] = "Set its folder first"; + } else { + EsdeVGames_Sync(s_esdePath); + g.result[GA_ESDE] = "Refreshed"; + } + Build(); + break; + case GA_CHOOSEFILE: + if (g.browseDir.empty()) { + const char* h = HomeDir(); + g.browseDir = h ? h : "/"; + } + g.browseStale = true; + Push(GV_BROWSE); + break; + case GA_BROWSEUP: + g.browseDir = ParentDir(g.browseDir); + g.browseStale = true; + g.cursor = g.scroll = 0; + Build(); + break; + case GA_BROWSE: { + if (r.arg < 0 || r.arg >= (int)g.entries.size()) break; + const BrowseEntry e = g.entries[r.arg]; + std::string full = g.browseDir; + if (full.empty() || (full[full.size() - 1] != '/' && full[full.size() - 1] != '\\')) full += Host_PathSep; + full += e.name; + if (e.dir) { + g.browseDir = full; + g.browseStale = true; + g.cursor = g.scroll = 0; + Build(); + break; + } + g.pickedPath = full; + g.pickedName = NameFromPath(full); + g.added.clear(); + Back(); + break; + } + case GA_ADDGAME: { + if (g.pickedPath.empty() || g.pickedName.empty()) break; + if (VGames_FindByName(g.pickedName.c_str()) >= 0) { g.added = "Already there"; Build(); break; } + char genID[16]; + snprintf(genID, sizeof(genID), "%08x", (unsigned)time(NULL)); + std::string launch = "\"" + g.pickedPath + "\""; + VGames_Add(g.pickedName.c_str(), genID, launch.c_str(), "E", kCategories[g.category]); + VGames_Save(); + UDataSynth_RebuildAll(); + g.added = "Added"; + g.pickedPath.clear(); + g.pickedName.clear(); + Build(); + break; + } + // Sign in starts a code flow, A again cancels it. + case GA_PLEX: + if (Plex_PinAuthInFlight()) Plex_CancelPinAuth(); else Plex_StartPinAuth(); + Build(); + break; + case GA_JELLYFIN: + if (Jellyfin_QuickConnectInFlight()) Jellyfin_CancelQuickConnect(); else Jellyfin_StartQuickConnect(); + Build(); + break; + case GA_XBOX: + if (Xcloud_LoginInFlight()) Xcloud_CancelLogin(); else Xcloud_StartLogin(); + Build(); + break; + case GA_PLEXOUT: Plex_SignOut(); SaveDesktopSettings(); Build(); break; + case GA_JELLYFINOUT: Jellyfin_SignOut(); SaveDesktopSettings(); Build(); break; + case GA_XBOXOUT: Xcloud_SignOut(); SaveDesktopSettings(); Build(); break; + case GA_PLAYLIST: + g.playlist = r.arg; + Push(GV_PLAYLIST); + break; + case GA_OPENLOGS: + Host_OpenFolder(Plat_UserDataDir()); + break; + case GA_COPYVERSION: + if (SDL_SetClipboardText(VersionLine().c_str()) == 0) g.copied = GA_COPYVERSION; + Build(); + break; + case GA_COPYLOG: + if (CopyLog()) g.copied = GA_COPYLOG; + Build(); + break; + case GA_QUIT: { + SDL_Event quit; + quit.type = SDL_QUIT; + SDL_PushEvent(&quit); + break; + } + } +} + +static void MoveCursor(int step) +{ + int n = (int)g.rows.size(); + if (n == 0) return; + g.cursor += step; + if (g.cursor < 0) g.cursor = 0; + if (g.cursor >= n) g.cursor = n - 1; +} + +static void PageStep(int step) +{ + if (g.view != GV_PAGE) return; + g.page = (g.page + step + kPageCount) % kPageCount; + g.cursor = g.scroll = 0; + Build(); +} + +static void PressB() +{ + if (g.welcome) { Close(); return; } + if (g.inPanel) { + if (!Back()) g.inPanel = false; + return; + } + Close(); +} + +static void Handle(unsigned pressed) +{ + if (g.edit.on) { EditHandle(pressed); return; } + if (pressed & GUIDE_B) { PressB(); return; } + if (pressed & GUIDE_LB) PageStep(-1); + if (pressed & GUIDE_RB) PageStep(1); + if (!g.inPanel) { + if (pressed & GUIDE_UP) ShowSection((g.section + SEC_COUNT - 1) % SEC_COUNT); + if (pressed & GUIDE_DOWN) ShowSection((g.section + 1) % SEC_COUNT); + if (pressed & (GUIDE_A | GUIDE_RIGHT)) g.inPanel = true; + return; + } + if (pressed & GUIDE_UP) MoveCursor(-1); + if (pressed & GUIDE_DOWN) MoveCursor(1); + // Left on a row that doesn't change goes back to the sections. + if ((pressed & GUIDE_LEFT) && !Adjust(-1) && !g.welcome && g.stack.empty()) g.inPanel = false; + if (pressed & GUIDE_RIGHT) Adjust(1); + if (pressed & GUIDE_A) Select(); +} + +// New presses, with held directions repeating. +static unsigned Presses(unsigned held) +{ + unsigned pressed = held & ~g.held; + // Directions repeat, and on the keyboard deleting and the cursor too. + unsigned repeats = GUIDE_UP | GUIDE_DOWN | GUIDE_LEFT | GUIDE_RIGHT; + if (g.edit.on) repeats |= GUIDE_X | GUIDE_LB | GUIDE_RB; + unsigned dirs = held & repeats; + Uint32 now = SDL_GetTicks(); + if (dirs && dirs == g.repeatHeld) { + if (now - g.repeatTime >= REPEAT_DELAY) { + pressed |= dirs; + g.repeatTime = now - REPEAT_DELAY + REPEAT_INTERVAL; + } + } else { + g.repeatHeld = dirs; + g.repeatTime = now; + } + g.held = held; + return pressed; +} + +void Guide_Frame() +{ + unsigned held = Joy_GuideButtons(&g.device); + + if (g.swallow && !held) g.swallow = false; + if (g.settle) { g.held = held; g.settle = false; } + + // First run, or Startup is "Ask": once the dashboard is up, the + // welcome offers the ways in. Neither waits for the scene to settle. + if (!g.startChecked && g_pCurLevel) { + g.startChecked = true; + if (!g_welcomeDone || g_startupMode == 0) { + Open(true); + return; + } + } + + if (g_guideRequested) { + g_guideRequested = false; + // Fullscreen video and a text field keep the buttons for themselves. + if (g.open) Close(); + else if (!g_mediaFullscreen && !g_xcloudFullscreen && !g_pActiveKeyboard_void()) Open(false); + g.held = held; + return; + } + + if (!g.open) return; + // Sign-in codes arrive in the background; the rows keep up with them. + if (g.view == GV_ACCOUNTS) Build(); + unsigned pressed = Presses(held); + if (pressed) Handle(pressed); +} + +// The pointer + +// Where a click left the guide, to tell a double click that would land on +// the next screen's rows from quick clicks on the same row. +static std::string Screen() +{ + char s[64]; + snprintf(s, sizeof(s), "%d/%d/%d/%d/%d/", (int)g.view, (int)g.stack.size(), g.section, g.page, g.edit.on); + return s + g.browseDir; +} + +static void Click(const HitTarget& t) +{ + switch (t.kind) { + case T_SECTION: + ShowSection(t.index); + g.inPanel = false; + break; + case T_ROW: + g.inPanel = true; + g.cursor = t.index; + Select(); + break; + case T_TAB: + g.page = t.index; + g.cursor = g.scroll = 0; + Build(); + break; + case T_PROMPT: + Handle((unsigned)t.index); + break; + case T_KEY: + g.edit.key = t.index; + PressKey(t.index); + break; + } +} + +void Guide_HandleEvent(const SDL_Event* e) +{ + if (!g.open) return; + extern SDL_Window* g_pSDLWindow; + if (!g_pSDLWindow) return; + Uint32 win = SDL_GetWindowID(g_pSDLWindow); + // Events come in window points, the overlay lays out in pixels. + float scale = Plat_GetDisplayScale(); + float x, y; + // A tool window over the guide keeps its own clicks. + const bool imgui = ImGui::GetIO().WantCaptureMouse; + + if (g.edit.on && (e->type == SDL_TEXTINPUT || e->type == SDL_KEYDOWN)) { + if ((e->type == SDL_TEXTINPUT ? e->text.windowID : e->key.windowID) == win) EditEvent(e); + return; + } + + switch (e->type) { + case SDL_MOUSEMOTION: + if (e->motion.windowID != win || imgui) return; + ui_overlay_to_layout((float)e->motion.x * scale, (float)e->motion.y * scale, &x, &y); + g.pointerX = x; + g.pointerY = y; + g.pointerMoved = true; + g.device = 0; + break; + case SDL_MOUSEWHEEL: { + if (e->wheel.windowID != win || imgui) return; + // Whole notches, gathered from a trackpad's fractions too. +#if SDL_VERSION_ATLEAST(2, 0, 18) + g.wheel += e->wheel.preciseY; +#else + g.wheel += (float)e->wheel.y; +#endif + int step = (int)g.wheel; + g.wheel -= (float)step; + if (e->wheel.direction == SDL_MOUSEWHEEL_FLIPPED) step = -step; + for (; step > 0; step--) Handle(GUIDE_UP); + for (; step < 0; step++) Handle(GUIDE_DOWN); + g.device = 0; + break; + } + case SDL_MOUSEBUTTONDOWN: { + if (e->button.windowID != win || imgui) return; + g.device = 0; + if (e->button.button == SDL_BUTTON_RIGHT) { PressB(); return; } + if (e->button.button != SDL_BUTTON_LEFT) return; + // The second half of a double click that opened something. + if (e->button.clicks > 1 && g.clickMoved) return; + ui_overlay_to_layout((float)e->button.x * scale, (float)e->button.y * scale, &x, &y); + for (size_t i = 0; i < g.targets.size(); i++) { + const HitTarget t = g.targets[i]; + if (x < t.x0 || x >= t.x1 || y < t.y0 || y >= t.y1) continue; + std::string before = Screen(); + Click(t); + g.clickMoved = Screen() != before; + // Spans are from the last frame drawn; a moved screen has new ones. + if (g.clickMoved) g.targets.clear(); + return; + } + break; + } + } +} + +// Hovering picks the row or section under the pointer. +static void Hover() +{ + if (!g.pointerMoved) return; + g.pointerMoved = false; + for (size_t i = 0; i < g.targets.size(); i++) { + const HitTarget& t = g.targets[i]; + if (g.pointerX < t.x0 || g.pointerX >= t.x1 || g.pointerY < t.y0 || g.pointerY >= t.y1) continue; + if (t.kind == T_KEY) g.edit.key = t.index; + else if (t.kind == T_ROW) { g.inPanel = true; g.cursor = t.index; } + else if (t.kind == T_SECTION && !g.inPanel && t.index != g.section) ShowSection(t.index); + return; + } +} + +// Drawing + +static void AddTarget(float x0, float y0, float x1, float y1, int kind, int index) +{ + HitTarget t = { x0, y0, x1, y1, kind, index }; + g.targets.push_back(t); +} + +// The text cut short with "..." to fit width. +static std::string Fit(int font, float size, const std::string& text, float width) +{ + if (width <= 0.0f) return ""; + if (ui_overlay_text_width(font, size, text.c_str()) <= width) return text; + std::string s = text; + while (!s.empty()) { + // Drop one whole UTF-8 character. + size_t cut = s.size() - 1; + while (cut > 0 && ((unsigned char)s[cut] & 0xC0) == 0x80) cut--; + s.erase(cut); + std::string t = s + "..."; + if (ui_overlay_text_width(font, size, t.c_str()) <= width) return t; + } + return "..."; +} + +static void DrawHeader(const char* title) +{ + ui_overlay_text_glow(UI_FONT_BOLD, 17.0f, CONTENT_L, HEADER_Y + 4, UI_ALIGN_LEFT, + ui_style_color(UI_COLOR_TITLE), ui_style_color(UI_COLOR_TITLE_GLOW), title); + char line[96]; + snprintf(line, sizeof(line), "UIX Desktop %s", ShippedVersion()); + ui_overlay_text(UI_FONT_BOLD, 10.0f, CONTENT_R, HEADER_Y + 3, UI_ALIGN_RIGHT, ui_style_color(UI_COLOR_NAME), line); + ui_overlay_text(UI_FONT_REGULAR, 8.5f, CONTENT_R, HEADER_Y + 17, UI_ALIGN_RIGHT, ui_style_color(UI_COLOR_FAINT), + Host_Name()); + ui_overlay_rect(CONTENT_L - 4, RULE_Y, CONTENT_R - CONTENT_L + 8, 1, 0, ui_style_color(UI_COLOR_RULE)); +} + +static void DrawSections() +{ + float x = CONTENT_L, y = BODY_Y; + for (int i = 0; i < SEC_COUNT; i++) { + bool picked = i == g.section, lit = picked && !g.inPanel; + if (i == SEC_EXIT) { + ui_overlay_rect(x + 9, y + 3, SECTIONS_W - 18, 1, 0, ui_style_color(UI_COLOR_SEPARATOR)); + y += 8; + } + if (picked) ui_style_row(x, y, SECTIONS_W, SECTION_H, lit); + std::string name = Fit(UI_FONT_BOLD, 11.5f, kSections[i], SECTIONS_W - 18); + ui_overlay_text_glow(UI_FONT_BOLD, 11.5f, x + 9, y + (SECTION_H - 11.5f) * 0.5f, UI_ALIGN_LEFT, + picked ? 0xFFFFFFFF : ui_style_color(UI_COLOR_NAV), + lit ? ui_style_color(UI_COLOR_SELECTED_GLOW) : 0, name.c_str()); + AddTarget(x, y, x + SECTIONS_W, y + SECTION_H, T_SECTION, i); + y += SECTION_H + SECTION_GAP; + } +} + +static void DrawRows(float left, float right, float top) +{ + float width = right - left; + int shown = (int)((BODY_B - top + ROW_GAP) / (ROW_H + ROW_GAP)); + if (shown < 1) shown = 1; + int n = (int)g.rows.size(); + if (g.cursor < g.scroll) g.scroll = g.cursor; + if (g.cursor >= g.scroll + shown) g.scroll = g.cursor - shown + 1; + if (g.scroll > n - shown) g.scroll = n - shown; + if (g.scroll < 0) g.scroll = 0; + + float y = top; + for (int i = g.scroll; i < n && i < g.scroll + shown; i++) { + const Row& r = g.rows[i]; + bool on = g.inPanel && i == g.cursor; + if (r.act == GA_SETTING || r.act == GA_CATEGORY) { + const SettingDef* d = r.act == GA_SETTING ? &DesktopSettings_Table().defs[r.arg] : NULL; + const char* badge = d && (d->flags & SF_RESTART) ? "RESTART" : NULL; + float labelRoom = width * 0.55f - (badge ? 50.0f : 0.0f); + std::string label = Fit(UI_FONT_BOLD, 11.0f, r.label, labelRoom); + std::string value = Fit(UI_FONT_BOLD, 11.0f, r.value, width * 0.45f - 50); + ui_style_setting(left, y, width, ROW_H, 1.0f, label.c_str(), badge, value.c_str(), on); + } else { + ui_style_row(left, y, width, ROW_H, on); + float ty = y + (ROW_H - 11.0f) * 0.5f; + float valueW = r.value.empty() ? 0.0f : ui_overlay_text_width(UI_FONT_BOLD, 11.0f, r.value.c_str()); + float chevron = r.act == GA_VIEW ? 14.0f : 0.0f; + if (valueW > width * 0.45f) valueW = width * 0.45f; + std::string label = Fit(UI_FONT_BOLD, 11.0f, r.label, width - 24 - valueW - chevron - 8); + unsigned int labelColor = r.act == GA_NONE ? ui_style_color(UI_COLOR_STATUS) : 0xFFFFFFFF; + ui_overlay_text(UI_FONT_BOLD, 11.0f, left + 12, ty, UI_ALIGN_LEFT, labelColor, label.c_str()); + if (!r.value.empty()) { + std::string value = Fit(UI_FONT_BOLD, 11.0f, r.value, valueW); + ui_overlay_text(UI_FONT_BOLD, 11.0f, right - 12 - chevron, ty, UI_ALIGN_RIGHT, + ui_style_color(UI_COLOR_TAB), value.c_str()); + } + if (chevron > 0.0f) + ui_overlay_text(UI_FONT_BOLD, 11.0f, right - 12, ty, UI_ALIGN_RIGHT, ui_style_color(UI_COLOR_TAB), + "\xE2\x80\xBA"); + } + AddTarget(left, y, right, y + ROW_H, T_ROW, i); + y += ROW_H + ROW_GAP; + } + // More above or below. + if (g.scroll > 0) + ui_overlay_text(UI_FONT_BOLD, 9.0f, right - 6, top - 12, UI_ALIGN_RIGHT, ui_style_color(UI_COLOR_LABEL), "MORE"); + if (g.scroll + shown < n) + ui_overlay_text(UI_FONT_BOLD, 9.0f, right - 6, y - ROW_GAP + 1, UI_ALIGN_RIGHT, ui_style_color(UI_COLOR_LABEL), + "MORE"); + + // The chosen row's help along the bottom. + if ((g.inPanel || g.welcome) && g.cursor >= 0 && g.cursor < n && !g.rows[g.cursor].help.empty()) { + std::string help = Fit(UI_FONT_REGULAR, 10.0f, g.rows[g.cursor].help, width); + ui_style_text(UI_TEXT_LINE, left, BODY_B + 8, UI_ALIGN_LEFT, help.c_str()); + } +} + +static void DrawPanel(float left, float right) +{ + float top = BODY_Y; + if (g.view == GV_PAGE) { + float starts[8], ends[8]; + ui_style_tabs(left, right, top, 20, kPages, kPageCount, g.page, "Q", "E", starts, ends); + for (int i = 0; i < kPageCount; i++) AddTarget(starts[i], top, ends[i], top + 20, T_TAB, i); + top += 30; + } else { + std::string heading = g.view == GV_WELCOME ? "Ways in" : ViewTitle(); + for (size_t i = 0; i < heading.size(); i++) heading[i] = (char)toupper((unsigned char)heading[i]); + heading = Fit(UI_FONT_BOLD, 9.0f, heading, right - left); + ui_style_text(UI_TEXT_HEADING, left + 1, top + 2, UI_ALIGN_LEFT, heading.c_str()); + top += 20; + } + DrawRows(left, right, top); +} + +// The field over the keys. Secrets show as dots; a long line slides so +// the cursor stays in sight. +static void DrawEditor() +{ + const Editor& e = g.edit; + const float left = CONTENT_L, right = CONTENT_R, width = right - left, pad = 12.0f, size = 13.0f; + + ui_style_glass(left, FIELD_Y, width, FIELD_H, 7, ui_style_color(UI_COLOR_PANE), + ui_style_color(UI_COLOR_SELECTED_EDGE)); + std::string shown; + size_t caret = 0; + if (e.secret) { + for (size_t i = 0; i < e.text.size(); i = NextChar(e.text, i)) { + if (i < e.caret) caret += 3; + shown += "\xE2\x80\xA2"; + } + } else { + shown = e.text; + caret = e.caret; + } + const float room = width - 2 * pad; + size_t from = 0; + std::string lead; + while (from < caret && + ui_overlay_text_width(UI_FONT_BOLD, size, (lead + shown.substr(from, caret - from)).c_str()) > room - 4) { + from = NextChar(shown, from); + lead = "..."; + } + const float ty = FIELD_Y + (FIELD_H - size) * 0.5f; + if (shown.empty()) + ui_overlay_text(UI_FONT_REGULAR, 11.0f, left + pad + 4, FIELD_Y + (FIELD_H - 11.0f) * 0.5f, UI_ALIGN_LEFT, + ui_style_color(UI_COLOR_TEXT_DIM), "Empty"); + else + ui_overlay_text(UI_FONT_BOLD, size, left + pad, ty, UI_ALIGN_LEFT, 0xFFFFFFFF, + Fit(UI_FONT_BOLD, size, lead + shown.substr(from), room).c_str()); + if ((SDL_GetTicks() - e.since) % 1000 < 600) { + float cx = left + pad + ui_overlay_text_width(UI_FONT_BOLD, size, (lead + shown.substr(from, caret - from)).c_str()); + ui_overlay_rect(cx, ty - 1, 1.5f, size + 3, 0, ui_style_color(UI_COLOR_SELECTED_EDGE)); + } + + static const char* const kWideNames[KEY_COUNT - KEY_CHARS] = { "Shift", "#+=", "Space", "Delete", "Paste", "Done" }; + const float unit = (width + KEY_GAP) / 10.0f; + const int layout = KeyLayout(); + for (int k = 0; k < KEY_COUNT; k++) { + int row; + float x, w; + KeySpan(k, &row, &x, &w); + float kx = left + x * unit, ky = KEYS_Y + row * (KEY_H + KEY_GAP), kw = w * unit - KEY_GAP; + bool on = k == e.key, lit = (k == K_SHIFT && e.shift) || (k == K_SYMBOLS && e.symbols); + ui_style_row(kx, ky, kw, KEY_H, on); + char c[2] = { 0, 0 }; + const char* label = c; + float ls = 13.0f; + if (k < KEY_CHARS) { + c[0] = kKeyRows[layout][k / 10][k % 10]; + } else { + label = k == K_SYMBOLS && e.symbols ? "abc" : kWideNames[k - KEY_CHARS]; + ls = 10.5f; + } + unsigned int color = on || k < KEY_CHARS ? 0xFFFFFFFF + : lit ? ui_style_color(UI_COLOR_TITLE) : ui_style_color(UI_COLOR_TAB); + ui_overlay_text(UI_FONT_BOLD, ls, kx + kw * 0.5f, ky + (KEY_H - ls) * 0.5f, UI_ALIGN_CENTER, color, label); + AddTarget(kx, ky, kx + kw, ky + KEY_H, T_KEY, k); + } + + ui_style_text(UI_TEXT_LINE, left, BODY_B + 8, UI_ALIGN_LEFT, + ui_overlay_keyboard() ? "Type away. Enter saves, Esc cancels." + : "LB and RB move the cursor."); +} + +static void Prompt(ui_style_prompt* p, int* n, int button, const char* key, const char* word, unsigned bits) +{ + p[*n].button = button; + p[*n].key = key; + p[*n].word = word; + g.promptButtons[*n] = (int)bits; + (*n)++; +} + +static void DrawPrompts() +{ + ui_style_prompt p[6]; + int n = 0; + if (g.edit.on) { + if (!ui_overlay_keyboard()) { + Prompt(p, &n, UI_BUTTON_A, NULL, "Type", GUIDE_A); + Prompt(p, &n, UI_BUTTON_X, NULL, "Delete", GUIDE_X); + Prompt(p, &n, UI_BUTTON_Y, NULL, "Space", GUIDE_Y); + } + Prompt(p, &n, UI_BUTTON_START, "ENTER", "Done", GUIDE_START); + Prompt(p, &n, UI_BUTTON_B, "ESC", "Cancel", GUIDE_B); + } else if (g.inPanel && g.cursor >= 0 && g.cursor < (int)g.rows.size()) { + int act = g.rows[g.cursor].act; + const char* word = act == GA_NONE ? NULL + : act == GA_SETTING || act == GA_CATEGORY ? "Change" + : act == GA_VIEW ? "Open" + : act == GA_TEXT || act == GA_NAME || act == GA_JELLYFINURL ? "Edit" : "Select"; + if (word) Prompt(p, &n, UI_BUTTON_A, "ENTER", word, GUIDE_A); + } else if (!g.inPanel) { + Prompt(p, &n, UI_BUTTON_A, "ENTER", "Open", GUIDE_A); + } + if (!g.edit.on) { + bool back = !g.welcome && g.inPanel; + Prompt(p, &n, UI_BUTTON_B, "ESC", back ? "Back" : "Close", GUIDE_B); + } + float starts[6], ends[6]; + ui_style_prompts(CONTENT_R, PROMPTS_Y, 15.0f, p, n, starts, ends); + for (int i = 0; i < n; i++) + AddTarget(starts[i] - 4, PROMPTS_Y - 4, ends[i] + 4, PROMPTS_Y + 19, T_PROMPT, g.promptButtons[i]); + ui_overlay_rect(CONTENT_L - 4, PROMPTS_Y - 9, CONTENT_R - CONTENT_L + 8, 1, 0, ui_style_color(UI_COLOR_RULE)); +} + +void Guide_Render(int width, int height) +{ + if (g.open) { + Hover(); + g.targets.clear(); + ui_overlay_set_device(g.device); + ui_style_theme(g_guideTheme); + ui_style_clock(SDL_GetTicks()); + ui_overlay_layer(0); + float l = ui_overlay_layout_left(), r = ui_overlay_layout_right(); + ui_overlay_rect(l - 2, -2, r - l + 4, 484, 0, ui_style_color(UI_COLOR_SHADE)); + ui_style_panel(PANEL_X, PANEL_Y, PANEL_W, PANEL_H, 1.0f); + if (g.edit.on) { + DrawHeader(g.edit.title.c_str()); + DrawEditor(); + } else if (g.welcome) { + DrawPanel(CONTENT_L + 40, CONTENT_R - 40); + } else { + DrawHeader("Guide"); + DrawSections(); + DrawPanel(LIST_L, CONTENT_R); + } + DrawPrompts(); + } + ui_overlay_present(2, width, height); +} + +void Guide_Shutdown() +{ + ui_overlay_shutdown(); +} diff --git a/theseus/desktop/guide.h b/theseus/desktop/guide.h new file mode 100644 index 0000000..bf6c4c9 --- /dev/null +++ b/theseus/desktop/guide.h @@ -0,0 +1,31 @@ +// guide.h: the guide, a native overlay over the dashboard (guide.cpp). +// Opens with the Guide button, LT+B or F7; B backs out a level and closes +// it from the top. While it's open it has the pad and the keyboard. +#pragma once + +union SDL_Event; + +// What the guide reads each frame (joystick.cpp's Joy_GuideButtons). +enum { + GUIDE_UP = 1 << 0, GUIDE_DOWN = 1 << 1, GUIDE_LEFT = 1 << 2, GUIDE_RIGHT = 1 << 3, + GUIDE_A = 1 << 4, GUIDE_B = 1 << 5, GUIDE_X = 1 << 6, GUIDE_Y = 1 << 7, + GUIDE_LB = 1 << 8, GUIDE_RB = 1 << 9, GUIDE_START = 1 << 10, GUIDE_BACK = 1 << 11, +}; + +// Pad and keyboard as guide buttons. device becomes 1 for the pad, 0 for +// the keyboard, and is left alone when nothing is held. +unsigned Joy_GuideButtons(int* device); + +extern bool g_guideRequested; // set by the Guide button, LT+B or F7 +extern bool g_welcomeDone; // desktop.ini WelcomeDone + +void Guide_Frame(); // once a frame, before the dashboard advances +void Guide_Render(int width, int height); // backbuffer pixels; draws on view 2 +void Guide_HandleEvent(const SDL_Event* event); +bool Guide_IsOpen(); +// True while the guide has the input, and after it closes until the +// buttons that closed it are let go of. +bool Guide_TakesInput(); +// True while its keyboard is up: keys type, they aren't buttons. +bool Guide_Typing(); +void Guide_Shutdown(); diff --git a/theseus/desktop/joystick.cpp b/theseus/desktop/joystick.cpp index 911636f..fe5922f 100755 --- a/theseus/desktop/joystick.cpp +++ b/theseus/desktop/joystick.cpp @@ -4,9 +4,12 @@ // place of XInput is Theseus's. Counterpart to xbox/input.cpp. #include "std.h" +#include +#include #include "dashapp.h" #include "node.h" #include +#include "guide.h" #define REPEAT_DELAY 0.3f #define REPEAT_INTERVAL 0.12f @@ -383,20 +386,17 @@ void CJoystick::EnableGlobalInput(int bEnable) // ============================================================================ // Input Polling // ============================================================================ -void CJoystick::PollSDL(SDL_GameController* gc, JoySnapshot* out) -{ - memset(out, 0, sizeof(*out)); - - // Tools have the pad: leave it zeroed and the dashboard sits still. - extern bool ImGui_OwnsPad(); - if (ImGui_OwnsPad()) return; +static bool GuideChord(SDL_GameController* gc); +static void DevPadScript(JoySnapshot* out); - // --- Physical controller --- +// The pad's buttons and sticks as a snapshot. holdB drops the B of LT+B. +static void ReadPad(SDL_GameController* gc, bool holdB, JoySnapshot* out) +{ if (gc) { // Face buttons -> analogDown[0..3] = A,B,X,Y out->analogDown[0] = SDL_GameControllerGetButton(gc, SDL_CONTROLLER_BUTTON_A); - out->analogDown[1] = SDL_GameControllerGetButton(gc, SDL_CONTROLLER_BUTTON_B); + out->analogDown[1] = SDL_GameControllerGetButton(gc, SDL_CONTROLLER_BUTTON_B) && !holdB; out->analogDown[2] = SDL_GameControllerGetButton(gc, SDL_CONTROLLER_BUTTON_X); out->analogDown[3] = SDL_GameControllerGetButton(gc, SDL_CONTROLLER_BUTTON_Y); // Black(RB), White(LB) -> analogDown[4,5] @@ -435,6 +435,21 @@ void CJoystick::PollSDL(SDL_GameController* gc, JoySnapshot* out) out->thumbRX = rx; out->thumbRY = ry; } +} + +void CJoystick::PollSDL(SDL_GameController* gc, JoySnapshot* out) +{ + memset(out, 0, sizeof(*out)); + + // Tools have the pad: leave it zeroed and the dashboard sits still. + extern bool ImGui_OwnsPad(); + if (ImGui_OwnsPad()) return; + // So does the guide, while it's open. + if (Guide_TakesInput()) return; + + const bool guideHoldsB = GuideChord(gc); + + ReadPad(gc, guideHoldsB, out); // --- Keyboard overlay (OR on top) --- extern bool ImGui_WantsKeyboard(); @@ -499,6 +514,204 @@ void CJoystick::PollSDL(SDL_GameController* gc, JoySnapshot* out) else if (keys[SDL_SCANCODE_S]) out->thumbLY = -1.0f; } } + + DevPadScript(out); +} + +// Guide button, LT+B or F7 asks for the guide. The B of LT+B never reaches +// the scene, or it would also back out of whatever is under the guide. +static bool GuideChord(SDL_GameController* gc) +{ + extern bool g_guideRequested; + extern SDL_Window* g_pSDLWindow; + static bool s_guide = false, s_chord = false, s_f7 = false; + static bool s_holdB = false; + + bool guide = false, chord = false, b = false; + if (gc) { + guide = SDL_GameControllerGetButton(gc, SDL_CONTROLLER_BUTTON_GUIDE) != 0; + b = SDL_GameControllerGetButton(gc, SDL_CONTROLLER_BUTTON_B) != 0; + chord = b && SDL_GameControllerGetAxis(gc, SDL_CONTROLLER_AXIS_TRIGGERLEFT) > 8000; + } + extern bool ImGui_WantsKeyboard(); + const bool f7 = SDL_GetKeyboardFocus() == g_pSDLWindow && !ImGui_WantsKeyboard() && + SDL_GetKeyboardState(NULL)[SDL_SCANCODE_F7]; + + if ((guide && !s_guide) || (chord && !s_chord) || (f7 && !s_f7)) + g_guideRequested = true; + if (chord && !s_chord) s_holdB = true; + s_guide = guide; s_chord = chord; s_f7 = f7; + + if (s_holdB && !b) s_holdB = false; + return s_holdB; +} + +// Development only: UIX_DEV_PAD="14:guide 16:down 17.5:a" presses pad buttons +// at those seconds after start, so screens can be captured without a pad. +static void DevPadScript(JoySnapshot* out) +{ + struct Press { Uint32 at; int kind, idx; }; + static std::vector s_presses; + static bool s_parsed = false; + if (!s_parsed) { + s_parsed = true; + const char* env = getenv("UIX_DEV_PAD"); + static const struct { const char* name; int kind, idx; } kNames[] = { + { "a", 0, 0 }, { "b", 0, 1 }, { "x", 0, 2 }, { "y", 0, 3 }, + { "black", 0, 4 }, { "white", 0, 5 }, { "lt", 0, 6 }, { "rt", 0, 7 }, + { "up", 1, 0 }, { "down", 1, 1 }, { "left", 1, 2 }, { "right", 1, 3 }, + { "start", 1, 4 }, { "back", 1, 5 }, { "guide", 2, 0 }, + }; + while (env && *env) { + float t = 0; char name[16] = ""; + int used = 0; + if (sscanf(env, " %f:%15[a-z]%n", &t, name, &used) != 2) break; + env += used; + for (size_t i = 0; i < sizeof(kNames) / sizeof(kNames[0]); i++) + if (strcmp(kNames[i].name, name) == 0) { + Press p = { (Uint32)(t * 1000.0f), kNames[i].kind, kNames[i].idx }; + s_presses.push_back(p); + } + } + } + const Uint32 now = SDL_GetTicks(); + for (size_t i = 0; i < s_presses.size(); i++) { + const Press& p = s_presses[i]; + if (now < p.at || now > p.at + 150) continue; + if (p.kind == 0) out->analogDown[p.idx] = true; + else if (p.kind == 1) out->digitalDown[p.idx] = true; + else { extern bool g_guideRequested; static Uint32 s_last = 0; + if (s_last != p.at) { s_last = p.at; g_guideRequested = true; } } + } +} + +// Development only: UIX_DEV_EVENTS="15:move=320,200 16:click=320,200,1 17:rclick +// 18:wheel=-1 19:text=abc 20:key=ctrl+v" queues pointer and key events at those +// seconds, in window points. Nothing moves the real pointer. +static void DevEventScript() +{ + struct Event { Uint32 at; std::string kind, value; }; + static std::vector s_events; + static size_t s_next = 0; + static bool s_parsed = false; + static int s_x = 0, s_y = 0, s_clicks = 1; + if (!s_parsed) { + s_parsed = true; + const char* env = getenv("UIX_DEV_EVENTS"); + while (env && *env) { + float t = 0; char kind[16] = "", value[256] = ""; + int used = 0; + if (sscanf(env, " %f:%15[a-z]%n", &t, kind, &used) != 2) break; + env += used; + if (*env == '=' && sscanf(env + 1, "%255s%n", value, &used) == 1) env += 1 + used; + Event e = { (Uint32)(t * 1000.0f), kind, value }; + s_events.push_back(e); + } + } + extern SDL_Window* g_pSDLWindow; + if (!g_pSDLWindow) return; + const Uint32 win = SDL_GetWindowID(g_pSDLWindow); + while (s_next < s_events.size() && SDL_GetTicks() >= s_events[s_next].at) { + const Event& e = s_events[s_next++]; + SDL_Event ev; + memset(&ev, 0, sizeof(ev)); + if (e.kind == "move" || e.kind == "click") { + s_clicks = 1; + if (sscanf(e.value.c_str(), "%d,%d,%d", &s_x, &s_y, &s_clicks) >= 2 || e.kind == "move") { + ev.type = SDL_MOUSEMOTION; + ev.motion.windowID = win; + ev.motion.x = s_x; ev.motion.y = s_y; + SDL_PushEvent(&ev); + } + } + if (e.kind == "click" || e.kind == "rclick") { + memset(&ev, 0, sizeof(ev)); + ev.type = SDL_MOUSEBUTTONDOWN; + ev.button.windowID = win; + ev.button.button = e.kind == "click" ? SDL_BUTTON_LEFT : SDL_BUTTON_RIGHT; + ev.button.state = SDL_PRESSED; + ev.button.clicks = (Uint8)(e.kind == "click" ? s_clicks : 1); + ev.button.x = s_x; ev.button.y = s_y; + SDL_PushEvent(&ev); + ev.type = SDL_MOUSEBUTTONUP; + ev.button.state = SDL_RELEASED; + SDL_PushEvent(&ev); + } else if (e.kind == "wheel") { + ev.type = SDL_MOUSEWHEEL; + ev.wheel.windowID = win; + ev.wheel.y = atoi(e.value.c_str()); + ev.wheel.preciseY = (float)ev.wheel.y; + SDL_PushEvent(&ev); + } else if (e.kind == "text") { + // sdl2-compat can't queue text, so it goes to the guide directly. + ev.type = SDL_TEXTINPUT; + ev.text.timestamp = SDL_GetTicks(); + ev.text.windowID = win; + snprintf(ev.text.text, sizeof(ev.text.text), "%s", e.value.c_str()); + Guide_HandleEvent(&ev); + } else if (e.kind == "key") { + std::string name = e.value; + Uint16 mod = 0; + if (name.compare(0, 5, "ctrl+") == 0) { mod = KMOD_LCTRL; name = name.substr(5); } + ev.type = SDL_KEYDOWN; + ev.key.windowID = win; + ev.key.state = SDL_PRESSED; + ev.key.keysym.sym = SDL_GetKeyFromName(name.c_str()); + ev.key.keysym.scancode = SDL_GetScancodeFromKey(ev.key.keysym.sym); + ev.key.keysym.mod = mod; + SDL_PushEvent(&ev); + ev.type = SDL_KEYUP; + ev.key.state = SDL_RELEASED; + SDL_PushEvent(&ev); + } + } +} + +// The guide's buttons: the pad, the keyboard and the dev script. Called +// every frame, so the Guide button, LT+B and F7 work with the guide open. +unsigned Joy_GuideButtons(int* device) +{ + extern SDL_Window* g_pSDLWindow; + extern bool ImGui_WantsKeyboard(); + SDL_GameController* gc = CJoystick::c_controller; + const bool holdB = GuideChord(gc); + + JoySnapshot pad; + memset(&pad, 0, sizeof(pad)); + ReadPad(gc, holdB, &pad); + DevPadScript(&pad); + DevEventScript(); + unsigned bits = 0; + if (pad.analogDown[0]) bits |= GUIDE_A; + if (pad.analogDown[1]) bits |= GUIDE_B; + if (pad.analogDown[2]) bits |= GUIDE_X; + if (pad.analogDown[3]) bits |= GUIDE_Y; + if (pad.analogDown[5]) bits |= GUIDE_LB; + if (pad.analogDown[4]) bits |= GUIDE_RB; + if (pad.digitalDown[0] || pad.thumbLY > 0.5f) bits |= GUIDE_UP; + if (pad.digitalDown[1] || pad.thumbLY < -0.5f) bits |= GUIDE_DOWN; + if (pad.digitalDown[2] || pad.thumbLX < -0.5f) bits |= GUIDE_LEFT; + if (pad.digitalDown[3] || pad.thumbLX > 0.5f) bits |= GUIDE_RIGHT; + if (pad.digitalDown[4]) bits |= GUIDE_START; + if (pad.digitalDown[5]) bits |= GUIDE_BACK; + + unsigned keys = 0; + if (SDL_GetKeyboardFocus() == g_pSDLWindow && !ImGui_WantsKeyboard() && !Guide_Typing()) { + const Uint8* k = SDL_GetKeyboardState(NULL); + if (k[SDL_SCANCODE_RETURN] || k[SDL_SCANCODE_KP_ENTER] || k[SDL_SCANCODE_SPACE]) keys |= GUIDE_A; + if (k[SDL_SCANCODE_ESCAPE] || k[SDL_SCANCODE_BACKSPACE]) keys |= GUIDE_B; + if (k[SDL_SCANCODE_UP] || k[SDL_SCANCODE_W]) keys |= GUIDE_UP; + if (k[SDL_SCANCODE_DOWN] || k[SDL_SCANCODE_S]) keys |= GUIDE_DOWN; + if (k[SDL_SCANCODE_LEFT] || k[SDL_SCANCODE_A]) keys |= GUIDE_LEFT; + if (k[SDL_SCANCODE_RIGHT] || k[SDL_SCANCODE_D]) keys |= GUIDE_RIGHT; + if (k[SDL_SCANCODE_Q]) keys |= GUIDE_LB; + if (k[SDL_SCANCODE_E]) keys |= GUIDE_RB; + } + if (bits) *device = 1; + else if (keys) *device = 0; + // Back is B in here, like Backspace. + if ((bits | keys) & GUIDE_BACK) bits |= GUIDE_B; + return bits | keys; } // ============================================================================ @@ -536,6 +749,8 @@ void CJoystick::ProcessMediaKeys() // Fire on rising edges only uint32_t pressed = cur & ~m_prevMediaKeys; m_prevMediaKeys = cur; + // The guide has the keys while it's up; a 1 typed there isn't On1. + if (Guide_TakesInput()) return; if (pressed & (1 << 0)) CallFunction("OnReverse"); if (pressed & (1 << 1)) CallFunction("OnForward"); diff --git a/theseus/desktop/launch.cpp b/theseus/desktop/launch.cpp index cc0e414..beab0ae 100644 --- a/theseus/desktop/launch.cpp +++ b/theseus/desktop/launch.cpp @@ -65,6 +65,30 @@ static bool IsExistingFile(const char* path) return Host_Stat(path, &info) && !(info.attrs & HOST_ATTR_DIR); } +// Quotes an unquoted program path that has spaces in it, like anything under +// "Program Files (x86)". Takes the longest leading run that names a real file. +static void QuoteLeadingPath(char* s, size_t size) +{ + if (!s || !s[0] || s[0] == '"') return; + size_t len = strlen(s); + size_t best = 0; + char probe[2048]; + for (size_t i = 1; i <= len && i < sizeof(probe); i++) { + if (s[i] != '\0' && !isspace((unsigned char)s[i])) continue; + memcpy(probe, s, i); + probe[i] = '\0'; + if (IsExistingFile(probe)) best = i; + } + if (best == 0 || len + 3 > size) return; + bool hasSpace = false; + for (size_t i = 0; i < best; i++) if (isspace((unsigned char)s[i])) hasSpace = true; + if (!hasSpace) return; + memmove(s + best + 2, s + best, len - best + 1); + memmove(s + 1, s, best); + s[0] = '"'; + s[best + 1] = '"'; +} + // Parent dir of the program being spawned. Tries quoted path, then whole // string as a file, then first whitespace-delimited token. static bool DeriveWorkDir(const char* cmd, char* out, size_t outSize) @@ -117,6 +141,7 @@ static bool Launch_DoSpawn(const char* expanded) strncpy(spec, expanded, sizeof(spec) - 1); spec[sizeof(spec) - 1] = '\0'; TrimOuterQuotes(spec); + if (!IsUrl(spec)) QuoteLeadingPath(spec, sizeof(spec)); char workdir[1024]; bool haveWorkdir = DeriveWorkDir(spec, workdir, sizeof(workdir)); diff --git a/theseus/desktop/launchers/registry.cpp b/theseus/desktop/launchers/registry.cpp index 0362e73..7e8c128 100644 --- a/theseus/desktop/launchers/registry.cpp +++ b/theseus/desktop/launchers/registry.cpp @@ -57,7 +57,9 @@ void Launcher_Build(const char* spec, char* outCmd, size_t outSize) { // Each module's RegisterFoo lives in its own .cpp. extern void Launcher_RegisterRetroArch(); +extern void Launcher_RegisterXemu(); void Launchers_RegisterAll() { Launcher_RegisterRetroArch(); + Launcher_RegisterXemu(); } diff --git a/theseus/desktop/launchers/xemu.cpp b/theseus/desktop/launchers/xemu.cpp new file mode 100644 index 0000000..e02ff8b --- /dev/null +++ b/theseus/desktop/launchers/xemu.cpp @@ -0,0 +1,86 @@ +// xemu.cpp: xemu launcher module. Title Maker stores an ISO as a bare +// `xemu -machine ... -dvd_path "..."` when no xemu path is set. Flatpak +// xemu never puts xemu on PATH and snap's /snap/bin isn't always on it, so +// a bare xemu that PATH can't find is pointed at whichever is installed. +// Flatpak xemu can read the whole disk, so the ISO path works. Neither +// exists off Linux, so there the spec runs as is. + +#include "launcher.h" +#include "xemu.h" + +#include +#include +#include +#include + +namespace { + +bool Exists(const char* path) { + struct stat st; + return stat(path, &st) == 0; +} + +// The text after a leading bare "xemu", or NULL when the spec isn't one. +const char* AfterBareXemu(const char* spec) { + if (!spec) return NULL; + while (*spec == ' ' || *spec == '\t') spec++; + if (strncmp(spec, "xemu", 4) != 0) return NULL; + if (spec[4] != ' ' && spec[4] != '\t' && spec[4] != 0) return NULL; + return spec + 4; +} + +bool OnPath(const char* exe) { + const char* path = getenv("PATH"); + if (!path) return false; + char dir[1024]; + while (*path) { + const char* end = strchr(path, ':'); + size_t n = end ? (size_t)(end - path) : strlen(path); + if (n > 0 && n < sizeof(dir) - 32) { + memcpy(dir, path, n); + snprintf(dir + n, sizeof(dir) - n, "/%s", exe); + if (Exists(dir)) return true; + } + if (!end) break; + path = end + 1; + } + return false; +} + +bool Claims(const char* spec) { + return AfterBareXemu(spec) != NULL; +} + +bool Build(const char* spec, char* outCmd, size_t outSize) { + const char* rest = AfterBareXemu(spec); + if (!rest || OnPath("xemu")) return false; + const char* runner = NULL; + if (Exists("/snap/bin/xemu")) runner = "/snap/bin/xemu"; + else if (Xemu_FlatpakInstalled()) runner = "flatpak run app.xemu.xemu"; + if (!runner) return false; + int n = snprintf(outCmd, outSize, "%s%s", runner, rest); + return n > 0 && (size_t)n < outSize; +} + +const Launcher kXemuLauncher = { + "xemu", + "xemu", + Claims, + 300, + Build, +}; + +} // namespace + +bool Xemu_FlatpakInstalled() { + if (Exists("/var/lib/flatpak/app/app.xemu.xemu")) return true; + const char* home = getenv("HOME"); + if (!home || !*home) return false; + char userApp[1024]; + snprintf(userApp, sizeof(userApp), "%s/.local/share/flatpak/app/app.xemu.xemu", home); + return Exists(userApp); +} + +void Launcher_RegisterXemu() { + Launcher_Register(&kXemuLauncher); +} diff --git a/theseus/desktop/launchers/xemu.h b/theseus/desktop/launchers/xemu.h new file mode 100644 index 0000000..93a78f6 --- /dev/null +++ b/theseus/desktop/launchers/xemu.h @@ -0,0 +1,9 @@ +// xemu.h: xemu launcher module. ISO entries are stored as `xemu ...` when +// no xemu path is set; this finds a Flatpak or snap xemu at launch. + +#pragma once + +#include + +// Flatpak xemu (app.xemu.xemu), system or user install. +bool Xemu_FlatpakInstalled(); diff --git a/theseus/desktop/log_redact.cpp b/theseus/desktop/log_redact.cpp new file mode 100644 index 0000000..d9a71b4 --- /dev/null +++ b/theseus/desktop/log_redact.cpp @@ -0,0 +1,88 @@ +// log_redact.cpp: home folder and IP address masking for copied logs. + +#include "log_redact.h" +#include +#include + +static bool IsHex(char c) { return isxdigit((unsigned char)c) != 0; } + +// Length of an IPv4 address starting at p, or 0. +static size_t MatchV4(const char* p, bool* keep) +{ + const char* s = p; + int parts[4]; + for (int i = 0; i < 4; i++) { + if (!isdigit((unsigned char)*s)) return 0; + int n = 0, digits = 0; + while (isdigit((unsigned char)*s) && digits < 4) { n = n * 10 + (*s - '0'); s++; digits++; } + if (digits > 3 || n > 255) return 0; + parts[i] = n; + if (i < 3) { if (*s != '.') return 0; s++; } + } + // Part of something longer, like a version string. + if (isdigit((unsigned char)*s) || (*s == '.' && isdigit((unsigned char)s[1]))) return 0; + *keep = (parts[0] == 127) || (parts[0] == 0 && parts[1] == 0 && parts[2] == 0 && parts[3] == 0); + return (size_t)(s - p); +} + +// Length of an IPv6 address starting at p, or 0. Needs "::" or seven colons, +// and a hex letter or "::", so times like 12:30:45 are left alone. +static size_t MatchV6(const char* p, bool* keep) +{ + const char* s = p; + int colons = 0, group = 0; + bool letter = false, dbl = false; + while (IsHex(*s) || *s == ':') { + if (*s == ':') { + colons++; + if (s[1] == ':') dbl = true; + group = 0; + } else { + if (++group > 4) return 0; + if (isalpha((unsigned char)*s)) letter = true; + } + s++; + } + if (colons < 2 || (!dbl && colons != 7) || (!dbl && !letter)) return 0; + if (s - p < 3) return 0; + std::string a(p, (size_t)(s - p)); + *keep = (a == "::1" || a == "::"); + return (size_t)(s - p); +} + +std::string Log_Redact(const std::string& in, const char* home) +{ + std::string text; + size_t hl = home ? strlen(home) : 0; + while (hl > 1 && (home[hl - 1] == '/' || home[hl - 1] == '\\')) hl--; + if (hl > 1) { + for (size_t i = 0; i < in.size();) { + if (in.compare(i, hl, home, hl) == 0) { text += '~'; i += hl; } + else text += in[i++]; + } + } else { + text = in; + } + + std::string out; + out.reserve(text.size()); + const char* p = text.c_str(); + const char* end = p + text.size(); + while (p < end) { + bool prevWord = (p > text.c_str()) && (isalnum((unsigned char)p[-1]) || p[-1] == '.' || p[-1] == ':'); + bool keep = false; + size_t n = 0; + if (!prevWord) { + n = MatchV4(p, &keep); + if (!n) n = MatchV6(p, &keep); + } + if (n) { + if (keep) out.append(p, n); + else out += "[ip]"; + p += n; + } else { + out += *p++; + } + } + return out; +} diff --git a/theseus/desktop/log_redact.h b/theseus/desktop/log_redact.h new file mode 100644 index 0000000..27017fd --- /dev/null +++ b/theseus/desktop/log_redact.h @@ -0,0 +1,7 @@ +// log_redact.h: make a log safe to paste into a public report. +#pragma once +#include + +// The home folder becomes ~ and IP addresses become [ip] (loopback and +// 0.0.0.0 stay, they say nothing about anyone). +std::string Log_Redact(const std::string& text, const char* home); diff --git a/theseus/desktop/media_db.cpp b/theseus/desktop/media_db.cpp index 98f25c0..fada735 100644 --- a/theseus/desktop/media_db.cpp +++ b/theseus/desktop/media_db.cpp @@ -259,46 +259,84 @@ static void SortEpisodes(std::vector& eps) }); } -// First video at or below dir. depth 1 is this level only; 2 also covers a -// Season folder or similar sitting under the title folder. -static std::string FindFirstVideoFile(const std::string& dir, int depth = 1) +// Trailers and samples ride along with movies, games and episodes alike: +// "trailer.mp4", "Movie-trailer.mkv", "Show.S01E01.sample.mkv". +static bool IsExtraVideo(const char* name) +{ + const char* dot = strrchr(name, '.'); + size_t n = dot ? (size_t)(dot - name) : strlen(name); + static const char* kTags[] = { "trailer", "sample" }; + for (size_t t = 0; t < sizeof(kTags) / sizeof(kTags[0]); t++) + { + size_t k = strlen(kTags[t]); + if (n < k || strncasecmp(name + n - k, kTags[t], k) != 0) continue; + if (n == k) return true; + char before = name[n - k - 1]; + if (before == '-' || before == '.' || before == '_' || before == ' ') return true; + } + return false; +} + +static bool IsMainVideo(const char* name) +{ + return IsVideoExt(name) && !IsExtraVideo(name); +} + +// The first movie sitting right in dir. Subfolders are a movie's extras. +static std::string FindFirstVideoFile(const std::string& dir) { HostDir* d = Host_OpenDir(dir.c_str()); if (!d) return ""; - std::vector subdirs; std::string out; char eName[260]; while (Host_ReadDir(d, eName, sizeof(eName), NULL)) { if (eName[0] == '.') continue; - if (IsVideoExt(eName)) + if (IsMainVideo(eName)) { out = dir + "/" + eName; break; } - if (depth > 1) - { - std::string sub = dir + "/" + eName; - struct stat st; - if (stat(sub.c_str(), &st) == 0 && S_ISDIR(st.st_mode)) - subdirs.push_back(sub); - } } Host_CloseDir(d); - if (!out.empty()) return out; - - // Files win over folders, so only descend once this level came up empty. - for (size_t i = 0; i < subdirs.size(); i++) - { - out = FindFirstVideoFile(subdirs[i], depth - 1); - if (!out.empty()) return out; - } - return ""; + return out; } // Filesystem scan ------------------------------------------------------------ +static void AddMovie(std::vector& out, const std::string& title, const std::string& path) +{ + MovieRec m; + m.title = title; + m.path = path; + m.year = ParseYearFromTitle(m.title); + out.push_back(m); + int n = (int)out.size(); + BumpProgress(n); + if ((n % 50) == 0) printf("[MediaDB] Movies: %d found\n", n); +} + +// A folder with a movie right in it is that movie. One without is a +// collection (Action/Die Hard/Die Hard.mkv): each subfolder holding a movie +// is its own movie and the collection itself isn't listed. Only one level. +static void ScanMovieCollection(std::vector& out, const std::string& dir) +{ + HostDir* d = Host_OpenDir(dir.c_str()); + if (!d) return; + char eName[260]; + while (Host_ReadDir(d, eName, sizeof(eName), NULL)) + { + if (eName[0] == '.') continue; + std::string sub = dir + "/" + eName; + struct stat st; + if (stat(sub.c_str(), &st) != 0 || !S_ISDIR(st.st_mode)) continue; + std::string videoPath = FindFirstVideoFile(sub); + if (!videoPath.empty()) AddMovie(out, eName, videoPath); + } + Host_CloseDir(d); +} + static void ScanMoviesInto(std::vector& out) { out.clear(); @@ -312,26 +350,17 @@ static void ScanMoviesInto(std::vector& out) struct stat st; if (stat(folder.c_str(), &st) != 0) continue; - std::string videoPath; - std::string title = eName; if (S_ISDIR(st.st_mode)) { - videoPath = FindFirstVideoFile(folder, 2); - } else if (IsVideoExt(eName)) { + std::string videoPath = FindFirstVideoFile(folder); + if (!videoPath.empty()) AddMovie(out, eName, videoPath); + else ScanMovieCollection(out, folder); + } else if (IsMainVideo(eName)) { // Movies dropped straight in the root, no folder per title. - videoPath = folder; + std::string title = eName; size_t dot = title.find_last_of('.'); if (dot != std::string::npos) title.erase(dot); + AddMovie(out, title, folder); } - if (videoPath.empty()) continue; - - MovieRec m; - m.title = title; - m.path = videoPath; - m.year = ParseYearFromTitle(m.title); - out.push_back(m); - int n = (int)out.size(); - BumpProgress(n); - if ((n % 50) == 0) printf("[MediaDB] Movies: %d found\n", n); } Host_CloseDir(d); printf("[MediaDB] Movie scan done: %d total\n", (int)out.size()); @@ -360,7 +389,7 @@ static void ScanShowSeasons(ShowRec& show) while (Host_ReadDir(sd, seName, sizeof(seName), NULL)) { if (seName[0] == '.') continue; - if (!IsVideoExt(seName)) continue; + if (!IsMainVideo(seName)) continue; EpisodeRec ep; ep.path = seasonDir + "/" + seName; ep.title = ParseEpisodeTitle(seName, ep.season, ep.episode); @@ -393,7 +422,7 @@ static void ScanShowSeasons(ShowRec& show) while (Host_ReadDir(fd, feName, sizeof(feName), NULL)) { if (feName[0] == '.') continue; - if (!IsVideoExt(feName)) continue; + if (!IsMainVideo(feName)) continue; EpisodeRec ep; ep.path = show.path + "/" + feName; ep.title = ParseEpisodeTitle(feName, ep.season, ep.episode); @@ -423,7 +452,7 @@ static void ScanShowsInto(std::vector& out) { // A loose episode in the root. Its own one episode show, so it // is at least reachable instead of invisible. - if (!IsVideoExt(eName)) continue; + if (!IsMainVideo(eName)) continue; std::string title = eName; size_t dot = title.find_last_of('.'); if (dot != std::string::npos) title.erase(dot); diff --git a/theseus/desktop/menu_bar.cpp b/theseus/desktop/menu_bar.cpp index b608f1e..ffd0b62 100644 --- a/theseus/desktop/menu_bar.cpp +++ b/theseus/desktop/menu_bar.cpp @@ -13,6 +13,7 @@ static const char* ShippedVersionPath() { return path; } #include "panel_shared.h" +#include "settings_schema.h" #include "title_maker.h" #include "hdd_browser.h" #include "audio_sdl.h" @@ -95,38 +96,6 @@ static void ApplyCRTPreset(const CRTPreset& p) { g_crt.brightness = p.brightness; } -// ============================================================================ -// MSAA Options (shared between menu and settings) -// ============================================================================ - -static const char* s_msaaLabels[] = { "Off", "2x", "4x", "8x" }; -static const int s_msaaValues[] = { 0, 2, 4, 8 }; -static const int s_msaaCount = 4; - -// bgfx has no adaptive vsync, so the control is a plain On/Off. g_vsyncMode -// still stores 0/1 = on, 2 = off for config compatibility. -static const char* s_vsyncLabels[] = { "On", "Off" }; - -static const char* s_resLabels[] = { "Native", "720p", "1080p", "1440p (2K)", "2160p (4K)" }; -static const int s_resValues[] = { 0, 720, 1080, 1440, 2160 }; -static const int s_resCount = 5; -static int GetResIndex() { - for (int i = 0; i < s_resCount; i++) - if (s_resValues[i] == g_windowResolution) return i; - return 0; -} - -static const char* s_displayModeLabels[] = { "Windowed", "Borderless", "Fullscreen" }; - -static const char* s_fpsLabels[] = { "Unlimited", "30", "60", "90", "120", "144", "240" }; -static const int s_fpsValues[] = { 0, 30, 60, 90, 120, 144, 240 }; -static const int s_fpsCount = 7; -static int GetFpsIndex() { - for (int i = 0; i < s_fpsCount; i++) - if (s_fpsValues[i] == g_fpsCap) return i; - return 0; -} - static void RestoreDisplayDefaults() { g_crt.enabled = false; ApplyCRTPreset(s_crtPresets[1]); // "Classic" @@ -137,10 +106,44 @@ static void RestoreDisplayDefaults() { g_hwdec = false; } -static int GetMSAAIndex() { - for (int i = 0; i < s_msaaCount; i++) - if (s_msaaValues[i] == g_msaaSamples) return i; - return 0; +// ============================================================================ +// Settings rows: label, range and side effects come from the schema +// ============================================================================ + +static void SettingChanged(const SettingDef* d) +{ + if (d->apply) d->apply(); + SaveDesktopSettings(); +} + +static bool SettingCheckbox(const char* key, const char* label = NULL) +{ + const SettingDef* d = DesktopSettings_Find(key); + if (!d || d->kind != SK_BOOL) return false; + if (!ImGui::Checkbox(label ? label : d->label, (bool*)d->ptr)) return false; + SettingChanged(d); + return true; +} + +static bool SettingCombo(const char* key, const char* id) +{ + const SettingDef* d = DesktopSettings_Find(key); + if (!d || d->kind != SK_ENUM) return false; + int idx = Setting_EnumIndex(*d); + if (idx < 0) idx = 0; + if (!ImGui::Combo(id, &idx, d->labels, d->count)) return false; + *(int*)d->ptr = d->values[idx]; + SettingChanged(d); + return true; +} + +// Saves once when the drag ends, not every frame. +static void SettingSlider(const char* key) +{ + const SettingDef* d = DesktopSettings_Find(key); + if (!d || d->kind != SK_FLOAT) return; + ImGui::SliderFloat(d->label, (float*)d->ptr, d->minV, d->maxV, "%.2f"); + if (ImGui::IsItemDeactivatedAfterEdit()) SettingChanged(d); } // ============================================================================ @@ -575,10 +578,7 @@ void RenderSettingsWindow() { // Startup mode ImGui::Text("Startup Mode:"); ImGui::SameLine(); - const char* modeLabels[] = { "Ask on launch", "Dashboard", "Development" }; - if (ImGui::Combo("##startupmode", &g_startupMode, modeLabels, 3)) { - SaveDesktopSettings(); - } + SettingCombo("StartupMode", "##startupmode"); ImGui::Spacing(); ImGui::Separator(); @@ -599,9 +599,7 @@ void RenderSettingsWindow() { ImGui::Spacing(); { - extern bool g_menuBarAutoHide; - if (ImGui::Checkbox("Auto-hide menu bar", &g_menuBarAutoHide)) - SaveDesktopSettings(); + SettingCheckbox("MenuBarAutoHide", "Auto-hide menu bar"); ImGui::SameLine(); ImGui::TextDisabled("(reveals when the pointer reaches the top edge)"); } @@ -618,17 +616,13 @@ void RenderSettingsWindow() { ImGui::Separator(); ImGui::Spacing(); - extern bool g_bUseOnScreenKeyboard; - if (ImGui::Checkbox("Use On-Screen Keyboard Only", &g_bUseOnScreenKeyboard)) - SaveDesktopSettings(); + SettingCheckbox("UseOnScreenKeyboard", "Use On-Screen Keyboard Only"); ImGui::SameLine(); ImGui::TextDisabled("(?)"); if (ImGui::IsItemHovered()) ImGui::SetTooltip("When checked, dashboard text fields ignore your physical\nkeyboard and require gamepad navigation of the on-screen keyboard."); - extern bool g_bShowBootAnimation; - if (ImGui::Checkbox("Show Boot Animation", &g_bShowBootAnimation)) - SaveDesktopSettings(); + SettingCheckbox("ShowBootAnimation", "Show Boot Animation"); ImGui::SameLine(); ImGui::TextDisabled("(?)"); if (ImGui::IsItemHovered()) @@ -645,19 +639,15 @@ void RenderSettingsWindow() { if (ImGui::BeginTabItem("Display")) { ImGui::Spacing(); - // Slider drags fire IsItemDeactivatedAfterEdit on release; saves - // once at end of drag, not every frame. - if (ImGui::Checkbox("CRT Effect", &g_crt.enabled)) SaveDesktopSettings(); + SettingCheckbox("CRT_Enabled"); if (g_crt.enabled) { ImGui::Spacing(); - ImGui::SliderFloat("Scanlines", &g_crt.scanlineIntensity, 0.0f, 1.0f, "%.2f"); if (ImGui::IsItemDeactivatedAfterEdit()) SaveDesktopSettings(); - ImGui::SliderFloat("Curvature", &g_crt.curvature, 0.0f, 2.0f, "%.2f"); if (ImGui::IsItemDeactivatedAfterEdit()) SaveDesktopSettings(); - ImGui::SliderFloat("Phosphor Mask", &g_crt.phosphorMask, 0.0f, 1.0f, "%.2f"); if (ImGui::IsItemDeactivatedAfterEdit()) SaveDesktopSettings(); - ImGui::SliderFloat("Vignette", &g_crt.vignette, 0.0f, 2.0f, "%.2f"); if (ImGui::IsItemDeactivatedAfterEdit()) SaveDesktopSettings(); - ImGui::SliderFloat("Bloom", &g_crt.bloom, 0.0f, 1.0f, "%.2f"); if (ImGui::IsItemDeactivatedAfterEdit()) SaveDesktopSettings(); - ImGui::SliderFloat("Flicker", &g_crt.flickerAmount, 0.0f, 1.0f, "%.2f"); if (ImGui::IsItemDeactivatedAfterEdit()) SaveDesktopSettings(); - ImGui::SliderFloat("Color Bleed", &g_crt.colorBleed, 0.0f, 4.0f, "%.2f"); if (ImGui::IsItemDeactivatedAfterEdit()) SaveDesktopSettings(); - ImGui::SliderFloat("Brightness", &g_crt.brightness, 0.8f, 1.3f, "%.2f"); if (ImGui::IsItemDeactivatedAfterEdit()) SaveDesktopSettings(); + static const char* kCrtKeys[] = { + "CRT_Scanlines", "CRT_Curvature", "CRT_Phosphor", "CRT_Vignette", + "CRT_Bloom", "CRT_Flicker", "CRT_ColorBleed", "CRT_Brightness", + }; + for (int i = 0; i < (int)(sizeof(kCrtKeys) / sizeof(kCrtKeys[0])); i++) + SettingSlider(kCrtKeys[i]); ImGui::Spacing(); ImGui::Text("Presets:"); @@ -677,7 +667,7 @@ void RenderSettingsWindow() { // Toggles grouped together if (ImGui::Checkbox("Wireframe", &g_bWireframe)) SaveDesktopSettings(); - if (ImGui::Checkbox("Hardware video decode (mpv)", &g_hwdec)) SaveDesktopSettings(); + SettingCheckbox("Hwdec", "Hardware video decode (mpv)"); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Off = software decode, safest. On = faster on AMD/NVIDIA.\nApplies to next media open."); @@ -689,60 +679,28 @@ void RenderSettingsWindow() { ImGui::AlignTextToFramePadding(); ImGui::Text("MSAA:"); ImGui::SameLine(kLabelX); ImGui::SetNextItemWidth(kWidgetW); - int msaaIdx = GetMSAAIndex(); - if (ImGui::Combo("##msaa", &msaaIdx, s_msaaLabels, s_msaaCount)) { - g_msaaSamples = s_msaaValues[msaaIdx]; - g_msaaChangeRequested = true; - SaveDesktopSettings(); - } + SettingCombo("MSAA", "##msaa"); ImGui::AlignTextToFramePadding(); ImGui::Text("VSync:"); ImGui::SameLine(kLabelX); ImGui::SetNextItemWidth(kWidgetW); - int vsyncIdx = (g_vsyncMode == 2) ? 1 : 0; - if (ImGui::Combo("##vsync", &vsyncIdx, s_vsyncLabels, 2)) { - g_vsyncMode = (vsyncIdx == 1) ? 2 : 0; - g_vsyncChangeRequested = true; - SaveDesktopSettings(); - } + SettingCombo("Vsync", "##vsync"); ImGui::AlignTextToFramePadding(); ImGui::Text("FPS Cap:"); ImGui::SameLine(kLabelX); ImGui::SetNextItemWidth(kWidgetW); - int fpsIdx = GetFpsIndex(); - if (ImGui::Combo("##fpscap", &fpsIdx, s_fpsLabels, s_fpsCount)) { - g_fpsCap = s_fpsValues[fpsIdx]; - SaveDesktopSettings(); - } + SettingCombo("FpsCap", "##fpscap"); - if (ImGui::Checkbox("Emulate Xbox Resolution", &g_bEmulateXboxRes)) { - TheseusSetProjectionDirty(); - SaveDesktopSettings(); - } + SettingCheckbox("EmulateXboxRes"); ImGui::SameLine(); ImGui::TextDisabled("(720x480 design space, keeps XIP scenes framed as authored)"); ImGui::Spacing(); ImGui::AlignTextToFramePadding(); ImGui::Text("Resolution:"); ImGui::SameLine(kLabelX); ImGui::SetNextItemWidth(kWidgetW); - int resIdx = GetResIndex(); - if (ImGui::Combo("##res", &resIdx, s_resLabels, s_resCount)) { - g_windowResolution = s_resValues[resIdx]; - g_displayChangeRequested = true; - SaveDesktopSettings(); - } + SettingCombo("Resolution", "##res"); ImGui::AlignTextToFramePadding(); ImGui::Text("Renderer:"); ImGui::SameLine(kLabelX); ImGui::SetNextItemWidth(kWidgetW); - static const char* s_rendererLabels[] = { - "Auto", - "Direct3D 11", - "Vulkan", - "OpenGL", - "Metal", - "OpenGL ES", - }; - if (ImGui::Combo("##renderer", &g_rendererPref, s_rendererLabels, 6)) { - SaveDesktopSettings(); - } + SettingCombo("Renderer", "##renderer"); ImGui::SameLine(); ImGui::TextDisabled("(?)"); if (ImGui::IsItemHovered()) @@ -755,10 +713,7 @@ void RenderSettingsWindow() { ImGui::AlignTextToFramePadding(); ImGui::Text("Display Mode:"); ImGui::SameLine(kLabelX); ImGui::SetNextItemWidth(kWidgetW); - if (ImGui::Combo("##dispmode", &g_windowMode, s_displayModeLabels, 3)) { - g_displayChangeRequested = true; - SaveDesktopSettings(); - } + SettingCombo("DisplayMode", "##dispmode"); ImGui::Spacing(); if (ImGui::Button("Restore Defaults")) { @@ -806,11 +761,7 @@ void RenderSettingsWindow() { ImGui::Separator(); ImGui::Spacing(); - extern bool g_useMilkdropViz; - if (ImGui::Checkbox("Use projectM visualizer (experimental)", - &g_useMilkdropViz)) { - SaveDesktopSettings(); - } + SettingCheckbox("UseMilkdropViz", "Use projectM visualizer (experimental)"); ImGui::SameLine(); ImGui::TextDisabled("(?)"); if (ImGui::IsItemHovered()) { @@ -820,11 +771,7 @@ void RenderSettingsWindow() { "fullscreens it once enabled."); } - extern bool g_showAlbumCover; - if (ImGui::Checkbox("Show album cover in orb", - &g_showAlbumCover)) { - SaveDesktopSettings(); - } + SettingCheckbox("ShowAlbumCover", "Show album cover in orb"); ImGui::SameLine(); ImGui::TextDisabled("(?)"); if (ImGui::IsItemHovered()) { diff --git a/theseus/desktop/preloader.cpp b/theseus/desktop/preloader.cpp index 65b5b1d..f6e2d2c 100644 --- a/theseus/desktop/preloader.cpp +++ b/theseus/desktop/preloader.cpp @@ -1,23 +1,18 @@ -// preloader.cpp: desktop preloader UI and XIP extraction. Shows the -// boot splash and unpacks bundled XIP archives into xboxfs/ on first -// run. Desktop-only. +// preloader.cpp: startup mode and XIP extraction. Development mode unpacks +// the bundled XIP archives into folders the first time. Desktop-only. #include "display.h" #include "app_paths.h" #include "preloader.h" #include "std.h" -#include "imgui.h" -#include "imgui_impl_sdl2.h" #include #include #include #include #include -#include #include "host.h" -#include // ============================================================================ // XIP Extraction (ported from xiptool.c) @@ -256,290 +251,20 @@ static bool Preloader_HasExtractedXIPs() { // StartupMode is stored in desktop.ini via g_startupMode (loaded by sdl_main.cpp) extern int g_startupMode; -extern void SaveDesktopSettings(); -// ============================================================================ -// Preloader UI -// ============================================================================ - -// Helper: draw a large styled button -static bool PreloaderButton(const char* label, const char* desc, float width, const ImVec4& color) { - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(color.x * 0.6f, color.y * 0.6f, color.z * 0.6f, 0.9f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(color.x * 0.8f, color.y * 0.8f, color.z * 0.8f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_ButtonActive, color); - ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 6.0f); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(16, 12)); - - bool clicked = ImGui::Button(label, ImVec2(width, 0)); - - ImGui::PopStyleVar(2); - ImGui::PopStyleColor(3); - - if (desc && desc[0]) { - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.5f, 0.6f, 0.5f, 1.0f)); - float textW = ImGui::CalcTextSize(desc).x; - ImGui::SetCursorPosX((ImGui::GetWindowWidth() - textW) * 0.5f); - ImGui::TextUnformatted(desc); - ImGui::PopStyleColor(); - } - - return clicked; +// Development mode runs the scenes from unpacked folders; unpack them the +// first time it's picked. +bool Preloader_EnsureExtracted() { + if (Preloader_HasExtractedXIPs()) return true; + char status[256] = ""; + Preloader_ExtractAllXIPs(AppPath("Data/Xips"), status, sizeof(status)); + fprintf(stderr, "[Preloader] %s\n", status); + return Preloader_HasExtractedXIPs(); } -bool RunPreloader(SDL_Window* window) { - // If user saved a preference, skip the UI entirely - if (g_startupMode == 1) return false; // dashboard mode - if (g_startupMode == 2) return true; // development mode - - enum { PAGE_MAIN, PAGE_EXTRACTED, PAGE_EXTRACTING, PAGE_EXTRACT_DONE } page = PAGE_MAIN; - bool done = false; - bool extractedMode = false; - bool rememberChoice = false; - char extractStatus[256] = ""; - bool hasExtracted = Preloader_HasExtractedXIPs(); - float animTime = 0.0f; - - while (!done) { - SDL_Event event; - while (SDL_PollEvent(&event)) { - ImGui_ImplSDL2_ProcessEvent(&event); - if (event.type == SDL_QUIT) exit(0); - if (event.type == SDL_KEYDOWN && event.key.keysym.sym == SDLK_ESCAPE) { - if (page == PAGE_EXTRACTED || page == PAGE_EXTRACT_DONE) - page = PAGE_MAIN; - } - } - - animTime += 0.016f; - - extern void ImGui_ImplBgfx_NewFrame(); - ImGui_ImplBgfx_NewFrame(); - ImGui_ImplSDL2_NewFrame(); - ImGui::NewFrame(); - - int winW, winH; - SDL_GetWindowSize(window, &winW, &winH); - - // Background glow effect - ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); - // Subtle green radial glow - float pulse = 0.5f + 0.15f * sinf(animTime * 0.8f); - ImVec2 center(winW * 0.5f, winH * 0.45f); - for (int r = 300; r > 0; r -= 4) { - float t = (float)r / 300.0f; - float a = (1.0f - t) * 0.08f * pulse; - bgDL->AddCircleFilled(center, (float)r, IM_COL32((int)(20 * a * 255), (int)(80 * a * 255), (int)(20 * a * 255), (int)(a * 255)), 64); - } - - // Centered panel - float panelW = 460, panelH = (page == PAGE_MAIN) ? 380 : 420; - ImGui::SetNextWindowPos(ImVec2(winW * 0.5f, winH * 0.5f), ImGuiCond_Always, ImVec2(0.5f, 0.5f)); - ImGui::SetNextWindowSize(ImVec2(panelW, panelH), ImGuiCond_Always); - - ImGuiWindowFlags flags = ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | - ImGuiWindowFlags_NoScrollbar; - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 16.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(36, 28)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 1.5f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.04f, 0.07f, 0.04f, 0.96f)); - ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.15f, 0.4f, 0.15f, 0.5f)); - - ImGui::Begin("##preloader", NULL, flags); - - // Title - { - ImGui::PushFont(NULL); // default font, just make it colored - const char* title = "UIX Desktop"; - float titleW = ImGui::CalcTextSize(title).x; - ImGui::SetCursorPosX((ImGui::GetWindowWidth() - titleW) * 0.5f); - float glow = 0.8f + 0.2f * sinf(animTime * 1.2f); - ImGui::TextColored(ImVec4(0.3f * glow, 1.0f * glow, 0.3f * glow, 1.0f), "%s", title); - ImGui::PopFont(); - } - - // Subtitle - { - const char* sub = "Select Startup Mode"; - float subW = ImGui::CalcTextSize(sub).x; - ImGui::SetCursorPosX((ImGui::GetWindowWidth() - subW) * 0.5f); - ImGui::TextColored(ImVec4(0.4f, 0.5f, 0.4f, 0.8f), "%s", sub); - } - - // Decorative line - ImGui::Spacing(); - { - ImVec2 p = ImGui::GetCursorScreenPos(); - float lineW = ImGui::GetContentRegionAvail().x; - ImDrawList* dl = ImGui::GetWindowDrawList(); - float cx = p.x + lineW * 0.5f; - // Gradient line from center outward - dl->AddRectFilledMultiColor( - ImVec2(cx - lineW * 0.4f, p.y), ImVec2(cx, p.y + 2), - IM_COL32(0, 80, 0, 0), IM_COL32(60, 200, 60, 200), - IM_COL32(60, 200, 60, 200), IM_COL32(0, 80, 0, 0)); - dl->AddRectFilledMultiColor( - ImVec2(cx, p.y), ImVec2(cx + lineW * 0.4f, p.y + 2), - IM_COL32(60, 200, 60, 200), IM_COL32(0, 80, 0, 0), - IM_COL32(0, 80, 0, 0), IM_COL32(60, 200, 60, 200)); - ImGui::Dummy(ImVec2(0, 8)); - } - - float btnWidth = ImGui::GetContentRegionAvail().x; - - if (page == PAGE_MAIN) { - ImGui::Spacing(); - ImGui::Spacing(); - - // Dashboard Mode button - if (PreloaderButton("Dashboard Mode", "Load the dashboard from XIP archives", btnWidth, - ImVec4(0.2f, 0.6f, 0.2f, 1.0f))) { - extractedMode = false; - if (rememberChoice) { g_startupMode = 1; SaveDesktopSettings(); } - done = true; - } - - ImGui::Spacing(); - ImGui::Spacing(); - ImGui::Spacing(); - - // Development Mode button - if (PreloaderButton("Development Mode", "Edit XAP scripts and preview changes live", btnWidth, - ImVec4(0.15f, 0.45f, 0.55f, 1.0f))) { - page = PAGE_EXTRACTED; - } - - // Remember choice checkbox - ImGui::Spacing(); - ImGui::Spacing(); - ImGui::SetCursorPosX((ImGui::GetWindowWidth() - 200) * 0.5f); - ImGui::Checkbox("Remember my choice", &rememberChoice); - - // Version info at bottom - ImGui::SetCursorPosY(ImGui::GetWindowHeight() - 44); - { - ImGui::Separator(); - ImGui::Spacing(); - const char* ver = "Change later in Settings"; - float verW = ImGui::CalcTextSize(ver).x; - ImGui::SetCursorPosX((ImGui::GetWindowWidth() - verW) * 0.5f); - ImGui::TextColored(ImVec4(0.3f, 0.4f, 0.3f, 0.6f), "%s", ver); - } - - } else if (page == PAGE_EXTRACTED) { - ImGui::Spacing(); - - // Back button - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.2f, 0.2f, 1.0f)); - if (ImGui::SmallButton("<< Back")) page = PAGE_MAIN; - ImGui::PopStyleColor(2); - - ImGui::Spacing(); - ImGui::Spacing(); - - // Extract XIPs button - if (PreloaderButton("Extract XIPs from Archives", "Unpack all .xip files into editable folders", btnWidth, - ImVec4(0.5f, 0.4f, 0.15f, 1.0f))) { - page = PAGE_EXTRACTING; - // Run extraction synchronously (it's fast, just file I/O) - Preloader_ExtractAllXIPs(AppPath("Data/Xips"), extractStatus, sizeof(extractStatus)); - hasExtracted = Preloader_HasExtractedXIPs(); - page = PAGE_EXTRACT_DONE; - } - - ImGui::Spacing(); - ImGui::Spacing(); - ImGui::Spacing(); - - // Load from extracted folder - { - bool canLoad = hasExtracted; - if (!canLoad) { - ImGui::PushStyleVar(ImGuiStyleVar_Alpha, 0.4f); - } - - if (PreloaderButton("Start Development Mode", - canLoad ? "Load scene from extracted XAP files" - : "No extracted XIPs found - extract first", - btnWidth, ImVec4(0.15f, 0.45f, 0.55f, 1.0f)) && canLoad) { - extractedMode = true; - if (rememberChoice) { g_startupMode = 2; SaveDesktopSettings(); } - done = true; - } - - if (!canLoad) { - ImGui::PopStyleVar(); - } - } - - // Status - if (hasExtracted) { - ImGui::Spacing(); - ImGui::Spacing(); - ImGui::TextColored(ImVec4(0.4f, 0.8f, 0.4f, 0.7f), " Extracted XIPs detected"); - } - - } else if (page == PAGE_EXTRACT_DONE) { - ImGui::Spacing(); - - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.2f, 0.2f, 1.0f)); - if (ImGui::SmallButton("<< Back")) page = PAGE_MAIN; - ImGui::PopStyleColor(2); - - ImGui::Spacing(); - ImGui::Spacing(); - - // Show extraction result - ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "Extraction Complete!"); - ImGui::Spacing(); - ImGui::TextColored(ImVec4(0.6f, 0.7f, 0.6f, 0.9f), "%s", extractStatus); - - ImGui::Spacing(); - ImGui::Spacing(); - ImGui::Spacing(); - - // Now they can load from the extracted folder - if (PreloaderButton("Load from Extracted Folder", - "Open XAP editor with live scene reload", - btnWidth, ImVec4(0.15f, 0.45f, 0.55f, 1.0f))) { - extractedMode = true; - done = true; - } - - ImGui::Spacing(); - ImGui::Spacing(); - - // Or go back and use compiled - if (PreloaderButton("Load Compiled XIPs Instead", - "Use original .xip archives", - btnWidth, ImVec4(0.2f, 0.6f, 0.2f, 1.0f))) { - extractedMode = false; - done = true; - } - } - - ImGui::End(); - ImGui::PopStyleColor(2); - ImGui::PopStyleVar(3); - - // Render - ImGui::Render(); - // Pixels here; winW/winH are points and drive the ImGui layout above. - int pw = 0, ph = 0; - Plat_GetDrawableSize(&pw, &ph); - if (pw <= 0 || ph <= 0) { pw = winW; ph = winH; } - bgfx::setViewRect(0, 0, 0, (uint16_t)pw, (uint16_t)ph); - bgfx::setViewClear(0, BGFX_CLEAR_COLOR, - (uint32_t)((5<<24) | (8<<16) | (5<<8) | 0xFF), - 1.0f, 0); - bgfx::touch(0); - extern void ImGui_ImplBgfx_RenderDrawData(ImDrawData*); - ImGui_ImplBgfx_RenderDrawData(ImGui::GetDrawData()); - bgfx::frame(); - } - - return extractedMode; +// The mode question itself is asked by the guide once the dashboard is up. +bool RunPreloader(SDL_Window*) { + if (g_startupMode != 2) return false; + Preloader_EnsureExtracted(); + return true; } diff --git a/theseus/desktop/preloader.h b/theseus/desktop/preloader.h index 32a5fdd..4427a80 100644 --- a/theseus/desktop/preloader.h +++ b/theseus/desktop/preloader.h @@ -4,8 +4,11 @@ #pragma once #include -// Run the preloader UI. Returns true if user chose extracted mode. +// True when this run is development mode (scenes from unpacked folders). bool RunPreloader(SDL_Window* window); +// Unpack the XIP archives for development mode if that hasn't happened yet. +bool Preloader_EnsureExtracted(); + // Extract a single XIP to a directory. Returns number of files extracted. int Preloader_ExtractXIP(const char* xipPath, const char* outDir); diff --git a/theseus/desktop/sdl_main.cpp b/theseus/desktop/sdl_main.cpp index ad408a7..7d3f486 100644 --- a/theseus/desktop/sdl_main.cpp +++ b/theseus/desktop/sdl_main.cpp @@ -28,6 +28,7 @@ #include "display.h" #include "esde.h" #include "esde_vgames.h" +#include "guide.h" #include "host.h" #include @@ -292,277 +293,11 @@ int g_startupMode = 0; // 0 = ask, 1 = dashboard, 2 = development bool g_bUseOnScreenKeyboard = false; // when true, ignore physical keyboard during keyboard popups bool g_bShowBootAnimation = true; // play xbox_boot.mp4 once at startup before the dashboard -// Legacy [Progressive] / [Dashboard Settings] sections, round-tripped through -// desktop.ini so the truncate-rewrite save doesn't drop them. -char g_use720p[8] = "Yes"; -char g_useProgressive[8] = "Yes"; -char g_currentSkin[64] = "Stock"; - class CKeyboard; extern CKeyboard* g_pActiveKeyboard; extern void Keyboard_InsertText(CKeyboard* pKb, const char* sz); extern void Keyboard_HandleKey(CKeyboard* pKb, int sdlKey); -void LoadDesktopSettings() { - FILE* fp = fopen(AppPath("Configs/desktop.ini"), "r"); - if (!fp) return; - char line[4096]; // xCloud refresh tokens run long - while (fgets(line, sizeof(line), fp)) { - char* nl = strchr(line, '\n'); if (nl) *nl = 0; - char* cr = strchr(line, '\r'); if (cr) *cr = 0; - if (strncmp(line, "XemuPath=", 9) == 0) - strncpy(s_xemuPath, line + 9, sizeof(s_xemuPath) - 1); - else if (strncmp(line, "QcowPath=", 9) == 0) - strncpy(g_qcowPath, line + 9, sizeof(g_qcowPath) - 1); - else if (strncmp(line, "SteamPath=", 10) == 0) - strncpy(s_steamPath, line + 10, sizeof(s_steamPath) - 1); - else if (strncmp(line, "EsdePath=", 9) == 0) - strncpy(s_esdePath, line + 9, sizeof(s_esdePath) - 1); - else if (strncmp(line, "ShowEsdeTab=", 12) == 0) - g_showEsdeTab = atoi(line + 12) != 0; - else if (strncmp(line, "RetroArchFullscreen=", 20) == 0) - g_retroarchFullscreen = atoi(line + 20) != 0; - else if (strncmp(line, "AudioBufferFrames=", 18) == 0) - g_audioBufferFrames = atoi(line + 18); - else if (strncmp(line, "RetroArchPath=", 14) == 0) - strncpy(s_retroarchPath, line + 14, sizeof(s_retroarchPath) - 1); - else if (strncmp(line, "ShowRetroArchTab=", 17) == 0) - g_showRetroArchTab = atoi(line + 17) != 0; - else if (strncmp(line, "ShowSteamTab=", 13) == 0) - g_showSteamTab = atoi(line + 13) != 0; - else if (strncmp(line, "CRT_Enabled=", 12) == 0) - g_crt.enabled = atoi(line + 12) != 0; - else if (strncmp(line, "CRT_Scanlines=", 14) == 0) - g_crt.scanlineIntensity = (float)atof(line + 14); - else if (strncmp(line, "CRT_Curvature=", 14) == 0) - g_crt.curvature = (float)atof(line + 14); - else if (strncmp(line, "CRT_Phosphor=", 13) == 0) - g_crt.phosphorMask = (float)atof(line + 13); - else if (strncmp(line, "CRT_Vignette=", 13) == 0) - g_crt.vignette = (float)atof(line + 13); - else if (strncmp(line, "CRT_Bloom=", 10) == 0) - g_crt.bloom = (float)atof(line + 10); - else if (strncmp(line, "CRT_Flicker=", 12) == 0) - g_crt.flickerAmount = (float)atof(line + 12); - else if (strncmp(line, "CRT_ColorBleed=", 15) == 0) - g_crt.colorBleed = (float)atof(line + 15); - else if (strncmp(line, "CRT_Brightness=", 15) == 0) - g_crt.brightness = (float)atof(line + 15); - else if (strncmp(line, "UseOnScreenKeyboard=", 20) == 0) - g_bUseOnScreenKeyboard = atoi(line + 20) != 0; - else if (strncmp(line, "ShowBootAnimation=", 18) == 0) - g_bShowBootAnimation = atoi(line + 18) != 0; - else if (strncmp(line, "Use 720p=", 9) == 0) { - strncpy(g_use720p, line + 9, sizeof(g_use720p) - 1); - g_use720p[sizeof(g_use720p) - 1] = 0; - } - else if (strncmp(line, "Use Progressive=", 16) == 0) { - strncpy(g_useProgressive, line + 16, sizeof(g_useProgressive) - 1); - g_useProgressive[sizeof(g_useProgressive) - 1] = 0; - } - else if (strncmp(line, "Current Skin=", 13) == 0) { - strncpy(g_currentSkin, line + 13, sizeof(g_currentSkin) - 1); - g_currentSkin[sizeof(g_currentSkin) - 1] = 0; - } - else if (strncmp(line, "StartupMode=", 12) == 0) { - if (strncmp(line + 12, "dashboard", 9) == 0) g_startupMode = 1; - else if (strncmp(line + 12, "development", 11) == 0) g_startupMode = 2; - else g_startupMode = 0; - } - else if (strncmp(line, "MusicRoot=", 10) == 0) - strncpy(g_musicRoot, line + 10, sizeof(g_musicRoot) - 1); - else if (strncmp(line, "MoviesRoot=", 11) == 0) - strncpy(g_moviesRoot, line + 11, sizeof(g_moviesRoot) - 1); - else if (strncmp(line, "TvRoot=", 7) == 0) - strncpy(g_tvRoot, line + 7, sizeof(g_tvRoot) - 1); - else if (strncmp(line, "TMDBKey=", 8) == 0) - strncpy(g_tmdbKey, line + 8, sizeof(g_tmdbKey) - 1); - else if (strncmp(line, "PlexToken=", 10) == 0) { - strncpy(g_plexToken, line + 10, sizeof(g_plexToken) - 1); - g_plexToken[sizeof(g_plexToken) - 1] = 0; - } - else if (strncmp(line, "PlexClientId=", 13) == 0) { - strncpy(g_plexClientId, line + 13, sizeof(g_plexClientId) - 1); - g_plexClientId[sizeof(g_plexClientId) - 1] = 0; - } - else if (strncmp(line, "JellyfinUrl=", 12) == 0) { - strncpy(g_jellyfinUrl, line + 12, sizeof(g_jellyfinUrl) - 1); - g_jellyfinUrl[sizeof(g_jellyfinUrl) - 1] = 0; - } - else if (strncmp(line, "JellyfinToken=", 14) == 0) { - strncpy(g_jellyfinToken, line + 14, sizeof(g_jellyfinToken) - 1); - g_jellyfinToken[sizeof(g_jellyfinToken) - 1] = 0; - } - else if (strncmp(line, "XcloudRefreshToken=", 19) == 0) { - strncpy(g_xcloudRefreshToken, line + 19, sizeof(g_xcloudRefreshToken) - 1); - g_xcloudRefreshToken[sizeof(g_xcloudRefreshToken) - 1] = 0; - } - else if (strncmp(line, "JellyfinUserId=", 15) == 0) { - strncpy(g_jellyfinUserId, line + 15, sizeof(g_jellyfinUserId) - 1); - g_jellyfinUserId[sizeof(g_jellyfinUserId) - 1] = 0; - } - else if (strncmp(line, "JellyfinClientId=", 17) == 0) { - strncpy(g_jellyfinClientId, line + 17, sizeof(g_jellyfinClientId) - 1); - g_jellyfinClientId[sizeof(g_jellyfinClientId) - 1] = 0; - } - else if (strncmp(line, "JellyfinUserName=", 17) == 0) { - strncpy(g_jellyfinUserName, line + 17, sizeof(g_jellyfinUserName) - 1); - g_jellyfinUserName[sizeof(g_jellyfinUserName) - 1] = 0; - } - else if (strncmp(line, "RomsDir=", 8) == 0) - strncpy(g_romsDir, line + 8, sizeof(g_romsDir) - 1); - else if (strncmp(line, "MSAA=", 5) == 0) { - int n = atoi(line + 5); - if (n == 0 || n == 2 || n == 4 || n == 8) - g_msaaSamples = n; - } - else if (strncmp(line, "EmulateXboxRes=", 15) == 0) { - g_bEmulateXboxRes = atoi(line + 15) != 0; - TheseusSetProjectionDirty(); - } - else if (strncmp(line, "PadMode=", 8) == 0) { - g_padModeEnabled = atoi(line + 8) != 0; - } - else if (strncmp(line, "MenuBarAutoHide=", 16) == 0) { - g_menuBarAutoHide = atoi(line + 16) != 0; - } - else if (strncmp(line, "Vsync=", 6) == 0) { - int n = atoi(line + 6); - if (n >= 0 && n <= 2) g_vsyncMode = n; - } - else if (strncmp(line, "FpsCap=", 7) == 0) { - int n = atoi(line + 7); - if (n >= 0) g_fpsCap = n; - } - else if (strncmp(line, "Hwdec=", 6) == 0) - g_hwdec = atoi(line + 6) != 0; - else if (strncmp(line, "MasterVolume=", 13) == 0) { - float v = (float)atof(line + 13); - if (v < 0.0f) v = 0.0f; if (v > 1.0f) v = 1.0f; - g_masterVolume = v; - } - else if (strncmp(line, "UseMilkdropViz=", 15) == 0) - g_useMilkdropViz = atoi(line + 15) != 0; - else if (strncmp(line, "ShowAlbumCover=", 15) == 0) - g_showAlbumCover = atoi(line + 15) != 0; - else if (strncmp(line, "Renderer=", 9) == 0) { - const char* v = line + 9; - // Order matters: opengles before opengl, metal before "" prefix. - if (strncmp(v, "auto", 4) == 0) g_rendererPref = 0; - else if (strncmp(v, "d3d11", 5) == 0) g_rendererPref = 1; - else if (strncmp(v, "vulkan", 6) == 0) g_rendererPref = 2; - else if (strncmp(v, "opengles", 8) == 0 || - strncmp(v, "gles", 4) == 0) g_rendererPref = 5; - else if (strncmp(v, "opengl", 6) == 0) g_rendererPref = 3; - else if (strncmp(v, "metal", 5) == 0) g_rendererPref = 4; - } - else if (strncmp(line, "Resolution=", 11) == 0) { - int n = atoi(line + 11); - if (n == 0 || n == 720 || n == 1080 || n == 1440 || n == 2160) - g_windowResolution = n; - } - else if (strncmp(line, "DisplayMode=", 12) == 0) { - int n = atoi(line + 12); - if (n >= 0 && n <= 2) g_windowMode = n; - } - } - fclose(fp); -} - -// Refresh the legacy sections from disk so a Save doesn't overwrite values -// the dashboard wrote between Load and Save (e.g. skin change via XAP). -static void RereadLegacyFromDisk() { - FILE* fp = fopen(AppPath("Configs/desktop.ini"), "r"); - if (!fp) return; - char line[1024]; - while (fgets(line, sizeof(line), fp)) { - char* nl = strchr(line, '\n'); if (nl) *nl = 0; - char* cr = strchr(line, '\r'); if (cr) *cr = 0; - if (strncmp(line, "Use 720p=", 9) == 0) { - strncpy(g_use720p, line + 9, sizeof(g_use720p) - 1); - g_use720p[sizeof(g_use720p) - 1] = 0; - } else if (strncmp(line, "Use Progressive=", 16) == 0) { - strncpy(g_useProgressive, line + 16, sizeof(g_useProgressive) - 1); - g_useProgressive[sizeof(g_useProgressive) - 1] = 0; - } - } - fclose(fp); -} - -void SaveDesktopSettings() { - Plat_MkdirP(AppPath_Tree("Configs")); - RereadLegacyFromDisk(); - SafeSave save(AppPath("Configs/desktop.ini")); - FILE* fp = save.f; - if (!fp) return; - fprintf(fp, "[Desktop]\n"); - fprintf(fp, "XemuPath=%s\n", s_xemuPath); - fprintf(fp, "QcowPath=%s\n", g_qcowPath); - fprintf(fp, "SteamPath=%s\n", s_steamPath); - fprintf(fp, "RetroArchPath=%s\n", s_retroarchPath); - fprintf(fp, "EsdePath=%s\n", s_esdePath); - fprintf(fp, "ShowEsdeTab=%d\n", g_showEsdeTab ? 1 : 0); - fprintf(fp, "RetroArchFullscreen=%d\n", g_retroarchFullscreen ? 1 : 0); - fprintf(fp, "AudioBufferFrames=%d\n", g_audioBufferFrames); - fprintf(fp, "ShowRetroArchTab=%d\n", g_showRetroArchTab ? 1 : 0); - fprintf(fp, "ShowSteamTab=%d\n", g_showSteamTab ? 1 : 0); - fprintf(fp, "StartupMode=%s\n", g_startupMode == 2 ? "development" : g_startupMode == 1 ? "dashboard" : ""); - fprintf(fp, "UseOnScreenKeyboard=%d\n", g_bUseOnScreenKeyboard ? 1 : 0); - fprintf(fp, "ShowBootAnimation=%d\n", g_bShowBootAnimation ? 1 : 0); - fprintf(fp, "MSAA=%d\n", g_msaaSamples); - fprintf(fp, "Vsync=%d\n", g_vsyncMode); - fprintf(fp, "MenuBarAutoHide=%d\n", g_menuBarAutoHide ? 1 : 0); - fprintf(fp, "PadMode=%d\n", g_padModeEnabled ? 1 : 0); - fprintf(fp, "EmulateXboxRes=%d\n", g_bEmulateXboxRes ? 1 : 0); - fprintf(fp, "FpsCap=%d\n", g_fpsCap); - fprintf(fp, "Hwdec=%d\n", g_hwdec ? 1 : 0); - fprintf(fp, "MasterVolume=%.3f\n", g_masterVolume); - fprintf(fp, "UseMilkdropViz=%d\n", g_useMilkdropViz ? 1 : 0); - fprintf(fp, "ShowAlbumCover=%d\n", g_showAlbumCover ? 1 : 0); - { - const char* rname = (g_rendererPref == 1) ? "d3d11" - : (g_rendererPref == 2) ? "vulkan" - : (g_rendererPref == 3) ? "opengl" - : (g_rendererPref == 4) ? "metal" - : (g_rendererPref == 5) ? "opengles" - : "auto"; - fprintf(fp, "Renderer=%s\n", rname); - } - fprintf(fp, "Resolution=%d\n", g_windowResolution); - fprintf(fp, "DisplayMode=%d\n", g_windowMode); - fprintf(fp, "\n[CRT]\n"); - fprintf(fp, "CRT_Enabled=%d\n", g_crt.enabled ? 1 : 0); - fprintf(fp, "CRT_Scanlines=%.3f\n", g_crt.scanlineIntensity); - fprintf(fp, "CRT_Curvature=%.3f\n", g_crt.curvature); - fprintf(fp, "CRT_Phosphor=%.3f\n", g_crt.phosphorMask); - fprintf(fp, "CRT_Vignette=%.3f\n", g_crt.vignette); - fprintf(fp, "CRT_Bloom=%.3f\n", g_crt.bloom); - fprintf(fp, "CRT_Flicker=%.3f\n", g_crt.flickerAmount); - fprintf(fp, "CRT_ColorBleed=%.3f\n", g_crt.colorBleed); - fprintf(fp, "CRT_Brightness=%.3f\n", g_crt.brightness); - fprintf(fp, "\n[Library]\n"); - fprintf(fp, "MusicRoot=%s\n", g_musicRoot); - fprintf(fp, "MoviesRoot=%s\n", g_moviesRoot); - fprintf(fp, "TvRoot=%s\n", g_tvRoot); - fprintf(fp, "TMDBKey=%s\n", g_tmdbKey); - fprintf(fp, "RomsDir=%s\n", g_romsDir); - fprintf(fp, "PlexToken=%s\n", g_plexToken); - fprintf(fp, "PlexClientId=%s\n", g_plexClientId); - fprintf(fp, "XcloudRefreshToken=%s\n", g_xcloudRefreshToken); - fprintf(fp, "JellyfinUrl=%s\n", g_jellyfinUrl); - fprintf(fp, "JellyfinToken=%s\n", g_jellyfinToken); - fprintf(fp, "JellyfinUserId=%s\n", g_jellyfinUserId); - fprintf(fp, "JellyfinClientId=%s\n", g_jellyfinClientId); - fprintf(fp, "JellyfinUserName=%s\n", g_jellyfinUserName); - // Legacy Q:\System\config.ini sections (aliased to this file by xbox_path.h). - fprintf(fp, "\n[Progressive]\n"); - fprintf(fp, "Use 720p=%s\n", g_use720p); - fprintf(fp, "Use Progressive=%s\n", g_useProgressive); - fprintf(fp, "\n[Dashboard Settings]\n"); - fprintf(fp, "Current Skin=%s\n", g_currentSkin); - save.Commit(); -} - // Helper: returns true when ImGui has an active text input widget (Title Maker, inspector search, etc.) static bool s_imguiHasRendered = false; static void BgfxApplyReset(); @@ -665,22 +400,16 @@ bool g_showMenuBar = true; // toggled by F10 / View > Hide Menu Bar / --no-too // units, so the trigger zone is the same physical size on every DPI. static bool MenuBarVisible() { + // The guide is the menu now; the tool bar is for development. + if (!g_extractedMode && g_startupMode != 2) return false; if (!g_showMenuBar) return false; if (!g_menuBarAutoHide) return true; static bool s_shown = false; ImGuiIO& io = ImGui::GetIO(); - // No pointer on a pad, so no top edge to reach for. LT+B is the same - // chord as the Xbox overlay; here the overlay is ImGui. - if (g_padModeEnabled && - ImGui::IsKeyDown(ImGuiKey_GamepadL2) && - ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight, false)) { - g_padModeActive = !g_padModeActive; - // Give nav somewhere to land, otherwise the bar is up but dead. - if (g_padModeActive) ImGui::SetWindowFocus("##MainMenuBar"); - } - // Pad mode has its own menu, so the pointer bar steps aside. + // Pad mode (the guide's Desktop Tools) has its own menu, so the pointer + // bar steps aside. if (g_padModeActive) return false; @@ -937,6 +666,12 @@ static void PreSwapOverlays() { ImGui::End(); } + { + int gw = 0, gh = 0; + Plat_GetDrawableSize(&gw, &gh); + Guide_Render(gw, gh); + } + ImGui::Render(); extern void ImGui_ImplBgfx_RenderDrawData(ImDrawData*); ImGui_ImplBgfx_RenderDrawData(ImGui::GetDrawData()); @@ -1003,9 +738,9 @@ static void BgfxApplyReset() { if (w <= 0 || h <= 0) return; bgfx::reset((uint32_t)w, (uint32_t)h, BgfxResetFlags()); bgfx::setViewRect(0, 0, 0, (uint16_t)w, (uint16_t)h); - // ImGui lives on view 2 (sharp on top of any CRT pass on view 1). + // ImGui lives on view 3, over the guide on 2 and any CRT pass on 1. // bgfx skips views with no rect set, so size it to the backbuffer. - bgfx::setViewRect(2, 0, 0, (uint16_t)w, (uint16_t)h); + bgfx::setViewRect(3, 0, 0, (uint16_t)w, (uint16_t)h); g_pp.BackBufferWidth = w; g_pp.BackBufferHeight = h; g_nViewWidth = (float)w; @@ -1097,7 +832,6 @@ void RenderXcloudOverlay() #if defined(THESEUS_HAVE_WEBRTC) #include "xcloud_video.h" -#include "safe_save.h" #include @@ -1397,7 +1131,7 @@ int main(int argc, char* argv[]) { // soon as we have one; placeholders here for the first // frame. bgfx::setViewRect(0, 0, 0, (uint16_t)initW, (uint16_t)initH); - bgfx::setViewRect(2, 0, 0, (uint16_t)initW, (uint16_t)initH); // ImGui + bgfx::setViewRect(3, 0, 0, (uint16_t)initW, (uint16_t)initH); // ImGui // The dashboard renders D3D-style: it expects every draw // to land on the framebuffer in the order it was issued, @@ -1538,11 +1272,12 @@ int main(int argc, char* argv[]) { // View layout under bgfx: // view 0 = dashboard 3D scene (FF emulator submits) // view 1 = CRT post-process blit (only touched when g_crt.enabled) - // view 2 = ImGui (menu bar, panels, libmpv via ImGui::AddImage) - // ImGui always lives on view 2 so it draws sharp on top of the - // post-processed scene, matching the GL backend's behavior. + // view 2 = the guide (guide.cpp) + // view 3 = ImGui (menu bar, panels, libmpv via ImGui::AddImage) + // ImGui stays on top so it draws sharp over the post-processed + // scene and the guide. extern bool ImGui_ImplBgfx_Init(unsigned char); - if (!ImGui_ImplBgfx_Init(2)) + if (!ImGui_ImplBgfx_Init(3)) fprintf(stderr, "ImGui_ImplBgfx_Init failed!\n"); } @@ -1669,6 +1404,7 @@ int main(int argc, char* argv[]) { while (SDL_PollEvent(&event)) { // Let ImGui handle events first ImGui_ImplSDL2_ProcessEvent(&event); + Guide_HandleEvent(&event); switch (event.type) { case SDL_QUIT: @@ -1992,6 +1728,8 @@ int main(int argc, char* argv[]) { ApplyEffectiveMute(); } + Guide_Frame(); + if (!g_mediaFullscreen && !g_xcloudFullscreen) { FramePhaseScope advance(FP_ADVANCE); Advance(); @@ -2137,6 +1875,7 @@ int main(int argc, char* argv[]) { // Cleanup XapEditor_Cleanup(); + Guide_Shutdown(); { extern void ImGui_ImplBgfx_Shutdown(); ImGui_ImplBgfx_Shutdown(); diff --git a/theseus/desktop/settings_schema.cpp b/theseus/desktop/settings_schema.cpp new file mode 100644 index 0000000..f84e191 --- /dev/null +++ b/theseus/desktop/settings_schema.cpp @@ -0,0 +1,388 @@ +// settings_schema.cpp: load, save and edit settings through a SettingsTable. + +#include "settings_schema.h" +#include "safe_save.h" +#include +#include +#include +#include +#include +#include + +static int EnumIndexOf(const SettingDef& d, int v) +{ + for (int i = 0; i < d.count; i++) + if (d.values[i] == v) return i; + return -1; +} + +// A token can carry aliases after '|'; the first one is what gets written. +static size_t TokenMatch(const char* tok, const char* text) +{ + size_t best = 0; + while (tok && *tok) { + const char* bar = strchr(tok, '|'); + size_t n = bar ? (size_t)(bar - tok) : strlen(tok); + if (n > best && strncmp(text, tok, n) == 0) best = n; + tok = bar ? bar + 1 : NULL; + } + return best; +} + +static void TokenFirst(const char* tok, char* out, size_t cch) +{ + const char* bar = strchr(tok, '|'); + size_t n = bar ? (size_t)(bar - tok) : strlen(tok); + if (n >= cch) n = cch - 1; + memcpy(out, tok, n); + out[n] = 0; +} + +static float Clamp(const SettingDef& d, float v) +{ + if (d.minV < d.maxV) { + if (v < d.minV) v = d.minV; + if (v > d.maxV) v = d.maxV; + } + return v; +} + +bool Setting_Parse(const SettingDef& d, const char* text) +{ + switch (d.kind) { + case SK_BOOL: + *(bool*)d.ptr = atoi(text) != 0; + return true; + case SK_INT: { + int n = atoi(text); + if (d.flags & SF_CLAMP) n = (int)Clamp(d, (float)n); + *(int*)d.ptr = n; + return true; + } + case SK_FLOAT: { + float v = (float)atof(text); + *(float*)d.ptr = (d.flags & SF_CLAMP) ? Clamp(d, v) : v; + return true; + } + case SK_PATH: + case SK_STRING: { + char* buf = (char*)d.ptr; + strncpy(buf, text, d.size - 1); + buf[d.size - 1] = 0; + return true; + } + case SK_ENUM: { + if (d.tokens) { + int pick = -1, blank = -1; + size_t best = 0; + for (int i = 0; i < d.count; i++) { + if (!d.tokens[i][0]) { blank = i; continue; } + size_t m = TokenMatch(d.tokens[i], text); + if (m > best) { best = m; pick = i; } + } + if (pick < 0) pick = blank; + if (pick < 0) return false; + *(int*)d.ptr = d.values[pick]; + return true; + } + int n = atoi(text); + if (EnumIndexOf(d, n) >= 0 || + (!(d.flags & SF_STRICT) && d.minV < d.maxV && n >= d.minV && n <= d.maxV)) { + *(int*)d.ptr = n; + return true; + } + return false; + } + } + return false; +} + +void Setting_Format(const SettingDef& d, char* out, size_t cch) +{ + switch (d.kind) { + case SK_BOOL: snprintf(out, cch, "%d", *(bool*)d.ptr ? 1 : 0); break; + case SK_INT: snprintf(out, cch, "%d", *(int*)d.ptr); break; + case SK_FLOAT: snprintf(out, cch, d.fmt ? d.fmt : "%.3f", *(float*)d.ptr); break; + case SK_PATH: + case SK_STRING: snprintf(out, cch, "%s", (const char*)d.ptr); break; + case SK_ENUM: { + int v = *(int*)d.ptr; + int i = EnumIndexOf(d, v); + if (d.tokens && i >= 0) TokenFirst(d.tokens[i], out, cch); + else snprintf(out, cch, "%d", v); + break; + } + } +} + +int Setting_EnumIndex(const SettingDef& d) +{ + if (d.kind != SK_ENUM) return -1; + return EnumIndexOf(d, *(int*)d.ptr); +} + +void Setting_Display(const SettingDef& d, char* out, size_t cch) +{ + switch (d.kind) { + case SK_BOOL: snprintf(out, cch, "%s", *(bool*)d.ptr ? "On" : "Off"); break; + case SK_ENUM: { + int i = Setting_EnumIndex(d); + if (i >= 0 && d.labels) snprintf(out, cch, "%s", d.labels[i]); + else snprintf(out, cch, "%d", *(int*)d.ptr); + break; + } + case SK_INT: snprintf(out, cch, "%d", *(int*)d.ptr); break; + case SK_FLOAT: + if (d.flags & SF_PERCENT) + snprintf(out, cch, "%d%%", (int)(*(float*)d.ptr * 100.0f + 0.5f)); + else + snprintf(out, cch, "%.2f", *(float*)d.ptr); + break; + case SK_PATH: + case SK_STRING: + if (d.flags & SF_SECRET) snprintf(out, cch, "%s", ((const char*)d.ptr)[0] ? "Set" : "Not set"); + else snprintf(out, cch, "%s", ((const char*)d.ptr)[0] ? (const char*)d.ptr : "Not set"); + break; + } +} + +bool Setting_IsDefault(const SettingDef& d) +{ + char cur[2048]; + Setting_Format(d, cur, sizeof(cur)); + return strcmp(cur, d.def ? d.def : "") == 0; +} + +bool Setting_Cycle(const SettingDef& d, int dir) +{ + switch (d.kind) { + case SK_BOOL: + *(bool*)d.ptr = !*(bool*)d.ptr; + return true; + case SK_ENUM: { + if (d.count <= 1) return false; + int i = Setting_EnumIndex(d); + if (i < 0) i = dir > 0 ? -1 : 0; + i = (i + dir + d.count) % d.count; + *(int*)d.ptr = d.values[i]; + return true; + } + case SK_INT: { + int v = *(int*)d.ptr, n = v + (int)(d.step > 0 ? d.step : 1) * dir; + if (d.minV < d.maxV) { if (n < d.minV) n = (int)d.minV; if (n > d.maxV) n = (int)d.maxV; } + *(int*)d.ptr = n; + return n != v; + } + case SK_FLOAT: { + float v = *(float*)d.ptr; + float st = d.step > 0 ? d.step : 0.05f; + // Land on the step grid so repeated presses read as round numbers. + float n = roundf((v + st * dir) / st) * st; + if (d.minV < d.maxV) { if (n < d.minV) n = d.minV; if (n > d.maxV) n = d.maxV; } + *(float*)d.ptr = n; + return n != v; + } + default: + return false; + } +} + +bool Setting_SetText(const SettingDef& d, const char* text) +{ + char before[2048]; + Setting_Format(d, before, sizeof(before)); + if (!Setting_Parse(d, text)) return false; + char after[2048]; + Setting_Format(d, after, sizeof(after)); + return strcmp(before, after) != 0; +} + +void Setting_Reset(const SettingDef& d) +{ + Setting_Parse(d, d.def ? d.def : ""); +} + +static const SettingDef* FindIn(const SettingsTable& t, const char* key, size_t n) +{ + for (int i = 0; i < t.count; i++) + if (strlen(t.defs[i].key) == n && strncmp(t.defs[i].key, key, n) == 0) + return &t.defs[i]; + return NULL; +} + +static void StripEol(char* s) +{ + char* p = strchr(s, '\n'); if (p) *p = 0; + p = strchr(s, '\r'); if (p) *p = 0; +} + +static bool ReadLines(const char* path, std::vector& lines) +{ + FILE* fp = fopen(path, "r"); + if (!fp) return false; + char line[4096]; // xCloud refresh tokens run long + while (fgets(line, sizeof(line), fp)) { + StripEol(line); + lines.push_back(line); + } + fclose(fp); + return true; +} + +// What a shared row held when we last read or wrote the file, so a save can +// tell our change from the dashboard's. +struct SyncedValue { const SettingDef* def; std::string text; }; +static std::vector s_synced; + +static void NoteSynced(const SettingDef& d) +{ + char v[2048]; + Setting_Format(d, v, sizeof(v)); + for (size_t i = 0; i < s_synced.size(); i++) + if (s_synced[i].def == &d) { s_synced[i].text = v; return; } + SyncedValue sv = { &d, v }; + s_synced.push_back(sv); +} + +static bool ChangedHere(const SettingDef& d) +{ + char v[2048]; + Setting_Format(d, v, sizeof(v)); + for (size_t i = 0; i < s_synced.size(); i++) + if (s_synced[i].def == &d) return s_synced[i].text != v; + return !Setting_IsDefault(d); +} + +bool Settings_Load(const SettingsTable& t, const char* path) +{ + std::vector lines; + if (!ReadLines(path, lines)) return false; + for (size_t i = 0; i < lines.size(); i++) { + const char* s = lines[i].c_str(); + const char* eq = strchr(s, '='); + if (!eq) continue; + const SettingDef* d = FindIn(t, s, (size_t)(eq - s)); + if (d) Setting_Parse(*d, eq + 1); + } + for (int i = 0; i < t.count; i++) + if (t.defs[i].flags & SF_SHARED) NoteSynced(t.defs[i]); + return true; +} + +static std::string SectionOf(const std::string& line) +{ + if (line.size() < 2 || line[0] != '[') return std::string(); + size_t close = line.find(']'); + return close == std::string::npos ? std::string() : line.substr(1, close - 1); +} + +static bool IsBlank(const std::string& s) +{ + for (size_t i = 0; i < s.size(); i++) + if (s[i] != ' ' && s[i] != '\t') return false; + return true; +} + +bool Settings_Save(const SettingsTable& t, const char* path) +{ + std::vector in, out; + ReadLines(path, in); + + std::vector written(t.count, 0); + // Where each section's first block ends: after its last non-blank line. + std::vector secNames; + std::vector secEnd; + int curSec = -1; + char val[4096]; + + for (size_t i = 0; i < in.size(); i++) { + const std::string& line = in[i]; + std::string sec = SectionOf(line); + if (!sec.empty()) { + curSec = -1; + for (size_t k = 0; k < secNames.size(); k++) + if (secNames[k] == sec) { curSec = -2; break; } // repeat block, don't extend + if (curSec == -1) { + secNames.push_back(sec); + secEnd.push_back(out.size() + 1); + curSec = (int)secNames.size() - 1; + } + out.push_back(line); + continue; + } + + size_t eq = line.find('='); + const SettingDef* d = (eq != std::string::npos && line[0] != ';' && line[0] != '#') + ? FindIn(t, line.c_str(), eq) : NULL; + if (d) { + int idx = (int)(d - t.defs); + if (written[idx]) continue; // a duplicate would shadow the real value + written[idx] = 1; + if ((d->flags & SF_SHARED) && !ChangedHere(*d)) { + Setting_Parse(*d, line.c_str() + eq + 1); + out.push_back(line); + } else { + Setting_Format(*d, val, sizeof(val)); + out.push_back(std::string(d->key) + "=" + val); + } + } else { + out.push_back(line); + } + if (curSec >= 0 && !IsBlank(out.back())) secEnd[curSec] = out.size(); + } + + // Missing keys go at the end of their section, or a new one at the end. + struct Insert { size_t at; std::vector lines; }; + std::vector inserts; + std::vector newSecs; + std::vector > newSecLines; + for (int i = 0; i < t.count; i++) { + if (written[i]) continue; + const SettingDef& d = t.defs[i]; + Setting_Format(d, val, sizeof(val)); + std::string kv = std::string(d.key) + "=" + val; + + int found = -1; + for (size_t k = 0; k < secNames.size(); k++) + if (secNames[k] == d.section) { found = (int)k; break; } + if (found >= 0) { + Insert* ins = NULL; + for (size_t k = 0; k < inserts.size(); k++) + if (inserts[k].at == secEnd[found]) ins = &inserts[k]; + if (!ins) { inserts.push_back(Insert()); ins = &inserts.back(); ins->at = secEnd[found]; } + ins->lines.push_back(kv); + continue; + } + int ns = -1; + for (size_t k = 0; k < newSecs.size(); k++) + if (newSecs[k] == d.section) { ns = (int)k; break; } + if (ns < 0) { + newSecs.push_back(d.section); + newSecLines.push_back(std::vector()); + ns = (int)newSecs.size() - 1; + } + newSecLines[ns].push_back(kv); + } + + // Back to front so earlier positions stay valid. + for (size_t a = 0; a < inserts.size(); a++) + for (size_t b = a + 1; b < inserts.size(); b++) + if (inserts[b].at > inserts[a].at) std::swap(inserts[a], inserts[b]); + for (size_t k = 0; k < inserts.size(); k++) + out.insert(out.begin() + inserts[k].at, inserts[k].lines.begin(), inserts[k].lines.end()); + + for (size_t k = 0; k < newSecs.size(); k++) { + if (!out.empty() && !IsBlank(out.back())) out.push_back(""); + out.push_back("[" + newSecs[k] + "]"); + out.insert(out.end(), newSecLines[k].begin(), newSecLines[k].end()); + } + + for (int i = 0; i < t.count; i++) + if (t.defs[i].flags & SF_SHARED) NoteSynced(t.defs[i]); + + SafeSave save(path); + if (!save.f) return false; + for (size_t i = 0; i < out.size(); i++) + fprintf(save.f, "%s\n", out[i].c_str()); + return save.Commit(); +} diff --git a/theseus/desktop/settings_schema.h b/theseus/desktop/settings_schema.h new file mode 100644 index 0000000..54d80bf --- /dev/null +++ b/theseus/desktop/settings_schema.h @@ -0,0 +1,67 @@ +// settings_schema.h: one table describes every desktop.ini setting, and +// load, save and the settings screens all work from it. +#pragma once +#include + +enum SettingKind { SK_BOOL, SK_ENUM, SK_INT, SK_FLOAT, SK_PATH, SK_STRING }; + +enum SettingFlags { + SF_RESTART = 1 << 0, // takes effect on next launch + SF_SECRET = 1 << 1, // tokens and keys: never shown, never copied + SF_HIDDEN = 1 << 2, // kept in the file, left off the settings screens + SF_SHARED = 1 << 3, // the dashboard writes it too; the file wins unless we changed it + SF_STRICT = 1 << 4, // enum: refuse values that aren't in the list + SF_PERCENT = 1 << 5, // 0..1 float shown as a percentage + SF_CLAMP = 1 << 6, // pull loaded values into minV..maxV +}; + +struct SettingDef { + const char* key; // name in the file + const char* section; // [section] it's written under + const char* page; // settings screen it shows on (General, Display...) + const char* label; + SettingKind kind; + void* ptr; // bool / int / float / char[size] + int size; // char buffer size for paths and strings + const char* def; // default, as file text + unsigned flags; + // Enums: values[] are the stored ints, labels[] what you see, tokens[] + // the file text when it isn't just the number. + int count; + const int* values; + const char* const* labels; + const char* const* tokens; + float minV, maxV, step; // UI range and step; enum: loose load range + const char* fmt; // float file format, "%.3f" when null + void (*apply)(); // runs after a change from a settings screen +}; + +struct SettingsTable { + const SettingDef* defs; + int count; +}; + +// The desktop table (desktop_settings.cpp). +const SettingsTable& DesktopSettings_Table(); +const SettingDef* DesktopSettings_Find(const char* key); + +// Text <-> value. Parse returns false and leaves the value alone when the +// text isn't acceptable. +bool Setting_Parse(const SettingDef& d, const char* text); +void Setting_Format(const SettingDef& d, char* out, size_t cch); +void Setting_Display(const SettingDef& d, char* out, size_t cch); +int Setting_EnumIndex(const SettingDef& d); +bool Setting_IsDefault(const SettingDef& d); + +// Step a bool / enum / int / float. dir is +1 or -1. Returns true on change. +bool Setting_Cycle(const SettingDef& d, int dir); +bool Setting_SetText(const SettingDef& d, const char* text); +void Setting_Reset(const SettingDef& d); + +// Reads every known key; the rest of the file is ignored. +bool Settings_Load(const SettingsTable& t, const char* path); + +// Rewrites known keys in place and keeps every other line (unknown keys, +// comments, the dashboard's own sections). Keys missing from the file land +// at the end of their section. +bool Settings_Save(const SettingsTable& t, const char* path); diff --git a/theseus/desktop/shaders/fs_guide.sc b/theseus/desktop/shaders/fs_guide.sc new file mode 100644 index 0000000..3308a84 --- /dev/null +++ b/theseus/desktop/shaders/fs_guide.sc @@ -0,0 +1,48 @@ +$input v_texcoord0, v_shape, v_edge, v_color0 + +// The guide's quads. v_shape is the point from the shape's middle and its +// half size, v_edge the corner radius and outline thickness (0 filled). +// A radius of -4 marks the plasma, a texture coordinate of 0 or more a glyph. + +#include + +SAMPLER2D(s_atlas, 0); +SAMPLER2D(s_plasma, 1); + +uniform vec4 u_plasmaShift; +uniform vec4 u_plasmaThread; + +void main() +{ + // Sampled up front so no texture read sits in a branch. + float glyph = texture2D(s_atlas, v_texcoord0).r; + vec4 t0 = texture2D(s_plasma, v_texcoord0); + vec4 t1 = texture2D(s_plasma, v_texcoord0 * u_plasmaShift.z + u_plasmaShift.xy); + + if (v_edge.x < -3.5) + { + vec2 q = abs(v_shape.xy) - v_shape.zw + vec2_splat(v_edge.y); + float d = length(max(q, vec2_splat(0.0))) + min(max(q.x, q.y), 0.0) - v_edge.y; + float a0 = t0.a * dot(t0.rgb, vec3_splat(0.3333)); + float a1 = t1.a * dot(t1.rgb, vec3_splat(0.3333)); + float thread = pow(clamp(1.0 - abs(a0 - a1) * 3.0, 0.0, 1.0), 8.0); + vec3 color = v_color0.rgb * (0.8 + 0.6 * a0 * a1) + u_plasmaThread.rgb * thread * 0.18; + gl_FragColor = vec4(color, v_color0.a * clamp(0.5 - d, 0.0, 1.0) * (0.92 + 0.08 * thread)); + return; + } + + float alpha; + if (v_texcoord0.x >= 0.0) + { + alpha = glyph; + } + else + { + vec2 q = abs(v_shape.xy) - v_shape.zw + vec2_splat(v_edge.x); + float d = length(max(q, vec2_splat(0.0))) + min(max(q.x, q.y), 0.0) - v_edge.x; + alpha = clamp(0.5 - d, 0.0, 1.0); + if (v_edge.y > 0.0) + alpha *= clamp(d + v_edge.y + 0.5, 0.0, 1.0); + } + gl_FragColor = vec4(v_color0.rgb, v_color0.a * alpha); +} diff --git a/theseus/desktop/shaders/varying.def.sc b/theseus/desktop/shaders/varying.def.sc index 6f69943..2a92dd2 100644 --- a/theseus/desktop/shaders/varying.def.sc +++ b/theseus/desktop/shaders/varying.def.sc @@ -1,8 +1,12 @@ vec4 v_color0 : COLOR0 = vec4(1.0, 0.0, 0.0, 1.0); vec2 v_texcoord0 : TEXCOORD0 = vec2(0.0, 0.0); +vec4 v_shape : TEXCOORD1 = vec4(0.0, 0.0, 0.0, 0.0); +vec4 v_edge : TEXCOORD2 = vec4(0.0, 0.0, 0.0, 0.0); vec3 a_position : POSITION; vec3 a_normal : NORMAL; vec4 a_color0 : COLOR0; vec4 a_color1 : COLOR1; vec2 a_texcoord0 : TEXCOORD0; +vec4 a_texcoord1 : TEXCOORD1; +vec2 a_texcoord2 : TEXCOORD2; diff --git a/theseus/desktop/shaders/vs_guide.sc b/theseus/desktop/shaders/vs_guide.sc new file mode 100644 index 0000000..009a5bd --- /dev/null +++ b/theseus/desktop/shaders/vs_guide.sc @@ -0,0 +1,18 @@ +$input a_position, a_texcoord0, a_texcoord1, a_texcoord2, a_color0 +$output v_texcoord0, v_shape, v_edge, v_color0 + +// The guide's quads (ui_overlay.cpp), already in window pixels from the top +// left. u_guideScale.xy turns pixels into clip space. + +#include + +uniform vec4 u_guideScale; + +void main() +{ + gl_Position = vec4(a_position.xy * u_guideScale.xy + vec2(-1.0, 1.0), 0.0, 1.0); + v_texcoord0 = a_texcoord0; + v_shape = a_texcoord1; + v_edge = vec4(a_texcoord2, 0.0, 0.0); + v_color0 = a_color0; +} diff --git a/theseus/desktop/title_maker.cpp b/theseus/desktop/title_maker.cpp index 259bb2d..722e940 100644 --- a/theseus/desktop/title_maker.cpp +++ b/theseus/desktop/title_maker.cpp @@ -15,6 +15,7 @@ #include "xiso.h" #include "launchers/steam.h" #include "launchers/retroarch.h" +#include "launchers/xemu.h" #include "imgui.h" #include "imfilebrowser.h" #include "stb_image.h" @@ -22,6 +23,11 @@ #include #include #include +#include +#include +#include +#include +#include #include "host.h" #include "safe_save.h" @@ -244,16 +250,17 @@ static void TM_RAImportItem(const char* label, const char* path, ctx->added++; } -// Read Icons.ini into parallel key/value arrays. Returns entry count. -#define TM_MAX_ICONS 512 -static int TM_ReadIconsIni(char keys[][128], char vals[][128]) { - int count = 0; +// Icons.ini as name -> titleID pairs, as many as the file holds. +struct TmIconEntry { char key[128]; char val[128]; }; + +static std::vector TM_ReadIconsIni() { + std::vector out; const char* path = AppPath("Configs/Icons.ini"); FILE* fp = fopen(path, "r"); if (!fp) fp = fopen(AppPath("Configs/icons.ini"), "r"); - if (!fp) return 0; - char line[256]; - while (fgets(line, sizeof(line), fp) && count < TM_MAX_ICONS) { + if (!fp) return out; + char line[512]; + while (fgets(line, sizeof(line), fp)) { char* nl = strchr(line, '\n'); if (nl) *nl = 0; char* cr = strchr(line, '\r'); if (cr) *cr = 0; if (line[0] == 0) continue; @@ -262,25 +269,40 @@ static int TM_ReadIconsIni(char keys[][128], char vals[][128]) { char* eq = strchr(line, '='); if (eq) { *eq = 0; - strncpy(keys[count], line, 127); keys[count][127] = 0; - strncpy(vals[count], eq + 1, 127); vals[count][127] = 0; - count++; + TmIconEntry e; + strncpy(e.key, line, 127); e.key[127] = 0; + strncpy(e.val, eq + 1, 127); e.val[127] = 0; + out.push_back(e); } } fclose(fp); - return count; + return out; } -static void TM_WriteIconsIni(char keys[][128], char vals[][128], int count) { +static void TM_WriteIconsIni(const std::vector& icons) { SafeSave save(AppPath("Configs/Icons.ini")); FILE* fp = save.f; if (!fp) return; fprintf(fp, "[default]\n"); - for (int i = 0; i < count; i++) - fprintf(fp, "%s=%s\n", keys[i], vals[i]); + for (size_t i = 0; i < icons.size(); i++) + fprintf(fp, "%s=%s\n", icons[i].key, icons[i].val); save.Commit(); } +static void TM_UpsertIcon(std::vector& icons, const char* name, const char* titleID) { + for (size_t i = 0; i < icons.size(); i++) { + if (strcasecmp(icons[i].key, name) == 0) { + strncpy(icons[i].val, titleID, 127); + icons[i].val[127] = 0; + return; + } + } + TmIconEntry e; + strncpy(e.key, name, 127); e.key[127] = 0; + strncpy(e.val, titleID, 127); e.val[127] = 0; + icons.push_back(e); +} + // Open a URL via the platform's default handler. Falls through the // same dispatch the launch overlay uses. static void TM_OpenUrl(const char* url) { @@ -472,32 +494,17 @@ static void TM_TransliterateUTF8(char* dst, const char* src, int maxLen) { // right icon file under Configs/icons/. static void TM_RegisterIcon(const char* name, const char* titleID) { if (!name || !*name || !titleID || !*titleID) return; - static char keys[TM_MAX_ICONS][128]; - static char vals[TM_MAX_ICONS][128]; - int count = TM_ReadIconsIni(keys, vals); - bool found = false; - for (int i = 0; i < count; i++) { - if (strcasecmp(keys[i], name) == 0) { - strncpy(vals[i], titleID, 127); - vals[i][127] = 0; - found = true; - break; - } - } - if (!found && count < TM_MAX_ICONS) { - strncpy(keys[count], name, 127); keys[count][127] = 0; - strncpy(vals[count], titleID, 127); vals[count][127] = 0; - count++; - } - TM_WriteIconsIni(keys, vals, count); + std::vector icons = TM_ReadIconsIni(); + TM_UpsertIcon(icons, name, titleID); + TM_WriteIconsIni(icons); } // Try a series of Steam CDN art URLs for an appid, copy the first hit -// to /.jpg, resize to 128px on macOS / Linux. -// Returns true if an icon ended up on disk. -static bool TM_DownloadSteamIcon(int appid, const char* titleID) { +// to /.jpg, resize to 128px on macOS / Linux. +// Returns true if an icon ended up on disk. Runs on the icon worker. +static bool TM_DownloadSteamIcon(int appid, const char* titleID, const char* iconDir) { char iconPath[512]; - snprintf(iconPath, sizeof(iconPath), "%s/%s.jpg", VGAMES_ICONS, titleID); + snprintf(iconPath, sizeof(iconPath), "%s/%s.jpg", iconDir, titleID); struct stat ist; if (stat(iconPath, &ist) == 0) return true; // already have one @@ -512,7 +519,7 @@ static bool TM_DownloadSteamIcon(int appid, const char* titleID) { bool isPng = strstr(tryUrls[u], ".png") != NULL; char tmpPath[512], url[512]; snprintf(tmpPath, sizeof(tmpPath), "%s/%s_tmp%s", - VGAMES_ICONS, titleID, isPng ? ".png" : ".jpg"); + iconDir, titleID, isPng ? ".png" : ".jpg"); snprintf(url, sizeof(url), "https://cdn.akamai.steamstatic.com/steam/apps/%d/%s", appid, tryUrls[u]); @@ -541,6 +548,71 @@ static bool TM_DownloadSteamIcon(int appid, const char* titleID) { return gotIcon; } +// Steam icons download on a few workers so a big library doesn't freeze the +// app. Most misses cost six requests each. The Title Maker shows progress. +static const int kTmIconWorkers = 4; +struct TmIconJob { int appid; char titleID[16]; std::string iconDir; }; +struct TmIconQueue { + std::mutex mtx; + std::deque jobs; + int workers = 0; + std::atomic done{0}, total{0}, got{0}; +}; +// Never freed, so a download still going at exit has nothing torn down under it. +static TmIconQueue& TM_Icons() { + static TmIconQueue* q = new TmIconQueue; + return *q; +} + +static void TM_IconWorker() { + TmIconQueue& q = TM_Icons(); + for (;;) { + TmIconJob job; + { + std::lock_guard lk(q.mtx); + if (q.jobs.empty()) { q.workers--; return; } + job = q.jobs.front(); + q.jobs.pop_front(); + } + if (TM_DownloadSteamIcon(job.appid, job.titleID, job.iconDir.c_str())) q.got++; + q.done++; + } +} + +static void TM_QueueSteamIcon(int appid, const char* titleID) { + TM_EnsureDir(VGAMES_ICONS); + TmIconJob job; + job.appid = appid; + strncpy(job.titleID, titleID, sizeof(job.titleID) - 1); + job.titleID[sizeof(job.titleID) - 1] = 0; + job.iconDir = VGAMES_ICONS; + TmIconQueue& q = TM_Icons(); + std::lock_guard lk(q.mtx); + // A finished batch starts the count over. + if (q.workers == 0) { q.done = 0; q.total = 0; q.got = 0; } + q.jobs.push_back(job); + q.total++; + if (q.workers < kTmIconWorkers) { + q.workers++; + std::thread(TM_IconWorker).detach(); + } +} + +// Main thread, every frame: let the dashboard pick up icons as they land. +static void TM_PollSteamIcons() { + TmIconQueue& q = TM_Icons(); + static int s_seen = 0; + int done = q.done; + if (done == s_seen) return; + bool idle; + { std::lock_guard lk(q.mtx); idle = q.workers == 0; } + if (done < s_seen || done - s_seen >= 50 || idle) { + extern bool s_iconsLoaded; + s_iconsLoaded = false; + s_seen = done; + } +} + // Walk every installed Steam app (via Steam_DiscoverLibraries + // appmanifest_*.acf parsing) and add fresh entries to VGames as // steam://rungameid/ launches. Returns counts via outparams. @@ -551,14 +623,12 @@ static void TM_ImportSteamLibrary(const char* steamPath, if (outIcons) *outIcons = 0; struct SteamGame { int appid; char name[256]; }; - static SteamGame games[256]; - int gameCount = 0; + std::vector games; char libDirs[16][512]; int libCount = Steam_DiscoverLibraries(steamPath, libDirs, 16); auto ParseAppManifest = [&](const char* libDir, const char* fileName) { - if (gameCount >= 256) return; if (strncmp(fileName, "appmanifest_", 12) != 0) return; char acfPath[512]; snprintf(acfPath, sizeof(acfPath), "%s/%s", libDir, fileName); @@ -581,27 +651,28 @@ static void TM_ImportSteamLibrary(const char* steamPath, } fclose(acf); if (appid > 0 && name[0]) { - games[gameCount].appid = appid; - TM_TransliterateUTF8(games[gameCount].name, name, 256); - gameCount++; + SteamGame g; + g.appid = appid; + TM_TransliterateUTF8(g.name, name, sizeof(g.name)); + games.push_back(g); } }; - for (int d = 0; d < libCount && gameCount < 256; d++) { + for (int d = 0; d < libCount; d++) { HostDir* dir = Host_OpenDir(libDirs[d]); if (!dir) continue; char name[260]; - while (gameCount < 256 && Host_ReadDir(dir, name, sizeof(name), NULL)) + while (Host_ReadDir(dir, name, sizeof(name), NULL)) ParseAppManifest(libDirs[d], name); Host_CloseDir(dir); } - if (gameCount == 0) return; + if (games.empty()) return; - TM_EnsureDir(VGAMES_ICONS); - int created = 0, skipped = 0, iconsDl = 0; + int created = 0, skipped = 0, iconsQueued = 0; + std::vector icons = TM_ReadIconsIni(); - for (int i = 0; i < gameCount; i++) { + for (size_t i = 0; i < games.size(); i++) { char safeName[256]; strncpy(safeName, games[i].name, 255); safeName[255] = 0; TM_SanitizeName(safeName, sizeof(safeName)); @@ -609,25 +680,27 @@ static void TM_ImportSteamLibrary(const char* steamPath, char titleID[16]; snprintf(titleID, sizeof(titleID), "%08x", games[i].appid); - TM_RegisterIcon(safeName, titleID); + TM_UpsertIcon(icons, safeName, titleID); if (VGames_FindByName(safeName) >= 0) { skipped++; continue; } char launchCmd[256]; snprintf(launchCmd, sizeof(launchCmd), "steam://rungameid/%d", games[i].appid); - VGames_Add(safeName, titleID, launchCmd, "E", "Games"); + if (VGames_Add(safeName, titleID, launchCmd, "E", "Games") < 0) break; created++; - if (TM_DownloadSteamIcon(games[i].appid, titleID)) iconsDl++; + TM_QueueSteamIcon(games[i].appid, titleID); + iconsQueued++; } + TM_WriteIconsIni(icons); VGames_Save(); UDataSynth_RebuildAll(); VGames_Reload(); if (outCreated) *outCreated = created; if (outSkipped) *outSkipped = skipped; - if (outIcons) *outIcons = iconsDl; + if (outIcons) *outIcons = iconsQueued; } // ============================================================================ @@ -635,6 +708,7 @@ static void TM_ImportSteamLibrary(const char* steamPath, // ============================================================================ void RenderTitleMaker() { + TM_PollSteamIcons(); if (!g_titleMakerOpen) return; // Position and size on first open. Cap max size to the viewport so the @@ -650,14 +724,13 @@ void RenderTitleMaker() { ImGui::Begin("UIX Title Maker", &g_titleMakerOpen); // State (persists across frames) - #define TM_MAX_ENTRIES 512 - struct TmEntry { int vgIndex; char name[128]; char launch[512]; char titleID[16]; char drive[4]; char category[32]; }; - static TmEntry s_entries[TM_MAX_ENTRIES]; + struct TmEntry { int vgIndex; char name[128]; char launch[sizeof(VirtualGame::launch)]; char titleID[16]; char drive[4]; char category[32]; }; + static std::vector s_entries; static int s_entryCount = 0; static bool s_needsScan = true; static int s_selectedIdx = -1; static char s_editName[128] = ""; - static char s_editLaunch[512] = ""; + static char s_editLaunch[sizeof(VirtualGame::launch)] = ""; static char s_editTitleID[16] = ""; static int s_editCategoryIdx = 0; static char s_newTitleName[128] = ""; @@ -730,10 +803,12 @@ void RenderTitleMaker() { extern bool s_iconsLoaded; s_iconsLoaded = false; - for (int i = 0; i < g_vgames.count && s_entryCount < TM_MAX_ENTRIES; i++) { + s_entries.clear(); + for (int i = 0; i < g_vgames.count; i++) { VirtualGame& vg = g_vgames.games[i]; if (!vg.valid) continue; - TmEntry& e = s_entries[s_entryCount]; + s_entries.emplace_back(); + TmEntry& e = s_entries.back(); e.vgIndex = i; strncpy(e.name, vg.name, sizeof(e.name) - 1); e.name[sizeof(e.name) - 1] = 0; strncpy(e.launch, vg.launch, sizeof(e.launch) - 1); e.launch[sizeof(e.launch) - 1] = 0; @@ -744,7 +819,7 @@ void RenderTitleMaker() { } // Sort alphabetically by name - std::sort(s_entries, s_entries + s_entryCount, [](const TmEntry& a, const TmEntry& b) { + std::sort(s_entries.begin(), s_entries.end(), [](const TmEntry& a, const TmEntry& b) { return strcasecmp(a.name, b.name) < 0; }); }; @@ -809,12 +884,16 @@ void RenderTitleMaker() { found = true; } } - if (!found) { + if (!found && Xemu_FlatpakInstalled()) { + s_xemuPath[0] = 0; + strncpy(s_statusMsg, "Flatpak xemu found, leave the path empty", sizeof(s_statusMsg) - 1); + s_statusTime = 3.0f; + } else if (!found) { strncpy(s_statusMsg, "xemu not found in common locations", sizeof(s_statusMsg) - 1); s_statusTime = 3.0f; } } - if (s_xemuPath[0] == 0) + if (s_xemuPath[0] == 0 && !Xemu_FlatpakInstalled()) ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.2f, 1.0f), "Set xemu path to enable ISO launching"); } @@ -983,22 +1062,9 @@ void RenderTitleMaker() { // Update Icons.ini for Xbox-side compatibility { - static char iconKeys[TM_MAX_ICONS][128]; - static char iconVals[TM_MAX_ICONS][128]; - int iconCount = TM_ReadIconsIni(iconKeys, iconVals); - bool found = false; - for (int i = 0; i < iconCount; i++) { - if (strcasecmp(iconKeys[i], s_editName) == 0) { - strncpy(iconVals[i], s_editTitleID, 127); - found = true; break; - } - } - if (!found && iconCount < TM_MAX_ICONS) { - strncpy(iconKeys[iconCount], s_editName, 127); - strncpy(iconVals[iconCount], s_editTitleID, 127); - iconCount++; - } - TM_WriteIconsIni(iconKeys, iconVals, iconCount); + std::vector icons = TM_ReadIconsIni(); + TM_UpsertIcon(icons, s_editName, s_editTitleID); + TM_WriteIconsIni(icons); } // Refresh local entry cache @@ -1038,20 +1104,12 @@ void RenderTitleMaker() { // Remove from Icons.ini { - static char iconKeys[TM_MAX_ICONS][128]; - static char iconVals[TM_MAX_ICONS][128]; - int iconCount = TM_ReadIconsIni(iconKeys, iconVals); - int newCount = 0; - for (int i = 0; i < iconCount; i++) { - if (strcasecmp(iconKeys[i], delName) != 0) { - if (i != newCount) { - strncpy(iconKeys[newCount], iconKeys[i], 127); - strncpy(iconVals[newCount], iconVals[i], 127); - } - newCount++; - } + std::vector icons = TM_ReadIconsIni(); + for (size_t i = 0; i < icons.size(); ) { + if (strcasecmp(icons[i].key, delName) == 0) icons.erase(icons.begin() + i); + else i++; } - TM_WriteIconsIni(iconKeys, iconVals, newCount); + TM_WriteIconsIni(icons); } snprintf(s_statusMsg, sizeof(s_statusMsg), "Deleted: %s", delName); @@ -1180,7 +1238,8 @@ void RenderTitleMaker() { snprintf(titleIdHex, sizeof(titleIdHex), "%08x", s_isoInfo.titleId); // Build xemu launch command - char xemuLaunchCmd[1024] = ""; + char xemuLaunchCmd[sizeof(VirtualGame::launch)] = ""; + int cmdLen; { char xemuBin[512] = ""; if (s_xemuPath[0]) { @@ -1190,28 +1249,34 @@ void RenderTitleMaker() { else strncpy(xemuBin, s_xemuPath, sizeof(xemuBin) - 1); } + // Bare xemu is found at launch, Flatpak and snap included. if (xemuBin[0]) - snprintf(xemuLaunchCmd, sizeof(xemuLaunchCmd), + cmdLen = snprintf(xemuLaunchCmd, sizeof(xemuLaunchCmd), "\"%s\" -machine xbox,short-animation=on -dvd_path \"%s\"", xemuBin, s_isoPath); else - snprintf(xemuLaunchCmd, sizeof(xemuLaunchCmd), + cmdLen = snprintf(xemuLaunchCmd, sizeof(xemuLaunchCmd), "xemu -machine xbox,short-animation=on -dvd_path \"%s\"", s_isoPath); } + // A cut off line loses its closing quote and never launches. + if (cmdLen < 0 || cmdLen >= (int)sizeof(xemuLaunchCmd)) { + snprintf(s_statusMsg, sizeof(s_statusMsg), "ISO path too long to launch: %s", s_isoPath); + s_statusTime = 6.0f; + } else { + VGames_Add(safeName, titleIdHex, xemuLaunchCmd, "E", "Games"); + VGames_Save(); UDataSynth_RebuildAll(); + + // Save icon to VGAMES_ICONS/{titleID}.jpg + if (s_isoInfo.titleImageRGBA) { + TM_EnsureDir(VGAMES_ICONS); + char iconPath[512]; + snprintf(iconPath, sizeof(iconPath), "%s/%s.jpg", VGAMES_ICONS, titleIdHex); + XisoSaveTitleImage(&s_isoInfo, iconPath); + } - VGames_Add(safeName, titleIdHex, xemuLaunchCmd, "E", "Games"); - VGames_Save(); UDataSynth_RebuildAll(); - - // Save icon to VGAMES_ICONS/{titleID}.jpg - if (s_isoInfo.titleImageRGBA) { - TM_EnsureDir(VGAMES_ICONS); - char iconPath[512]; - snprintf(iconPath, sizeof(iconPath), "%s/%s.jpg", VGAMES_ICONS, titleIdHex); - XisoSaveTitleImage(&s_isoInfo, iconPath); + snprintf(s_statusMsg, sizeof(s_statusMsg), "Added: %s", safeName); + s_statusTime = 3.0f; + s_needsScan = true; } - - snprintf(s_statusMsg, sizeof(s_statusMsg), "Added: %s", safeName); - s_statusTime = 3.0f; - s_needsScan = true; s_showIsoResult = false; } ImGui::SameLine(); @@ -1453,12 +1518,11 @@ void RenderTitleMaker() { } else { VGames_Add(cleanName, genID, launch, "E", "Games"); TM_RegisterIcon(cleanName, genID); - bool gotIcon = TM_DownloadSteamIcon(appidNum, genID); + TM_QueueSteamIcon(appidNum, genID); VGames_Save(); UDataSynth_RebuildAll(); snprintf(s_statusMsg, sizeof(s_statusMsg), - "Added: %s (%s)", cleanName, - gotIcon ? "with icon" : "no icon found"); + "Added: %s, fetching its icon", cleanName); s_statusTime = 3.0f; s_stTitle[0] = 0; s_stAppID[0] = 0; @@ -1471,16 +1535,25 @@ void RenderTitleMaker() { bool canImport = s_steamPath[0] != 0; if (!canImport) ImGui::BeginDisabled(); if (ImGui::Button("Import Steam Library")) { - int created = 0, skipped = 0, iconsDl = 0; - TM_ImportSteamLibrary(s_steamPath, &created, &skipped, &iconsDl); + int created = 0, skipped = 0, iconsQueued = 0; + TM_ImportSteamLibrary(s_steamPath, &created, &skipped, &iconsQueued); s_needsScan = true; snprintf(s_statusMsg, sizeof(s_statusMsg), - "Imported %d new, %d existing, %d icons", - created, skipped, iconsDl); + "Imported %d new, %d existing, fetching %d icons", + created, skipped, iconsQueued); s_statusTime = 5.0f; } if (!canImport) ImGui::EndDisabled(); + { + TmIconQueue& q = TM_Icons(); + int done = q.done, total = q.total; + if (total > 0 && done < total) + ImGui::Text("Fetching icons: %d / %d", done, total); + else if (total > 0) + ImGui::TextDisabled("Icons: %d of %d found", (int)q.got, total); + } + ImGui::EndChild(); ImGui::SameLine(); ImGui::BeginChild("##stdetails", ImVec2(0, kSFormPanelH), true); @@ -1573,13 +1646,13 @@ void RenderTitleMaker() { // Library list: every VGames entry whose launch URL is a // steam:// spec. { - int matchIdx[TM_MAX_ENTRIES]; - int matchCount = 0; - for (int i = 0; i < g_vgames.count && matchCount < TM_MAX_ENTRIES; i++) { + std::vector matchIdx; + for (int i = 0; i < g_vgames.count; i++) { if (!g_vgames.games[i].valid) continue; if (strncmp(g_vgames.games[i].launch, "steam://", 8) != 0) continue; - matchIdx[matchCount++] = i; + matchIdx.push_back(i); } + int matchCount = (int)matchIdx.size(); ImGui::Text("Steam Library (%d)", matchCount); @@ -1991,13 +2064,13 @@ void RenderTitleMaker() { // retroarch:// spec. Per-row delete; columns show the core // and content path parsed out of the launch URL. { - int matchIdx[TM_MAX_ENTRIES]; - int matchCount = 0; - for (int i = 0; i < g_vgames.count && matchCount < TM_MAX_ENTRIES; i++) { + std::vector matchIdx; + for (int i = 0; i < g_vgames.count; i++) { if (!g_vgames.games[i].valid) continue; if (strncmp(g_vgames.games[i].launch, "retroarch://", 12) != 0) continue; - matchIdx[matchCount++] = i; + matchIdx.push_back(i); } + int matchCount = (int)matchIdx.size(); ImGui::Text("RetroArch Library (%d)", matchCount); @@ -2249,3 +2322,32 @@ void RenderTitleMaker() { ImGui::End(); } + +// The guide's imports. Same work as the Title Maker buttons, without the panel. +int TitleMaker_ImportSteam(int* outSkipped) +{ + int created = 0, skipped = 0, icons = 0; + if (!s_steamPath[0]) { + char roots[4][512]; + if (Steam_DiscoverInstallRoots(NULL, roots, 4) <= 0) return -1; + } + TM_ImportSteamLibrary(s_steamPath, &created, &skipped, &icons); + if (outSkipped) *outSkipped = skipped; + return created; +} + +int TitleMaker_ImportRetroArch(int* outSkipped) +{ + char playlistsDir[600] = ""; + if (!RetroArch_DiscoverPlaylistsDir(s_retroarchPath, playlistsDir, sizeof(playlistsDir))) + return -1; + TM_RAImportCtx ctx = {}; + strncpy(ctx.installRoot, s_retroarchPath, sizeof(ctx.installRoot) - 1); + RetroArch_WalkPlaylists(playlistsDir, TM_RAImportItem, &ctx); + if (ctx.added > 0) { + VGames_Save(); + UDataSynth_RebuildAll(); + } + if (outSkipped) *outSkipped = ctx.skipped; + return ctx.added; +} diff --git a/theseus/desktop/ui_font.cpp b/theseus/desktop/ui_font.cpp new file mode 100644 index 0000000..071d6c4 --- /dev/null +++ b/theseus/desktop/ui_font.cpp @@ -0,0 +1,93 @@ +// ui_font.cpp: the guide's fonts, read once from Data/Fonts. + +#include "ui_font.h" +#include "ui_overlay.h" +#include "app_paths.h" +#include +#include +#include + +#define STBTT_STATIC +#define STB_TRUETYPE_IMPLEMENTATION +#include "imgui/imstb_truetype.h" + +static const char* const kFiles[UI_FONT_COUNT] = { + "Data/Fonts/NotoSans-Regular.ttf", + "Data/Fonts/NotoSans-Bold.ttf", +}; + +static struct { + int state; // 0 not tried, 1 ready, -1 missing + stbtt_fontinfo info; + unsigned char* data; +} s_fonts[UI_FONT_COUNT]; + +static stbtt_fontinfo* Face(int font) +{ + if (font < 0 || font >= UI_FONT_COUNT) font = UI_FONT_REGULAR; + if (!s_fonts[font].state) { + s_fonts[font].state = -1; + FILE* f = fopen(AppPath(kFiles[font]), "rb"); + long size = -1; + if (f && fseek(f, 0, SEEK_END) == 0) size = ftell(f); + unsigned char* data = size > 0 && size < 32 * 1024 * 1024 ? (unsigned char*)malloc((size_t)size) : NULL; + if (data && fseek(f, 0, SEEK_SET) == 0 && fread(data, 1, (size_t)size, f) == (size_t)size) { + int offset = stbtt_GetFontOffsetForIndex(data, 0); + if (offset >= 0 && stbtt_InitFont(&s_fonts[font].info, data, offset)) { + s_fonts[font].data = data; + s_fonts[font].state = 1; + data = NULL; + } + } + free(data); + if (f) fclose(f); + if (s_fonts[font].state < 0) + fprintf(stderr, "[Guide] can't read %s, its text won't draw\n", kFiles[font]); + } + return s_fonts[font].state > 0 ? &s_fonts[font].info : NULL; +} + +bool UiFont_Metrics(int font, float pixelHeight, float* ascent, float* descent) +{ + stbtt_fontinfo* info = Face(font); + if (!info) return false; + int a, d, gap; + float scale = stbtt_ScaleForPixelHeight(info, pixelHeight); + stbtt_GetFontVMetrics(info, &a, &d, &gap); + *ascent = (float)a * scale; + *descent = (float)-d * scale; + return true; +} + +bool UiFont_Has(int font, unsigned int codepoint) +{ + stbtt_fontinfo* info = Face(font); + return info && stbtt_FindGlyphIndex(info, (int)codepoint) != 0; +} + +float UiFont_Advance(int font, float pixelHeight, unsigned int codepoint, unsigned int previous) +{ + stbtt_fontinfo* info = Face(font); + if (!info) return 0.0f; + int advance, bearing; + float scale = stbtt_ScaleForPixelHeight(info, pixelHeight); + stbtt_GetCodepointHMetrics(info, (int)codepoint, &advance, &bearing); + float w = (float)advance * scale; + if (previous) w += (float)stbtt_GetCodepointKernAdvance(info, (int)previous, (int)codepoint) * scale; + return w; +} + +unsigned char* UiFont_Glyph(int font, float pixelHeight, unsigned int codepoint, + int* width, int* height, int* xOffset, int* yOffset) +{ + *width = *height = *xOffset = *yOffset = 0; + stbtt_fontinfo* info = Face(font); + if (!info) return NULL; + return stbtt_GetCodepointBitmap(info, 0, stbtt_ScaleForPixelHeight(info, pixelHeight), (int)codepoint, + width, height, xOffset, yOffset); +} + +void UiFont_Free(unsigned char* bitmap) +{ + stbtt_FreeBitmap(bitmap, NULL); +} diff --git a/theseus/desktop/ui_font.h b/theseus/desktop/ui_font.h new file mode 100644 index 0000000..33d09ad --- /dev/null +++ b/theseus/desktop/ui_font.h @@ -0,0 +1,11 @@ +// ui_font.h: the guide's TrueType faces (Data/Fonts, Noto Sans), measured +// and rasterized with stb_truetype for ui_overlay.cpp's glyph atlas. +#pragma once + +bool UiFont_Metrics(int font, float pixelHeight, float* ascent, float* descent); +bool UiFont_Has(int font, unsigned int codepoint); +float UiFont_Advance(int font, float pixelHeight, unsigned int codepoint, unsigned int previous); +// An 8 bit coverage bitmap, freed with UiFont_Free. NULL for a blank glyph. +unsigned char* UiFont_Glyph(int font, float pixelHeight, unsigned int codepoint, + int* width, int* height, int* xOffset, int* yOffset); +void UiFont_Free(unsigned char* bitmap); diff --git a/theseus/desktop/ui_overlay.cpp b/theseus/desktop/ui_overlay.cpp new file mode 100644 index 0000000..ce66e9e --- /dev/null +++ b/theseus/desktop/ui_overlay.cpp @@ -0,0 +1,685 @@ +// ui_overlay.cpp: the guide's drawing. Shapes and text are gathered in the +// 640x480 layout while a frame is built, then laid out in window pixels and +// drawn in one bgfx submit on their own view, after the scene and before +// ImGui. +// +// Each shape is a quad whose fragment shader works out a rounded rect's edge +// from the distance to it, so corners and outlines are smooth at any size. +// Text is glyphs packed into one atlas the first time they're drawn at a +// size. The plasma is the dashboard's look under a selected row: a tiling +// noise map sampled twice at two scrolling scales. + +#include "ui_overlay.h" +#include "ui_font.h" +#include +#include +#include +#include +#include + +bgfx::ShaderHandle theseus_bgfx_load_shader(const char* name); + +enum { + LAYOUT_W = 640, + LAYOUT_H = 480, + MAX_QUADS = 16384, + ATLAS = 2048, + MAX_GLYPHS = 4096, + MAX_TEXT = 64 * 1024, + MAX_TEXTS = 4096, + MAX_LAYERS = 5, + NOISE = 128, +}; + +// Marks in a quad's radius: what it is besides a plain shape. +static const float PLASMA_MARK = -4.0f; +static const float GLYPH_MARK = -1.0f; + +struct Quad { + float x, y, w, h; + float radius, thickness; + unsigned int top, bottom; + float u0, v0, u1, v1; // u0 < 0: a shape + int layer; +}; + +struct Vertex { + float x, y; + float u, v; + float lx, ly, hw, hh; // the point from the shape's middle, its half size + float radius, thickness; + unsigned char color[4]; +}; + +struct Glyph { + int font, pixelSize; + unsigned int codepoint; + short ax, ay, w, h, xoff, yoff; + float advance; +}; + +struct Text { + int font, align, layer; + float size, x, y; + unsigned int color; + int offset; +}; + +static struct { + bool ready, failed; + bgfx::ProgramHandle program; + bgfx::VertexLayout layout; + bgfx::TextureHandle atlasTex, plasmaTex; + bgfx::UniformHandle uScale, uShift, uThread, sAtlas, sPlasma; + + Quad quads[MAX_QUADS]; + int quadCount; + Text texts[MAX_TEXTS]; + int textCount; + char text[MAX_TEXT]; + int textUsed; + int layer; + + Glyph glyphs[MAX_GLYPHS]; + int glyphCount; + int shelfX, shelfY, shelfH; + float lastScale; + unsigned char* atlas; + int dirtyTop, dirtyBottom; + + float shift[8]; // second map's place and scale, then the threads' color + bool plasmaDrawn; + + int device; + float scale, originX, originY; // last present's layout -> pixels + + Vertex vertices[MAX_QUADS * 6]; +} ov; + +static unsigned int Fade(unsigned int color, float share) +{ + float a = (float)(color & 0xFF) * share; + return (color & 0xFFFFFF00) | (unsigned int)(a < 0.0f ? 0.0f : a > 255.0f ? 255.0f : a); +} + +static Quad* NewQuad() +{ + if (ov.quadCount >= MAX_QUADS) return NULL; + Quad* q = &ov.quads[ov.quadCount++]; + memset(q, 0, sizeof(*q)); + q->u0 = -1.0f; + q->layer = ov.layer; + return q; +} + +void ui_overlay_layer(int layer) +{ + ov.layer = layer < 0 ? 0 : layer >= MAX_LAYERS ? MAX_LAYERS - 1 : layer; +} + +void ui_overlay_gradient(float x, float y, float w, float h, float radius, unsigned int top, unsigned int bottom) +{ + Quad* q = NewQuad(); + if (!q) return; + q->x = x; q->y = y; q->w = w; q->h = h; + q->radius = radius; + q->top = top; q->bottom = bottom; +} + +void ui_overlay_rect(float x, float y, float w, float h, float radius, unsigned int color) +{ + ui_overlay_gradient(x, y, w, h, radius, color, color); +} + +void ui_overlay_outline(float x, float y, float w, float h, float radius, float thickness, unsigned int color) +{ + Quad* q = NewQuad(); + if (!q) return; + q->x = x; q->y = y; q->w = w; q->h = h; + q->radius = radius; + q->thickness = thickness > 0.0f ? thickness : 1.0f; + q->top = q->bottom = color; +} + +// Text + +static unsigned int NextCodepoint(const char** cursor) +{ + const unsigned char* b = (const unsigned char*)*cursor; + if (!*b) return 0; + unsigned int cp; + int extra; + if (b[0] < 0x80) { cp = b[0]; extra = 0; } + else if ((b[0] & 0xE0) == 0xC0) { cp = b[0] & 0x1F; extra = 1; } + else if ((b[0] & 0xF0) == 0xE0) { cp = b[0] & 0x0F; extra = 2; } + else { cp = b[0] & 0x07; extra = 3; } + *cursor += 1; + while (extra-- > 0 && (**cursor & 0xC0) == 0x80) { + cp = (cp << 6) | (unsigned int)(**cursor & 0x3F); + *cursor += 1; + } + return cp; +} + +// Regular has more scripts than Bold doesn't; a missing character falls back. +static int GlyphFont(int font, unsigned int cp) +{ + if (font != UI_FONT_REGULAR && cp > ' ' && !UiFont_Has(font, cp)) return UI_FONT_REGULAR; + return font; +} + +static float Measure(int font, float size, const char* text) +{ + const char* c = text; + unsigned int cp, prev = 0; + int prevFont = -1; + float w = 0.0f; + while ((cp = NextCodepoint(&c)) != 0) { + int face = GlyphFont(font, cp); + w += UiFont_Advance(face, size, cp, face == prevFont ? prev : 0); + prev = cp; + prevFont = face; + } + return w; +} + +// Text is drawn at a whole pixel size, so it's measured at that size too; +// a cursor placed by a width then lands where the glyphs did. +static float LayoutWidth(int font, float size, const char* text) +{ + if (ov.scale <= 0.0f) return Measure(font, size, text); + float px = floorf(size * ov.scale + 0.5f); + return px < 4.0f ? Measure(font, size, text) : Measure(font, px, text) / ov.scale; +} + +float ui_overlay_text_width(int font, float size, const char* text) +{ + return text ? LayoutWidth(font, size, text) : 0.0f; +} + +static float AddText(int font, float size, float x, float y, int align, unsigned int color, const char* text) +{ + int len = (int)strlen(text); + float w = LayoutWidth(font, size, text); + if (ov.textCount >= MAX_TEXTS || ov.textUsed + len + 1 > MAX_TEXT) return w; + Text& t = ov.texts[ov.textCount++]; + t.font = font; t.align = align; t.layer = ov.layer; + t.size = size; t.x = x; t.y = y; + t.color = color; + t.offset = ov.textUsed; + memcpy(ov.text + ov.textUsed, text, (size_t)len + 1); + ov.textUsed += len + 1; + return w; +} + +float ui_overlay_text(int font, float size, float x, float y, int align, unsigned int color, const char* text) +{ + return text && *text ? AddText(font, size, x, y, align, color, text) : 0.0f; +} + +float ui_overlay_text_glow(int font, float size, float x, float y, int align, unsigned int color, + unsigned int glow, const char* text) +{ + // The glow is the text again, faint, in a ring of offsets at two distances. + static const float ring[8][2] = { + { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 }, + { 0.7f, 0.7f }, { -0.7f, 0.7f }, { 0.7f, -0.7f }, { -0.7f, -0.7f }, + }; + if (!text || !*text) return 0.0f; + float spread = size * 0.06f > 0.6f ? size * 0.06f : 0.6f; + AddText(font, size, x + 0.8f, y + 1.0f, align, Fade(0x000000FF, (float)(color & 0xFF) / 255.0f * 0.7f), text); + for (int i = 0; i < 8 && (glow & 0xFF); i++) { + AddText(font, size, x + ring[i][0] * spread * 2, y + ring[i][1] * spread * 2, align, Fade(glow, 0.10f), text); + AddText(font, size, x + ring[i][0] * spread, y + ring[i][1] * spread, align, Fade(glow, 0.16f), text); + } + return AddText(font, size, x, y, align, color, text); +} + +// The plasma + +// Smooth value noise in octaves, tiling, from a fixed seed: the same each run. +static unsigned char* PlasmaNoise() +{ + enum { CELLS = 4 }; + unsigned char* px = (unsigned char*)malloc(NOISE * NOISE * 4); + if (!px) return NULL; + static float lattice[3][32][32]; + unsigned int seed = 0x9E3779B9u; + for (int o = 0; o < 3; o++) + for (int y = 0; y < 32; y++) + for (int x = 0; x < 32; x++) { + seed = seed * 1664525u + 1013904223u; + lattice[o][y][x] = (float)(seed >> 8) / 16777216.0f; + } + for (int y = 0; y < NOISE; y++) + for (int x = 0; x < NOISE; x++) { + float value = 0.0f, weight = 0.6f; + for (int o = 0; o < 3; o++) { + int cells = CELLS << o; + float fx = (float)x * cells / NOISE, fy = (float)y * cells / NOISE; + int x0 = (int)fx, y0 = (int)fy, x1 = (x0 + 1) % cells, y1 = (y0 + 1) % cells; + float tx = fx - x0, ty = fy - y0; + tx = tx * tx * (3.0f - 2.0f * tx); + ty = ty * ty * (3.0f - 2.0f * ty); + float a = lattice[o][y0][x0] + (lattice[o][y0][x1] - lattice[o][y0][x0]) * tx; + float b = lattice[o][y1][x0] + (lattice[o][y1][x1] - lattice[o][y1][x0]) * tx; + value += (a + (b - a) * ty) * weight; + weight *= 0.5f; + } + value /= 1.05f; + unsigned char* p = px + (y * NOISE + x) * 4; + p[0] = p[1] = p[2] = 255; + p[3] = (unsigned char)(value < 0.0f ? 0 : value > 1.0f ? 255 : value * 255.0f); + } + return px; +} + +static float Scroll(unsigned int ms, float perSecond) +{ + return (float)fmod((double)perSecond * 0.001 * (double)ms, 1.0); +} + +void ui_overlay_plasma(float x, float y, float w, float h, float radius, unsigned int color, unsigned int ms) +{ + // Two maps, one repeating every 311 layout pixels and one every 201, each + // scrolling at its own pace. + const float first = 311.0f, second = 201.0f; + float fx = Scroll(ms, 0.03215434f), fy = Scroll(ms, 0.026795285f); + float sx = -Scroll(ms, 0.035536603f), sy = -Scroll(ms, 0.031094525f); + Quad* q = NewQuad(); + if (!q) return; + q->x = x; q->y = y; q->w = w; q->h = h; + q->radius = PLASMA_MARK; + q->thickness = radius; + q->top = q->bottom = color; + q->u0 = x / first + fx; + q->v0 = y / first + fy; + q->u1 = (x + w) / first + fx; + q->v1 = (y + h) / first + fy; + ov.shift[0] = sx - fx * first / second; + ov.shift[1] = sy - fy * first / second; + ov.shift[2] = first / second; + if (ov.shift[7] == 0.0f) ui_overlay_plasma_thread(0xB8F23CFF); + ov.plasmaDrawn = true; +} + +void ui_overlay_plasma_thread(unsigned int color) +{ + ov.shift[4] = (float)((color >> 24) & 0xFF) / 255.0f; + ov.shift[5] = (float)((color >> 16) & 0xFF) / 255.0f; + ov.shift[6] = (float)((color >> 8) & 0xFF) / 255.0f; + ov.shift[7] = 1.0f; +} + +// Buttons and keys + +void ui_overlay_set_device(int device) { ov.device = device; } +int ui_overlay_keyboard() { return ov.device == 0; } + +static const char* const kPillLabels[2][UI_BUTTON_COUNT] = { + { "ENTER", "ESC", "X", "Y", "F7", "BKSP", "Q", "E", "\xE2\x86\x90", "\xE2\x86\x92" }, + { "A", "B", "X", "Y", "START", "BACK", "LB", "RB", "\xE2\x80\xB9", "\xE2\x80\xBA" }, +}; + +// The pad's face buttons, lit at the top and deep at the bottom. +static const unsigned int kFaceColors[4][2] = { + { 0x9BE57AFF, 0x2C8A1CFF }, // A green + { 0xFF8A7AFF, 0xB0201CFF }, // B red + { 0x8FB4FFFF, 0x2448B8FF }, // X blue + { 0xFFE680FF, 0xC48A0CFF }, // Y yellow +}; + +static float PillTextSize(float size) { return size * 0.52f; } + +static float PillWidth(const char* label, float size) +{ + float w = Measure(UI_FONT_BOLD, PillTextSize(size), label) + size * 0.6f; + return w > size * 1.3f ? w : size * 1.3f; +} + +static float Pill(const char* label, float size, float x, float y, unsigned int color, bool key) +{ + float share = (float)(color & 0xFF) / 255.0f; + float w = PillWidth(label, size); + float h = size * 0.78f, top = y + (size - h) * 0.5f; + float r = h * (key ? 0.22f : 0.45f); + float ls = PillTextSize(size); + ui_overlay_rect(x, top + size * 0.06f, w, h, r, Fade(0x00000070, share)); + ui_overlay_gradient(x, top, w, h, r, Fade(key ? 0xF2F5FAFF : 0x5A606BFF, share), + Fade(key ? 0xA9B3C2FF : 0x16191FFF, share)); + ui_overlay_outline(x, top, w, h, r, 0.75f, Fade(key ? 0x5F6B7DFF : 0x9AA3B0A0, share)); + AddText(UI_FONT_BOLD, ls, x + w * 0.5f, top + (h - ls) * 0.5f + 0.2f, UI_ALIGN_CENTER, + Fade(key ? 0x1A2230FF : 0xFFFFFFFF, share), label); + return w; +} + +float ui_overlay_button_width(int button, float size) +{ + if (button < 0 || button >= UI_BUTTON_COUNT) return 0.0f; + if (ov.device == 1 && button <= UI_BUTTON_Y) return size; + return PillWidth(kPillLabels[ov.device == 1][button], size); +} + +float ui_overlay_button(int button, float size, float x, float y, unsigned int color) +{ + if (button < 0 || button >= UI_BUTTON_COUNT) return 0.0f; + if (ov.device != 1 || button > UI_BUTTON_Y) + return Pill(kPillLabels[ov.device == 1][button], size, x, y, color, ov.device != 1); + + // A glossy ball: lit to deep, a sheen on its upper half, its letter in white. + float share = (float)(color & 0xFF) / 255.0f; + float d = size * 0.96f, inset = (size - d) * 0.5f, letter = size * 0.66f; + const char* label = kPillLabels[1][button]; + ui_overlay_rect(x + inset, y + inset + size * 0.05f, d, d, d * 0.5f, Fade(0x00000060, share)); + ui_overlay_gradient(x + inset, y + inset, d, d, d * 0.5f, + Fade(kFaceColors[button][0], share), Fade(kFaceColors[button][1], share)); + ui_overlay_gradient(x + inset + d * 0.16f, y + inset + d * 0.06f, d * 0.68f, d * 0.42f, d * 0.21f, + Fade(0xFFFFFF70, share), Fade(0xFFFFFF08, share)); + ui_overlay_outline(x + inset, y + inset, d, d, d * 0.5f, 0.75f, Fade(0x00000070, share)); + AddText(UI_FONT_BOLD, letter, x + size * 0.5f + 0.4f, y + (size - letter) * 0.5f + 0.9f, UI_ALIGN_CENTER, + Fade(0x00000080, share), label); + AddText(UI_FONT_BOLD, letter, x + size * 0.5f, y + (size - letter) * 0.5f + 0.3f, UI_ALIGN_CENTER, + Fade(0xFFFFFFFF, share), label); + return size; +} + +float ui_overlay_key_width(const char* label, float size) +{ + return PillWidth(label ? label : "", size); +} + +float ui_overlay_key(const char* label, float size, float x, float y, unsigned int color) +{ + return Pill(label ? label : "", size, x, y, color, true); +} + +// Drawing with bgfx + +static bool SetUp() +{ + if (ov.ready || ov.failed) return ov.ready; + ov.failed = true; + bgfx::ShaderHandle vs = theseus_bgfx_load_shader("vs_guide"); + bgfx::ShaderHandle fs = theseus_bgfx_load_shader("fs_guide"); + if (!bgfx::isValid(vs) || !bgfx::isValid(fs)) { + if (bgfx::isValid(vs)) bgfx::destroy(vs); + if (bgfx::isValid(fs)) bgfx::destroy(fs); + fprintf(stderr, "[Guide] no guide shaders for this renderer, the guide won't draw\n"); + return false; + } + ov.program = bgfx::createProgram(vs, fs, true); + if (!bgfx::isValid(ov.program)) return false; + + ov.layout.begin() + .add(bgfx::Attrib::Position, 2, bgfx::AttribType::Float) + .add(bgfx::Attrib::TexCoord0, 2, bgfx::AttribType::Float) + .add(bgfx::Attrib::TexCoord1, 4, bgfx::AttribType::Float) + .add(bgfx::Attrib::TexCoord2, 2, bgfx::AttribType::Float) + .add(bgfx::Attrib::Color0, 4, bgfx::AttribType::Uint8, true) + .end(); + + ov.uScale = bgfx::createUniform("u_guideScale", bgfx::UniformType::Vec4); + ov.uShift = bgfx::createUniform("u_plasmaShift", bgfx::UniformType::Vec4); + ov.uThread = bgfx::createUniform("u_plasmaThread", bgfx::UniformType::Vec4); + ov.sAtlas = bgfx::createUniform("s_atlas", bgfx::UniformType::Sampler); + ov.sPlasma = bgfx::createUniform("s_plasma", bgfx::UniformType::Sampler); + + ov.atlas = (unsigned char*)calloc(ATLAS, ATLAS); + unsigned char* noise = PlasmaNoise(); + if (!ov.atlas || !noise) { free(noise); return false; } + // No initial data: bgfx makes a texture created with data immutable, and + // the glyphs arrive as updates. + ov.atlasTex = bgfx::createTexture2D(ATLAS, ATLAS, false, 1, bgfx::TextureFormat::R8, + BGFX_TEXTURE_BLIT_DST | BGFX_SAMPLER_U_CLAMP | BGFX_SAMPLER_V_CLAMP); + ov.plasmaTex = bgfx::createTexture2D(NOISE, NOISE, false, 1, bgfx::TextureFormat::RGBA8, BGFX_SAMPLER_NONE, + bgfx::copy(noise, NOISE * NOISE * 4)); + free(noise); + ov.dirtyTop = 0; + ov.dirtyBottom = ATLAS; + ov.shelfX = ov.shelfY = 2; + ov.failed = false; + ov.ready = true; + return true; +} + +void ui_overlay_shutdown() +{ + if (!ov.ready) return; + bgfx::destroy(ov.program); + bgfx::destroy(ov.atlasTex); + bgfx::destroy(ov.plasmaTex); + bgfx::destroy(ov.uScale); + bgfx::destroy(ov.uShift); + bgfx::destroy(ov.uThread); + bgfx::destroy(ov.sAtlas); + bgfx::destroy(ov.sPlasma); + free(ov.atlas); + ov.atlas = NULL; + ov.ready = false; +} + +static void ForgetGlyphs() +{ + ov.glyphCount = 0; + ov.shelfX = ov.shelfY = 2; + ov.shelfH = 0; +} + +// A glyph at a pixel size, packed into the atlas the first time. NULL when +// the atlas is full; it starts over next frame. +static Glyph* GetGlyph(int font, int pixelSize, unsigned int cp, bool* full) +{ + for (int i = 0; i < ov.glyphCount; i++) { + Glyph* g = &ov.glyphs[i]; + if (g->codepoint == cp && g->pixelSize == pixelSize && g->font == font) return g; + } + if (ov.glyphCount >= MAX_GLYPHS) { *full = true; return NULL; } + int w, h, xo, yo; + unsigned char* bmp = UiFont_Glyph(font, (float)pixelSize, cp, &w, &h, &xo, &yo); + if (w > 0 && h > 0) { + if (ov.shelfX + w + 2 > ATLAS) { + ov.shelfX = 2; + ov.shelfY += ov.shelfH + 2; + ov.shelfH = 0; + } + if (ov.shelfY + h + 2 > ATLAS) { + UiFont_Free(bmp); + *full = true; + return NULL; + } + } + Glyph* g = &ov.glyphs[ov.glyphCount++]; + g->font = font; g->pixelSize = pixelSize; g->codepoint = cp; + g->w = (short)w; g->h = (short)h; g->xoff = (short)xo; g->yoff = (short)yo; + g->advance = UiFont_Advance(font, (float)pixelSize, cp, 0); + g->ax = (short)ov.shelfX; + g->ay = (short)ov.shelfY; + if (bmp && w > 0 && h > 0) { + // A clear texel all around, or filtering at a fraction of a pixel + // pulls in whatever was packed there before. + for (int row = -1; row <= h; row++) { + unsigned char* dst = ov.atlas + (ov.shelfY + row) * ATLAS + ov.shelfX - 1; + memset(dst, 0, (size_t)w + 2); + if (row >= 0 && row < h) memcpy(dst + 1, bmp + row * w, (size_t)w); + } + if (ov.shelfY - 1 < ov.dirtyTop) ov.dirtyTop = ov.shelfY - 1; + if (ov.shelfY + h + 1 > ov.dirtyBottom) ov.dirtyBottom = ov.shelfY + h + 1; + ov.shelfX += w + 2; + if (h > ov.shelfH) ov.shelfH = h; + } + if (bmp) UiFont_Free(bmp); + return g; +} + +// A text's glyphs as quads already in window pixels. +static void LayOutText(int index, bool* full) +{ + const Text& t = ov.texts[index]; + const char* s = ov.text + t.offset; + int px = (int)floorf(t.size * ov.scale + 0.5f); + float ascent, descent; + if (px < 4 || !UiFont_Metrics(t.font, (float)px, &ascent, &descent)) return; + float w = Measure(t.font, (float)px, s); + float penX = ov.originX + t.x * ov.scale; + if (t.align == UI_ALIGN_CENTER) penX -= w * 0.5f; + else if (t.align == UI_ALIGN_RIGHT) penX -= w; + // y is the line's top: the baseline an ascent below, centered in the size + float penY = ov.originY + t.y * ov.scale + ((float)px - (ascent + descent)) * 0.5f + ascent; + penX = floorf(penX + 0.5f); + penY = floorf(penY + 0.5f); + unsigned int cp, prev = 0; + int prevFont = -1; + while ((cp = NextCodepoint(&s)) != 0) { + int face = GlyphFont(t.font, cp); + if (prev && face == prevFont) + penX += UiFont_Advance(face, (float)px, cp, prev) - UiFont_Advance(face, (float)px, cp, 0); + Glyph* g = GetGlyph(face, px, cp, full); + if (!g) return; + Quad* q; + if (g->w > 0 && (q = NewQuad()) != NULL) { + q->x = penX + g->xoff; + q->y = penY + g->yoff; + q->w = g->w; + q->h = g->h; + q->top = q->bottom = t.color; + q->u0 = (float)g->ax / ATLAS; + q->v0 = (float)g->ay / ATLAS; + q->u1 = (float)(g->ax + g->w) / ATLAS; + q->v1 = (float)(g->ay + g->h) / ATLAS; + q->radius = GLYPH_MARK; + q->layer = t.layer; + } + penX += g->advance; + prev = cp; + prevFont = face; + } +} + +static void Put(Vertex* v, float x, float y, float u, float t, float lx, float ly, float hw, float hh, + float radius, float thickness, unsigned int color) +{ + v->x = x; v->y = y; v->u = u; v->v = t; + v->lx = lx; v->ly = ly; v->hw = hw; v->hh = hh; + v->radius = radius; v->thickness = thickness; + v->color[0] = (unsigned char)(color >> 24); + v->color[1] = (unsigned char)(color >> 16); + v->color[2] = (unsigned char)(color >> 8); + v->color[3] = (unsigned char)color; +} + +// Each layer's shapes, then its text, then the next layer. Returns the count. +static int BuildVertices(int shapeCount) +{ + int count = 0; + for (int layer = 0; layer < MAX_LAYERS; layer++) + for (int pass = 0; pass < 2; pass++) + for (int i = pass ? shapeCount : 0; i < (pass ? ov.quadCount : shapeCount) && count + 6 <= MAX_QUADS * 6; i++) { + const Quad* q = &ov.quads[i]; + if (q->layer != layer) continue; + Vertex* v = &ov.vertices[count]; + unsigned int tc = q->top, bc = q->bottom; + if (pass == 0) { + float l = ov.originX + q->x * ov.scale, t = ov.originY + q->y * ov.scale; + float r = l + q->w * ov.scale, b = t + q->h * ov.scale; + float hw = (r - l) * 0.5f, hh = (b - t) * 0.5f; + if (q->radius == PLASMA_MARK) { + float rad = q->thickness * ov.scale; + Put(&v[0], l, t, q->u0, q->v0, -hw, -hh, hw, hh, PLASMA_MARK, rad, tc); + Put(&v[1], r, t, q->u1, q->v0, hw, -hh, hw, hh, PLASMA_MARK, rad, tc); + Put(&v[2], l, b, q->u0, q->v1, -hw, hh, hw, hh, PLASMA_MARK, rad, bc); + Put(&v[3], r, t, q->u1, q->v0, hw, -hh, hw, hh, PLASMA_MARK, rad, tc); + Put(&v[4], r, b, q->u1, q->v1, hw, hh, hw, hh, PLASMA_MARK, rad, bc); + Put(&v[5], l, b, q->u0, q->v1, -hw, hh, hw, hh, PLASMA_MARK, rad, bc); + } else { + // A pixel larger all round for the smooth edge. + float rad = q->radius * ov.scale; + float th = q->thickness > 0.0f ? fmaxf(1.0f, q->thickness * ov.scale) : 0.0f; + const float g = 1.0f; + Put(&v[0], l - g, t - g, -1, 0, -hw - g, -hh - g, hw, hh, rad, th, tc); + Put(&v[1], r + g, t - g, -1, 0, hw + g, -hh - g, hw, hh, rad, th, tc); + Put(&v[2], l - g, b + g, -1, 0, -hw - g, hh + g, hw, hh, rad, th, bc); + Put(&v[3], r + g, t - g, -1, 0, hw + g, -hh - g, hw, hh, rad, th, tc); + Put(&v[4], r + g, b + g, -1, 0, hw + g, hh + g, hw, hh, rad, th, bc); + Put(&v[5], l - g, b + g, -1, 0, -hw - g, hh + g, hw, hh, rad, th, bc); + } + } else { + float l = q->x, t = q->y, r = l + q->w, b = t + q->h; + Put(&v[0], l, t, q->u0, q->v0, 0, 0, 0, 0, 0, 0, tc); + Put(&v[1], r, t, q->u1, q->v0, 0, 0, 0, 0, 0, 0, tc); + Put(&v[2], l, b, q->u0, q->v1, 0, 0, 0, 0, 0, 0, tc); + Put(&v[3], r, t, q->u1, q->v0, 0, 0, 0, 0, 0, 0, tc); + Put(&v[4], r, b, q->u1, q->v1, 0, 0, 0, 0, 0, 0, tc); + Put(&v[5], l, b, q->u0, q->v1, 0, 0, 0, 0, 0, 0, tc); + } + count += 6; + } + return count; +} + +static void Place(int width, int height) +{ + // 480 layout lines fill the height; a wider window has margins either side. + ov.scale = (float)height / LAYOUT_H; + ov.originX = ((float)width - LAYOUT_W * ov.scale) * 0.5f; + ov.originY = 0.0f; +} + +void ui_overlay_to_layout(float px, float py, float* x, float* y) +{ + float s = ov.scale > 0.0f ? ov.scale : 1.0f; + *x = (px - ov.originX) / s; + *y = (py - ov.originY) / s; +} + +float ui_overlay_layout_left() { return ov.scale > 0.0f ? -ov.originX / ov.scale : 0.0f; } +float ui_overlay_layout_right() { return ov.scale > 0.0f ? LAYOUT_W + ov.originX / ov.scale : (float)LAYOUT_W; } + +void ui_overlay_present(unsigned short view, int width, int height) +{ + if (width > 0 && height > 0) Place(width, height); + if ((!ov.quadCount && !ov.textCount) || width <= 0 || height <= 0 || !SetUp()) { + ov.quadCount = ov.textCount = ov.textUsed = 0; + ov.layer = 0; + ov.plasmaDrawn = false; + return; + } + if (ov.scale != ov.lastScale) { + ForgetGlyphs(); + ov.lastScale = ov.scale; + } + bool full = false; + int shapes = ov.quadCount; + for (int i = 0; i < ov.textCount; i++) LayOutText(i, &full); + int count = BuildVertices(shapes); + + if (ov.dirtyBottom > ov.dirtyTop) + bgfx::updateTexture2D(ov.atlasTex, 0, 0, 0, (uint16_t)ov.dirtyTop, ATLAS, + (uint16_t)(ov.dirtyBottom - ov.dirtyTop), + bgfx::copy(ov.atlas + (size_t)ov.dirtyTop * ATLAS, + (uint32_t)((ov.dirtyBottom - ov.dirtyTop) * ATLAS))); + ov.dirtyTop = ATLAS; + ov.dirtyBottom = 0; + + if (count > 0 && bgfx::getAvailTransientVertexBuffer((uint32_t)count, ov.layout) >= (uint32_t)count) { + bgfx::TransientVertexBuffer tvb; + bgfx::allocTransientVertexBuffer(&tvb, (uint32_t)count, ov.layout); + memcpy(tvb.data, ov.vertices, (size_t)count * sizeof(Vertex)); + bgfx::setViewRect(view, 0, 0, (uint16_t)width, (uint16_t)height); + bgfx::setViewMode(view, bgfx::ViewMode::Sequential); + float scale[4] = { 2.0f / (float)width, -2.0f / (float)height, 0.0f, 0.0f }; + bgfx::setUniform(ov.uScale, scale); + bgfx::setUniform(ov.uShift, ov.shift); + bgfx::setUniform(ov.uThread, ov.shift + 4); + bgfx::setTexture(0, ov.sAtlas, ov.atlasTex); + bgfx::setTexture(1, ov.sPlasma, ov.plasmaTex); + bgfx::setVertexBuffer(0, &tvb, 0, (uint32_t)count); + bgfx::setState(BGFX_STATE_WRITE_RGB | BGFX_STATE_WRITE_A | + BGFX_STATE_BLEND_FUNC_SEPARATE(BGFX_STATE_BLEND_SRC_ALPHA, BGFX_STATE_BLEND_INV_SRC_ALPHA, + BGFX_STATE_BLEND_ONE, BGFX_STATE_BLEND_INV_SRC_ALPHA)); + bgfx::submit(view, ov.program); + } + ov.quadCount = ov.textCount = ov.textUsed = 0; + ov.layer = 0; + ov.plasmaDrawn = false; + if (full) ForgetGlyphs(); +} diff --git a/theseus/desktop/ui_overlay.h b/theseus/desktop/ui_overlay.h new file mode 100644 index 0000000..78cd2ff --- /dev/null +++ b/theseus/desktop/ui_overlay.h @@ -0,0 +1,106 @@ +// ui_overlay.h: the guide's immediate mode UI. Shapes and text gathered in a +// 640x480 layout during the frame and drawn over the dashboard at the +// window's resolution, so corners and text stay sharp at any size. +// Shapes are rounded rects worked out per pixel from their distance to the +// edge; text comes from a glyph atlas. ui_overlay.cpp draws, and +// ui_overlay_style.cpp is the guide's look built on top. +#pragma once + +enum { UI_FONT_REGULAR, UI_FONT_BOLD, UI_FONT_COUNT }; + +enum { UI_ALIGN_LEFT, UI_ALIGN_CENTER, UI_ALIGN_RIGHT }; + +// A pad button, drawn the way the device used last names it: the pad's +// colored face buttons and dark pills, or the keyboard's key caps. +enum { + UI_BUTTON_A, UI_BUTTON_B, UI_BUTTON_X, UI_BUTTON_Y, + UI_BUTTON_START, UI_BUTTON_BACK, + UI_BUTTON_LEFT_SHOULDER, UI_BUTTON_RIGHT_SHOULDER, + UI_BUTTON_DPAD_LEFT, UI_BUTTON_DPAD_RIGHT, + UI_BUTTON_COUNT +}; + +// Colors are 0xRRGGBBAA. Everything is in the 640x480 layout; a wide +// window has room either side, which x below 0 or past 640 reaches. +void ui_overlay_rect(float x, float y, float w, float h, float radius, unsigned int color); +void ui_overlay_gradient(float x, float y, float w, float h, float radius, unsigned int top, unsigned int bottom); +void ui_overlay_outline(float x, float y, float w, float h, float radius, float thickness, unsigned int color); + +// One line of UTF-8, size its height, y its top. Returns its width. +float ui_overlay_text(int font, float size, float x, float y, int align, unsigned int color, const char* text); +float ui_overlay_text_width(int font, float size, const char* text); +// Text with a drop shadow and a soft glow around it (glow alpha 0: shadow only). +float ui_overlay_text_glow(int font, float size, float x, float y, int align, unsigned int color, + unsigned int glow, const char* text); + +// The dashboard's plasma: a noise map twice, scrolling at two scales, tinted +// by color, in a rounded rect. ms is the clock it scrolls by. +void ui_overlay_plasma(float x, float y, float w, float h, float radius, unsigned int color, unsigned int ms); +void ui_overlay_plasma_thread(unsigned int color); + +// Later layers draw over earlier ones (0 to 4); text sits over its own +// layer's shapes. Each frame starts at 0. +void ui_overlay_layer(int layer); + +// Which device the prompts show: 0 keyboard (and mouse), 1 pad. +void ui_overlay_set_device(int device); +int ui_overlay_keyboard(); +float ui_overlay_button(int button, float size, float x, float y, unsigned int color); +float ui_overlay_button_width(int button, float size); +float ui_overlay_key(const char* label, float size, float x, float y, unsigned int color); +float ui_overlay_key_width(const char* label, float size); + +// Layout <-> window pixels for the current backbuffer, for the pointer. +void ui_overlay_to_layout(float px, float py, float* x, float* y); +float ui_overlay_layout_left(); +float ui_overlay_layout_right(); + +// Draws what was gathered into a bgfx view over the backbuffer +// (width x height pixels), then starts the next frame empty. +void ui_overlay_present(unsigned short view, int width, int height); +void ui_overlay_shutdown(); + +// The guide's look (ui_overlay_style.cpp) + +enum { + UI_COLOR_NAME, UI_COLOR_STATUS, UI_COLOR_LABEL, UI_COLOR_HEAD, UI_COLOR_HEAD_GLOW, + UI_COLOR_TEXT, UI_COLOR_WARNING, UI_COLOR_SHADE, + UI_COLOR_GLASS_TOP, UI_COLOR_GLASS_BOTTOM, UI_COLOR_PANE, + UI_COLOR_TITLE, UI_COLOR_TITLE_GLOW, UI_COLOR_NAV, UI_COLOR_NAV_GLOW, + UI_COLOR_SELECTED_GLOW, UI_COLOR_TEXT_DIM, UI_COLOR_FAINT, UI_COLOR_GOOD, UI_COLOR_BAD, + UI_COLOR_PROMPT, UI_COLOR_PROMPT_GLOW, UI_COLOR_SEPARATOR, + UI_COLOR_PANEL_EDGE, UI_COLOR_GLOW, UI_COLOR_RULE, UI_COLOR_TAB, UI_COLOR_TAB_ON, + UI_COLOR_SELECTED_EDGE, UI_COLOR_ROW, UI_COLOR_ROW_EDGE, UI_COLOR_ROW_ON, + UI_COLOR_PLASMA_THREAD, + UI_COLOR_COUNT +}; +unsigned int ui_style_color(int name); + +// Dashboard green is the default; blue and red are there to pick. +enum { UI_THEME_GREEN, UI_THEME_BLUE, UI_THEME_RED, UI_THEME_COUNT }; +void ui_style_theme(int theme); + +enum { UI_TEXT_HEADING, UI_TEXT_NAME, UI_TEXT_STATUS, UI_TEXT_TITLE, UI_TEXT_LINE, UI_TEXT_LABEL, UI_TEXT_WARNING }; +float ui_style_text(int kind, float x, float y, int align, const char* text); +float ui_style_text_scaled(int kind, float scale, float x, float y, int align, const char* text); +float ui_style_text_size(int kind); + +void ui_style_clock(unsigned int ms); +// Plasma with an edge (alpha 0: none) and, lit, a glow around it. +void ui_style_surface(float x, float y, float w, float h, float radius, unsigned int color, unsigned int edge, int lit); +// The big glass panel a screen sits on, faded by share (0 to 1). +void ui_style_panel(float x, float y, float w, float h, float share); +void ui_style_glass(float x, float y, float w, float h, float radius, unsigned int color, unsigned int edge); +// A row's capsule: glass, the selected one lit with the plasma. +void ui_style_row(float x, float y, float w, float h, int selected); +// A setting's row: label, an optional tag after it, the value between arrows. +void ui_style_setting(float x, float y, float w, float h, float scale, const char* label, const char* badge, + const char* value, int selected); +// Section tabs with the bumpers at the ends; each tab's span for the pointer. +void ui_style_tabs(float left, float right, float y, float h, const char* const* names, int count, int selected, + const char* leftKey, const char* rightKey, float* starts, float* ends); + +struct ui_style_prompt { int button; const char* key; const char* word; }; +// Right aligned at right; each prompt's span for the pointer. +void ui_style_prompts(float right, float y, float size, const ui_style_prompt* prompts, int count, + float* starts, float* ends); diff --git a/theseus/desktop/ui_overlay_style.cpp b/theseus/desktop/ui_overlay_style.cpp new file mode 100644 index 0000000..807713c --- /dev/null +++ b/theseus/desktop/ui_overlay_style.cpp @@ -0,0 +1,249 @@ +// ui_overlay_style.cpp: the guide's look as pieces any screen can draw. +// Translucent glass panels and rows, the dashboard's plasma on the +// selection, a glow on the lit edges, section tabs with their bumpers and +// the prompt bar. Built only on ui_overlay.h, so it's the same on every +// renderer. + +#include "ui_overlay.h" +#include + +// One table a theme. Dashboard Green is the default: dark green glass and +// a lime selection. Blue and Red swap the same palette. +static unsigned int s_palettes[UI_THEME_COUNT][UI_COLOR_COUNT]; + +static void FillPalettes() +{ + static bool done = false; + if (done) return; + done = true; + unsigned int* g = s_palettes[UI_THEME_GREEN]; + g[UI_COLOR_NAME] = 0xFFFFFFFF; g[UI_COLOR_STATUS] = 0xC8F09AFF; + g[UI_COLOR_LABEL] = 0xA8E05AFF; g[UI_COLOR_HEAD] = 0xA8E05AFF; + g[UI_COLOR_HEAD_GLOW] = 0x7FCB2A40; g[UI_COLOR_TEXT] = 0xFFFFFFFF; + g[UI_COLOR_WARNING] = 0xFFCF6BFF; g[UI_COLOR_SHADE] = 0x010802B4; + g[UI_COLOR_GLASS_TOP] = 0x123C14F4; g[UI_COLOR_GLASS_BOTTOM] = 0x061C08F8; + g[UI_COLOR_PANE] = 0x04180AE0; g[UI_COLOR_TITLE] = 0xB4F03CFF; + g[UI_COLOR_TITLE_GLOW] = 0x9BDB2AC0; g[UI_COLOR_NAV] = 0xA8E04AFF; + g[UI_COLOR_NAV_GLOW] = 0x8CD22870; g[UI_COLOR_SELECTED_GLOW] = 0xD8FF9C8C; + g[UI_COLOR_TEXT_DIM] = 0xA9BB9AFF; g[UI_COLOR_FAINT] = 0x8FB27AFF; + g[UI_COLOR_GOOD] = 0x9FEA7AFF; g[UI_COLOR_BAD] = 0xFF8A80FF; + g[UI_COLOR_PROMPT] = 0xB4F03CFF; g[UI_COLOR_PROMPT_GLOW] = 0x8CD22880; + g[UI_COLOR_SEPARATOR] = 0xA8E05A5A; g[UI_COLOR_PANEL_EDGE] = 0x5FA524FF; + g[UI_COLOR_GLOW] = 0x8CD228FF; g[UI_COLOR_RULE] = 0x9BDB2A5A; + g[UI_COLOR_TAB] = 0xB9E68DFF; g[UI_COLOR_TAB_ON] = 0x6FAE2A8C; + g[UI_COLOR_SELECTED_EDGE] = 0xD8FF9CFF; g[UI_COLOR_ROW] = 0x0E3612E6; + g[UI_COLOR_ROW_EDGE] = 0x9BDB2A40; g[UI_COLOR_ROW_ON] = 0x7DBE2A70; + g[UI_COLOR_PLASMA_THREAD] = 0xB8F23CFF; + + unsigned int* b = s_palettes[UI_THEME_BLUE]; + b[UI_COLOR_NAME] = 0xFFFFFFFF; b[UI_COLOR_STATUS] = 0x8FC4FFFF; + b[UI_COLOR_LABEL] = 0x6FB6FFFF; b[UI_COLOR_HEAD] = 0x6FB6FFFF; + b[UI_COLOR_HEAD_GLOW] = 0x3D8BFF40; b[UI_COLOR_TEXT] = 0xFFFFFFFF; + b[UI_COLOR_WARNING] = 0xFFCF6BFF; b[UI_COLOR_SHADE] = 0x01040CB4; + b[UI_COLOR_GLASS_TOP] = 0x0C2A58F6; b[UI_COLOR_GLASS_BOTTOM] = 0x061430FA; + b[UI_COLOR_PANE] = 0x03102AE0; b[UI_COLOR_TITLE] = 0x3D9BFFFF; + b[UI_COLOR_TITLE_GLOW] = 0x3D9BFFC0; b[UI_COLOR_NAV] = 0x4FA6FFFF; + b[UI_COLOR_NAV_GLOW] = 0x3D9BFF70; b[UI_COLOR_SELECTED_GLOW] = 0x9FD4FF8C; + b[UI_COLOR_TEXT_DIM] = 0x9AA9BBFF; b[UI_COLOR_FAINT] = 0x7F9CBDFF; + b[UI_COLOR_GOOD] = 0x7FE39AFF; b[UI_COLOR_BAD] = 0xFF8A80FF; + b[UI_COLOR_PROMPT] = 0x3D9BFFFF; b[UI_COLOR_PROMPT_GLOW] = 0x3D8BFF80; + b[UI_COLOR_SEPARATOR] = 0x6EB4FF5A; b[UI_COLOR_PANEL_EDGE] = 0x2F7FD6FF; + b[UI_COLOR_GLOW] = 0x2F8FFFFF; b[UI_COLOR_RULE] = 0x50A0FF5A; + b[UI_COLOR_TAB] = 0x7FB6EEFF; b[UI_COLOR_TAB_ON] = 0x2F7FD68C; + b[UI_COLOR_SELECTED_EDGE] = 0x9FD4FFFF; b[UI_COLOR_ROW] = 0x0A2246E6; + b[UI_COLOR_ROW_EDGE] = 0x50A0FF40; b[UI_COLOR_ROW_ON] = 0x2F7FD670; + b[UI_COLOR_PLASMA_THREAD] = 0x3373FFFF; + + unsigned int* r = s_palettes[UI_THEME_RED]; + r[UI_COLOR_NAME] = 0xFFFFFFFF; r[UI_COLOR_STATUS] = 0xFF9C9FFF; + r[UI_COLOR_LABEL] = 0xFF8184FF; r[UI_COLOR_HEAD] = 0xFF8184FF; + r[UI_COLOR_HEAD_GLOW] = 0xFF505440; r[UI_COLOR_TEXT] = 0xFFFFFFFF; + r[UI_COLOR_WARNING] = 0xFFCF6BFF; r[UI_COLOR_SHADE] = 0x0C0202B4; + r[UI_COLOR_GLASS_TOP] = 0x581415F6; r[UI_COLOR_GLASS_BOTTOM] = 0x300A0AFA; + r[UI_COLOR_PANE] = 0x2A0607E0; r[UI_COLOR_TITLE] = 0xFF5459FF; + r[UI_COLOR_TITLE_GLOW] = 0xFF5459C0; r[UI_COLOR_NAV] = 0xFF6569FF; + r[UI_COLOR_NAV_GLOW] = 0xFF545970; r[UI_COLOR_SELECTED_GLOW] = 0xFFACAF8C; + r[UI_COLOR_TEXT_DIM] = 0xBB9E9EFF; r[UI_COLOR_FAINT] = 0xBD8688FF; + r[UI_COLOR_GOOD] = 0x7FE39AFF; r[UI_COLOR_BAD] = 0xFFA060FF; + r[UI_COLOR_PROMPT] = 0xFF5459FF; r[UI_COLOR_PROMPT_GLOW] = 0xFF505480; + r[UI_COLOR_SEPARATOR] = 0xFF80835A; r[UI_COLOR_PANEL_EDGE] = 0xD64347FF; + r[UI_COLOR_GLOW] = 0xFF474CFF; r[UI_COLOR_RULE] = 0xFF64685A; + r[UI_COLOR_TAB] = 0xEE8D90FF; r[UI_COLOR_TAB_ON] = 0xD643478C; + r[UI_COLOR_SELECTED_EDGE] = 0xFFACAFFF; r[UI_COLOR_ROW] = 0x461011E6; + r[UI_COLOR_ROW_EDGE] = 0xFF646840; r[UI_COLOR_ROW_ON] = 0xD6434770; + r[UI_COLOR_PLASMA_THREAD] = 0xFF4346FF; +} + +static const unsigned int* s_palette = s_palettes[UI_THEME_GREEN]; +#define PALETTE(name) s_palette[UI_COLOR_##name] + +unsigned int ui_style_color(int name) +{ + FillPalettes(); + return name >= 0 && name < UI_COLOR_COUNT ? s_palette[name] : PALETTE(NAME); +} + +void ui_style_theme(int theme) +{ + FillPalettes(); + if (theme < 0 || theme >= UI_THEME_COUNT) theme = UI_THEME_GREEN; + s_palette = s_palettes[theme]; + ui_overlay_plasma_thread(PALETTE(PLASMA_THREAD)); +} + +static unsigned int Fade(unsigned int color, float share) +{ + float a = (float)(color & 0xFF) * share; + return (color & 0xFFFFFF00) | (unsigned int)(a < 0.0f ? 0.0f : a > 255.0f ? 255.0f : a); +} + +static const struct { int font; float size; int color; int glow; } kText[] = { + { UI_FONT_BOLD, 9.0f, UI_COLOR_HEAD, UI_COLOR_HEAD_GLOW }, // HEADING + { UI_FONT_BOLD, 11.0f, UI_COLOR_NAME, -1 }, // NAME + { UI_FONT_REGULAR, 8.5f, UI_COLOR_STATUS, -1 }, // STATUS + { UI_FONT_BOLD, 14.0f, UI_COLOR_NAME, UI_COLOR_HEAD_GLOW }, // TITLE + { UI_FONT_REGULAR, 10.0f, UI_COLOR_STATUS, -1 }, // LINE + { UI_FONT_REGULAR, 9.0f, UI_COLOR_LABEL, -1 }, // LABEL + { UI_FONT_REGULAR, 9.0f, UI_COLOR_WARNING, -1 }, // WARNING +}; + +float ui_style_text_size(int kind) +{ + return kind >= 0 && kind <= UI_TEXT_WARNING ? kText[kind].size : 10.0f; +} + +float ui_style_text_scaled(int kind, float scale, float x, float y, int align, const char* text) +{ + if (kind < 0 || kind > UI_TEXT_WARNING) kind = UI_TEXT_LINE; + if (kText[kind].glow >= 0) + return ui_overlay_text_glow(kText[kind].font, kText[kind].size * scale, x, y, align, + s_palette[kText[kind].color], s_palette[kText[kind].glow], text); + return ui_overlay_text(kText[kind].font, kText[kind].size * scale, x, y, align, s_palette[kText[kind].color], text); +} + +float ui_style_text(int kind, float x, float y, int align, const char* text) +{ + return ui_style_text_scaled(kind, 1.0f, x, y, align, text); +} + +static unsigned int s_clock; + +void ui_style_clock(unsigned int ms) { s_clock = ms; } + +void ui_style_surface(float x, float y, float w, float h, float radius, unsigned int color, unsigned int edge, int lit) +{ + if (lit) + for (int ring = 1; ring <= 4; ring++) + ui_overlay_outline(x - ring, y - ring, w + 2 * ring, h + 2 * ring, radius + ring, 1.0f, + Fade(PALETTE(GLOW), (float)(edge & 0xFF) / 255.0f * 0.30f / (float)ring)); + ui_overlay_plasma(x, y, w, h, radius, color, s_clock); + if (edge & 0xFF) ui_overlay_outline(x, y, w, h, radius, 1.0f, edge); +} + +void ui_style_glass(float x, float y, float w, float h, float radius, unsigned int color, unsigned int edge) +{ + ui_overlay_rect(x, y, w, h, radius, color); + if (edge & 0xFF) ui_overlay_outline(x, y, w, h, radius, 1.0f, edge); +} + +void ui_style_panel(float x, float y, float w, float h, float share) +{ + // Nearly opaque glass so nothing under it reads through, its edge + // glowing. The plasma belongs to the selection alone. + for (int ring = 1; ring <= 4; ring++) + ui_overlay_outline(x - ring, y - ring, w + 2 * ring, h + 2 * ring, 12.0f + ring, 1.0f, + Fade(PALETTE(GLOW), share * 0.30f / (float)ring)); + ui_overlay_gradient(x, y, w, h, 12, Fade(PALETTE(GLASS_TOP), share), Fade(PALETTE(GLASS_BOTTOM), share)); + ui_overlay_outline(x, y, w, h, 12, 1.0f, Fade(PALETTE(PANEL_EDGE), share)); +} + +void ui_style_row(float x, float y, float w, float h, int selected) +{ + if (selected) ui_style_surface(x, y, w, h, 7, PALETTE(ROW_ON), PALETTE(SELECTED_EDGE), 1); + else ui_style_glass(x, y, w, h, 7, PALETTE(ROW), PALETTE(ROW_EDGE)); +} + +void ui_style_setting(float x, float y, float w, float h, float scale, const char* label, const char* badge, + const char* value, int selected) +{ + float size = ui_style_text_size(UI_TEXT_NAME) * scale; + float textY = y + (h - size) * 0.5f, right = x + w - size * 1.2f; + float valueW = ui_overlay_text_width(UI_FONT_BOLD, size, value); + + ui_style_row(x, y, w, h, selected); + float labelEnd = x + size * 1.1f + ui_style_text_scaled(UI_TEXT_NAME, scale, x + size * 1.1f, textY, + UI_ALIGN_LEFT, label); + if (badge && badge[0]) { + float bs = scale * 0.75f, bSize = ui_style_text_size(UI_TEXT_WARNING) * bs; + float bw = ui_overlay_text_width(UI_FONT_REGULAR, bSize, badge) + bSize; + float bx = labelEnd + size * 0.6f, bh = bSize * 1.6f; + ui_overlay_outline(bx, y + (h - bh) * 0.5f, bw, bh, 3.0f, 0.8f, Fade(PALETTE(WARNING), 0.7f)); + ui_style_text_scaled(UI_TEXT_WARNING, bs, bx + bSize * 0.5f, y + (h - bSize) * 0.5f, UI_ALIGN_LEFT, badge); + } + // The value between its arrows, the way the dashboard's settings show it. + ui_overlay_text(UI_FONT_BOLD, size, right, textY, UI_ALIGN_RIGHT, PALETTE(TAB), "\xE2\x80\xBA"); + ui_style_text_scaled(UI_TEXT_NAME, scale, right - size * 1.2f, textY, UI_ALIGN_RIGHT, value); + ui_overlay_text(UI_FONT_BOLD, size, right - size * 1.8f - valueW, textY, UI_ALIGN_RIGHT, PALETTE(TAB), + "\xE2\x80\xB9"); +} + +static float Bumper(int button, const char* key, float size, float x, float y, bool measureOnly) +{ + if (ui_overlay_keyboard() && key) + return measureOnly ? ui_overlay_key_width(key, size) : ui_overlay_key(key, size, x, y, 0xFFFFFFFF); + return measureOnly ? ui_overlay_button_width(button, size) : ui_overlay_button(button, size, x, y, 0xFFFFFFFF); +} + +void ui_style_tabs(float left, float right, float y, float h, const char* const* names, int count, int selected, + const char* leftKey, const char* rightKey, float* starts, float* ends) +{ + float size = h * 0.45f, pad = h * 0.4f, gap = 3.0f, total = 0.0f, glyph = h * 0.64f; + for (int i = 0; i < count; i++) + total += ui_overlay_text_width(UI_FONT_BOLD, size, names[i]) + 2 * pad + gap; + total -= gap; + Bumper(UI_BUTTON_LEFT_SHOULDER, leftKey, glyph, left, y + (h - glyph) * 0.5f, false); + Bumper(UI_BUTTON_RIGHT_SHOULDER, rightKey, glyph, + right - Bumper(UI_BUTTON_RIGHT_SHOULDER, rightKey, glyph, 0, 0, true), y + (h - glyph) * 0.5f, false); + float x = (left + right) * 0.5f - total * 0.5f; + for (int i = 0; i < count; i++) { + float w = ui_overlay_text_width(UI_FONT_BOLD, size, names[i]) + 2 * pad; + if (i == selected) { + ui_style_surface(x, y, w, h, 6, PALETTE(TAB_ON), PALETTE(SELECTED_EDGE), 1); + ui_overlay_text_glow(UI_FONT_BOLD, size, x + w * 0.5f, y + (h - size) * 0.5f, UI_ALIGN_CENTER, + 0xFFFFFFFF, PALETTE(PROMPT_GLOW), names[i]); + } else { + ui_overlay_text(UI_FONT_BOLD, size, x + w * 0.5f, y + (h - size) * 0.5f, UI_ALIGN_CENTER, + PALETTE(TAB), names[i]); + } + if (starts) starts[i] = x; + if (ends) ends[i] = x + w; + x += w + gap; + } +} + +static float PromptGlyph(const ui_style_prompt* p, float size, float x, float y, bool measureOnly) +{ + if (p->key && ui_overlay_keyboard()) + return measureOnly ? ui_overlay_key_width(p->key, size) : ui_overlay_key(p->key, size, x, y, 0xFFFFFFFF); + return measureOnly ? ui_overlay_button_width(p->button, size) : ui_overlay_button(p->button, size, x, y, 0xFFFFFFFF); +} + +void ui_style_prompts(float right, float y, float size, const ui_style_prompt* prompts, int count, + float* starts, float* ends) +{ + float gap = size, total = 0.0f; + for (int i = 0; i < count; i++) { + total += PromptGlyph(&prompts[i], size, 0, 0, true) + 4.0f + + ui_overlay_text_width(UI_FONT_BOLD, size * 0.72f, prompts[i].word); + if (i) total += gap; + } + float x = right - total; + for (int i = 0; i < count; i++) { + if (starts) starts[i] = x; + x += PromptGlyph(&prompts[i], size, x, y, false) + 4.0f; + x += ui_overlay_text_glow(UI_FONT_BOLD, size * 0.72f, x, y + size * 0.14f, UI_ALIGN_LEFT, PALETTE(PROMPT), + PALETTE(PROMPT_GLOW), prompts[i].word); + if (ends) ends[i] = x; + x += gap; + } +} diff --git a/theseus/desktop/virtual_games.h b/theseus/desktop/virtual_games.h index 9234c93..6573285 100644 --- a/theseus/desktop/virtual_games.h +++ b/theseus/desktop/virtual_games.h @@ -23,7 +23,7 @@ struct VirtualGame { char name[128]; // display name and virtual folder name char titleID[16]; // hex title id, e.g. "45410013" - char launch[512]; + char launch[1024]; // an xemu line carries two full paths char drive[4]; // "E", "F", "G" char category[32]; // "Games", "Applications", "Homebrew", ... // Optional folder path under the category, "/" separated, e.g. @@ -65,7 +65,7 @@ inline void VGames_Load() { FILE* fp = fopen(VGAMES_INI, "r"); if (!fp) return; - char line[1024]; + char line[2048]; VirtualGame* cur = NULL; while (fgets(line, sizeof(line), fp)) { @@ -238,22 +238,7 @@ inline void VGames_DeleteByName(const char* name) { g_vgames.games[idx].valid = false; g_vgames.generation++; - SafeSave save(AppPath("Configs/games.ini")); - FILE* fp = save.f; - if (fp) { - for (int i = 0; i < g_vgames.count; i++) { - if (!g_vgames.games[i].valid) continue; - if (VGames_IsStreaming(g_vgames.games[i]) || g_vgames.games[i].transient) continue; - fprintf(fp, "[%s]\n", g_vgames.games[i].name); - fprintf(fp, "TitleID=%s\n", g_vgames.games[i].titleID); - fprintf(fp, "Launch=%s\n", g_vgames.games[i].launch); - fprintf(fp, "Drive=%s\n", g_vgames.games[i].drive); - if (g_vgames.games[i].category[0]) - fprintf(fp, "Category=%s\n", g_vgames.games[i].category); - fprintf(fp, "\n"); - } - save.Commit(); - } + VGames_Save(); fprintf(stderr, "[VGames] Deleted: %s\n", name); }