diff --git a/.github/actions/setup-v/action.yml b/.github/actions/setup-v/action.yml index dbfecdb..cd429de 100644 --- a/.github/actions/setup-v/action.yml +++ b/.github/actions/setup-v/action.yml @@ -18,7 +18,15 @@ runs: repository: vlang/tccbin ref: d6e7ac1b1bcc98aed734a6ecbfa8509f24606c74 path: toolchain/v/thirdparty/tcc - - shell: bash + - if: runner.os != 'Windows' + shell: bash run: | make -C toolchain/v local=1 echo "$GITHUB_WORKSPACE/toolchain/v" >> "$GITHUB_PATH" + - if: runner.os == 'Windows' + shell: cmd + working-directory: toolchain/v + run: makev.bat -msvc --local + - if: runner.os == 'Windows' + shell: pwsh + run: '"$env:GITHUB_WORKSPACE\toolchain\v" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append' diff --git a/.github/workflows/additional-examples.yml b/.github/workflows/additional-examples.yml index df35ca7..ba48b04 100644 --- a/.github/workflows/additional-examples.yml +++ b/.github/workflows/additional-examples.yml @@ -26,7 +26,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: repository: antono2/imgui - ref: 658d1fa0e863c3d0345ffdd9685fee05876a7648 + ref: 48d6c6614ead130450791a72179f711f4090a7e8 path: build/modules/antono2/imgui submodules: recursive - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/.github/workflows/compile.yml b/.github/workflows/compile.yml index dd5d7a7..f31064b 100644 --- a/.github/workflows/compile.yml +++ b/.github/workflows/compile.yml @@ -26,7 +26,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: repository: antono2/imgui - ref: a28bfc544a46fd80221bdfe0aa45e161df9782d0 + ref: fa2e194d447a4cb0975126b1d83e45889e7cadd9 path: source/modules/antono2/imgui submodules: recursive - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -60,13 +60,14 @@ jobs: make local=1 echo "$GITHUB_WORKSPACE/toolchain/v" >> "$GITHUB_PATH" - name: Install native dependencies - run: sudo apt-get update && sudo apt-get install -y build-essential cmake libglfw3-dev libvulkan-dev libvulkan-volk-dev pkg-config mesa-vulkan-drivers vulkan-validationlayers xvfb + run: sudo apt-get update && sudo apt-get install -y build-essential cmake libglfw3-dev libvulkan-dev libvulkan-volk-dev pkg-config mesa-vulkan-drivers vulkan-validationlayers xvfb openbox xdotool x11-utils x11-xserver-utils wmctrl - name: Build native ImGui library working-directory: source/modules/antono2/imgui run: v run build_vimgui.vsh --linkage shared --glfw system - name: Compile GLFW/Vulkan example run: | v -check-syntax source/demo/setup.vsh + ./source/demo/setup.vsh --help for example in glfw_vulkan widget_gallery implot_dashboard; do source=source/demo if [ "$example" != glfw_vulkan ]; then source="source/demo/examples/$example"; fi @@ -74,6 +75,8 @@ jobs: done - name: Render desktop examples with Vulkan validation run: bash source/demo/scripts/smoke_desktop.sh "$RUNNER_TEMP" + - name: Check desktop keyboard shortcuts + run: ./source/demo/tests/desktop/shortcuts.sh "$RUNNER_TEMP" --raw-gallery - name: Verify executable run: test -x "$RUNNER_TEMP/glfw_vulkan" && test -x "$RUNNER_TEMP/widget_gallery" && test -x "$RUNNER_TEMP/implot_dashboard" @@ -89,7 +92,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: repository: antono2/imgui - ref: a28bfc544a46fd80221bdfe0aa45e161df9782d0 + ref: fa2e194d447a4cb0975126b1d83e45889e7cadd9 path: source/modules/antono2/imgui submodules: recursive - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -127,7 +130,7 @@ jobs: "$env:GITHUB_WORKSPACE\toolchain\v" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - name: Install Vulkan SDK and SwiftShader test artifacts shell: bash - run: bash source/modules/antono2/imgui/.github/scripts/setup-vulkan-ci.sh 1.4.309.0 v1.2 + run: v run source/modules/antono2/imgui/scripts/setup_vulkan_ci.vsh 1.4.309.0 v1.2 - name: Install matching Volk header shell: pwsh run: | @@ -201,7 +204,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: repository: antono2/imgui - ref: a28bfc544a46fd80221bdfe0aa45e161df9782d0 + ref: fa2e194d447a4cb0975126b1d83e45889e7cadd9 path: source/modules/antono2/imgui submodules: recursive - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml new file mode 100644 index 0000000..e6b77ff --- /dev/null +++ b/.github/workflows/release-packages.yml @@ -0,0 +1,144 @@ +name: Portable release packages +on: + pull_request: + branches: [master] + workflow_dispatch: +permissions: + contents: read +jobs: + desktop: + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-2022] + variant: [docking, standard] + include: + - variant: docking + imgui-ref: fa2e194d447a4cb0975126b1d83e45889e7cadd9 + - variant: standard + imgui-ref: 48d6c6614ead130450791a72179f711f4090a7e8 + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + env: + VMODULES: ${{ github.workspace }}/build/modules + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: ./.github/actions/setup-v + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: antono2/imgui + ref: ${{ matrix.imgui-ref }} + path: build/modules/antono2/imgui + submodules: recursive + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: antono2/vulkan + ref: v3.2.0 + path: build/modules/antono2/vulkan + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: antono2/glfw + ref: v2.0.0 + path: build/modules/antono2/glfw + - name: Install Linux build and rendering tools + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y build-essential cmake libglfw3-dev libvulkan-dev libvulkan-volk-dev libdbus-1-dev libatk1.0-dev libatk-bridge2.0-dev libatspi2.0-dev libglib2.0-dev at-spi2-core dbus-x11 pkg-config mesa-vulkan-drivers vulkan-validationlayers ripgrep gdb xvfb patchelf zip xdotool openbox x11-utils x11-xserver-utils wmctrl + - name: Install Windows Vulkan build tools + if: runner.os == 'Windows' + shell: bash + run: v run build/modules/antono2/imgui/scripts/setup_vulkan_ci.vsh 1.4.309.0 v1.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + if: runner.os == 'Windows' + with: + repository: zeux/volk + ref: 58689c063427f5bad4f133625049b1a3c5dd8287 + path: build/volk + - name: Install Windows Volk header + if: runner.os == 'Windows' + shell: pwsh + run: | + $Destination = Join-Path $env:VULKAN_SDK 'Include/volk' + New-Item -ItemType Directory -Force $Destination | Out-Null + Copy-Item build/volk/volk.h, build/volk/volk.c $Destination + - name: Build, bundle and render Linux releases + if: runner.os == 'Linux' + run: ./scripts/build_release.v '${{ matrix.variant }}' + - name: Exercise bundled gallery through native AT-SPI + if: runner.os == 'Linux' + run: | + c++ -std=c++17 tests/desktop/accessibility.cpp $(pkg-config --cflags --libs atspi-2 gio-2.0) -o build/accessibility-check + GSETTINGS_BACKEND=memory dbus-run-session -- xvfb-run -a build/accessibility-check "build/v-imgui-examples-linux-x64-${{ matrix.variant }}/widget_gallery" "build/release-atspi-${{ matrix.variant }}.log" + - name: Check bundled desktop keyboard shortcuts + if: runner.os == 'Linux' + run: | + sudo sysctl kernel.yama.ptrace_scope=0 + ./tests/desktop/shortcuts.sh "build/v-imgui-examples-linux-x64-${{ matrix.variant }}" + - name: Build and audit Windows release DLLs + if: runner.os == 'Windows' + shell: pwsh + run: | + . scripts/setup_windows_build.ps1 + v -prod run scripts/build_release.v '${{ matrix.variant }}' + if ($LASTEXITCODE -ne 0) { throw 'Portable release build failed' } + & "build/v-imgui-examples-windows-x64-${{ matrix.variant }}/examples.exe" --help + if ($LASTEXITCODE -ne 0) { throw 'Examples launcher failed' } + - name: Exercise native Windows UI Automation + if: runner.os == 'Windows' + shell: pwsh + run: | + . scripts/setup_windows_build.ps1 + cl.exe /nologo /std:c++17 /EHsc tests/desktop/windows_text.cpp /Febuild/windows-text-check.exe /Fobuild/windows-text-check.obj /link uiautomationcore.lib ole32.lib oleaut32.lib + if ($LASTEXITCODE -ne 0) { throw 'Native text probe build failed' } + powershell.exe -Mta -NoProfile -ExecutionPolicy Bypass -File tests/desktop/accessibility.ps1 -Executable "build/v-imgui-examples-windows-x64-${{ matrix.variant }}/widget_gallery.exe" -SelectionProbe "$PWD/build/windows-text-check.exe" + if ($LASTEXITCODE -ne 0) { throw 'Native UI Automation check failed' } + - name: Upload portable desktop package + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: desktop-${{ runner.os }}-${{ matrix.variant }} + path: build/v-imgui-examples-*.zip + if-no-files-found: error + - name: Upload rendering logs + if: always() && runner.os == 'Linux' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: render-${{ matrix.variant }} + path: | + build/v-imgui-examples-linux-*/smoke-logs + build/v-imgui-examples-linux-*/shortcut-logs + + android: + strategy: + fail-fast: false + matrix: + abi: [armeabi-v7a, arm64-v8a, x86_64] + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + ANDROID_ABI: ${{ matrix.abi }} + VIMGUI_ANDROID_APPLICATION_UI: '1' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: ./.github/actions/setup-v + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - uses: android-actions/setup-android@v3 + with: + packages: platform-tools + - name: Install Android build tools + run: | + sdkmanager --install 'ndk;27.3.13750724' 'platforms;android-36' 'build-tools;36.0.0' + echo "ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/27.3.13750724" >> "$GITHUB_ENV" + - name: Build accessible touch app + run: bash scripts/build_android.sh --build-only + - name: Collect Android notices + run: ./scripts/collect_licenses.v build/modules/antono2/imgui "build/android-touch-$ANDROID_ABI/licenses" android + - name: Upload unsigned APK and notices for release signing + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: android-release-input-${{ matrix.abi }} + path: | + build/android-touch-${{ matrix.abi }}/vimgui-demo-unsigned.apk + build/android-touch-${{ matrix.abi }}/licenses + if-no-files-found: error diff --git a/.gitignore b/.gitignore index d9f3940..cf25904 100644 --- a/.gitignore +++ b/.gitignore @@ -26,9 +26,12 @@ bin/ imgui.ini main.cpp run.sh +!packaging/run.sh tmp.txt .editorconfig # Reproducible dependency checkouts and build artifacts build/ *.apk + +__pycache__/ diff --git a/IMGUI_REVISION b/IMGUI_REVISION index 414802a..e2b9f21 100644 --- a/IMGUI_REVISION +++ b/IMGUI_REVISION @@ -1 +1 @@ -a28bfc544a46fd80221bdfe0aa45e161df9782d0 +fa2e194d447a4cb0975126b1d83e45889e7cadd9 diff --git a/README.md b/README.md index 3648607..e2bf3a5 100644 --- a/README.md +++ b/README.md @@ -19,15 +19,49 @@ with Vulkan validation in both standard and docking variants; Windows compiles all desktop examples. Android CI builds debug APKs for three ABIs. Device testing is required before release; see [the testing checklist](docs/testing.md). -## Recommended quick start +## Try a release -From this checkout, the cross-platform setup installs/builds ImGui and compiles -the example without opening a window: +[Download a release](https://github.com/antono2/v_imgui_examples/releases) and follow +[installation instructions](docs/installing-releases.md). Desktop ZIPs bundle +their application runtime libraries; Android APKs include their native libraries +and font. The gallery and Android app include labelled native accessibility +controls, high contrast and text sizing. Choose **Docking** unless you specifically +want the Standard variant. + +- **Windows:** extract the ZIP and open `examples.exe` to choose an example. +- **Linux:** extract the ZIP and run `./run.sh` for the example menu. +- **Android:** install the universal APK and open **V ImGui Touch Examples**. + +In each desktop example, **Escape** quits when a control is not using it to +cancel an edit, drag, popup or navigation operation. **F11** toggles borderless +fullscreen on the window's monitor; pressing it again restores the window. + +## Build from source + +Install V and Git, then run this command from the checkout: + +```sh +./setup.vsh +``` + +Use the official V compiler. The tested compiler revisions are recorded in +[the CI compiler setup](.github/actions/setup-v/action.yml). +On Windows, use `v run setup.vsh` with the same options; the direct executable +script commands below apply to Linux and macOS. + +The guided runner lets you choose an example, explains setup changes, builds it +and opens it. It streams build progress and offers to run an existing desktop +build or rebuild it on later visits. Widget gallery is the default choice. +Missing tools are reported with next steps. System package installation requires +confirmation in the guided flow. + +For automation, explicit options skip the menus and setup confirmation. System +installers may still request administrator authorization: ```sh -v run setup.vsh -v run setup.vsh --example widget_gallery -v run setup.vsh --example implot_dashboard +./setup.vsh --install --example widget_gallery --run +./setup.vsh --build-only --example implot_dashboard --run +./setup.vsh --check --example widget_gallery ``` Setup uses an isolated `build/modules` dependency directory and checks out the @@ -35,17 +69,19 @@ ImGui commit recorded in `IMGUI_REVISION`. It does not discard changes in an existing dependency checkout. Set `VMODULES` to use a different module directory. The setup may install system prerequisites through the upstream setup script. -On Linux/macOS, launch `build/glfw_vulkan`, `build/widget_gallery`, or -`build/implot_dashboard` after setup. Windows setup reports its executable path. -Use `--build-only` to compile using dependencies already prepared, or `--check` -for read-only prerequisite diagnostics. Both accept `--example`. +Explicit builds only launch when `--run` is supplied. Use `--build-only` to +compile with prepared dependencies, or `--check` for read-only prerequisite +diagnostics. Windows keeps each built example beside its runtime DLLs. -For Android, set the SDK/NDK paths and ABI, then run -`scripts/build_android.sh --build-only`. It builds a signed debug APK without -installing it. See [Android instructions](examples/android_touch/README.md). +Choose Android in the menu for SDK/NDK detection and connected-device selection. +Install the Android development tools first; the runner can ask for their paths +but does not download the SDK. It builds, installs and opens a debug APK after +confirmation. The guided Android flow requires Bash and the toolchain described +in [Android instructions](examples/android_touch/README.md). Those instructions +also cover explicit builds without installing on a device. -The pinned ImGui commit includes the external-example host options. Until its -upstream PR is merged, use this revision rather than an older installed module. +The pinned ImGui commit includes the external-example host options. Use this +revision rather than an older installed module. The ImGui repository owns native-library setup and pins a tested revision of this example. On Ubuntu or Debian, the shortest supported path is: @@ -106,7 +142,8 @@ the upstream native host. `examples/glfw_vulkan/` contains the shared desktop loop, native flags, and the default demo UI. ImPlot context/spec objects are created and destroyed through host lifecycle callbacks. -iOS/Metal and the optional application/accessibility layer are planned additions. -They are not yet examples in this repository. +The release gallery and touch app integrate the application/accessibility layer. +Raw API views retain ordinary ImGui widgets; they do not automatically expose +screen-reader semantics. iOS/Metal remains an upstream source integration. ![V + Vulkan + GLFW + Dear ImGui](Snapshot_glfw_vulkan.png) diff --git a/docs/installing-releases.md b/docs/installing-releases.md new file mode 100644 index 0000000..ecff7f9 --- /dev/null +++ b/docs/installing-releases.md @@ -0,0 +1,88 @@ +# Installing release downloads + +Download assets from [Releases](https://github.com/antono2/v_imgui_examples/releases). +The downloads include their application libraries. You do not need to install +V, CMake, GLFW, an Android SDK, or a separate Visual C++ runtime. +Your operating system, graphical desktop and graphics driver still provide the +platform services. The examples use Vulkan; a supported Vulkan graphics driver +is required. + +## Desktop + +Choose a ZIP for your OS and **x64** processor. **Docking** is the recommended download: + +- **Windows:** Windows 10 or 11. Extract the entire ZIP to a writable folder. + Open `examples.exe`, then choose an example from its menu. Press Enter for the + Widget gallery. You can also open each example executable directly. + Keep the DLLs beside the executables. +- **Linux:** Ubuntu 24.04 or a compatible newer distribution, with an X11 or + XWayland desktop. Extract the entire ZIP. Run `./run.sh`, or + choose an example from its menu (Enter opens Widget gallery), or run + `./run.sh implot_dashboard` / `./run.sh glfw_vulkan` directly. + Keep the `lib` directory beside the executables. If your archive tool drops + executable permissions, run `chmod +x run.sh examples glfw_vulkan widget_gallery implot_dashboard`. + +**Docking** includes ImGui docking and optional platform viewports. +**Standard** uses independent ImGui windows. Both contain all three examples. +You can move the extracted folder; binaries use package-relative library paths. +Press **F11** to toggle borderless fullscreen and restore the previous window +position and size. **Escape** quits, after any active edit or popup consumes it. +`licenses/` and `RUNTIME-LIBRARIES.txt` identify bundled components. + +## Android: installation without a Play Store + +The Android app needs Android 7.0 (API 24) or later and Vulkan 1.0 support. + +1. Download `v-imgui-touch-universal.apk` on the device. It contains ARM 32-bit, + ARM 64-bit, and x86_64 libraries, so you do not need to choose an ABI. + Smaller APKs for each ABI are also available. +2. Open the download. If Android asks, allow **Install unknown apps** for the + browser or file manager used to open this APK, then return to the installer. + Menu names vary by device. You can disable that permission after installation. +3. Install and open **V ImGui Touch Examples**. Everything needed by the app, + including native libraries and its font, is in the APK. + +To update, install a later release APK over the existing app. Published releases +use the same signing identity. Builds from CI are developer test APKs signed +with debug keys; to replace one with a release, uninstall the test app first. +Uninstalling removes its application data. A signature conflict is not fixed by +installing extra libraries. + +Optional installation from a computer with Android platform tools: + +```sh +adb install -r v-imgui-touch-universal.apk +``` + +The app has no Internet permission. Clipboard copy uses the whole editable +field, and read displays a preview. Touch scrolling works on static text and +empty space. Navigation bars and the keyboard reserve space automatically. + +## Accessibility in the examples + +The desktop widget gallery opens in **Accessible controls** mode. It includes +labelled input, native button/checkbox/radio actions, keyboard focus, status and +progress, high contrast, 200% text, and a virtual file list with off-screen focus. +The **Raw ImGui widgets** view retains the original API gallery, tables and +popups. Raw ImGui widgets and ImPlot charts do not automatically publish native +screen-reader semantics; use Accessible controls to exercise that layer. + +The Android touch app uses the same application/accessibility layer for its +normal controls. Try TalkBack or your device's accessibility service, keyboard +navigation, high contrast and larger text. The native adapter exposes Unicode +text and selection to assistive technology and forwards edits to the keyboard. +Font coverage depends on the bundled font; Unicode round-trips even when a +particular glyph is unavailable. + +There is no separate accessibility app to install. On Linux the native bridge +uses AT-SPI, on Windows UI Automation, and on Android native accessibility nodes. +See [testing](testing.md) for the verified checks and manual release checklist. + +## Other platforms and verification + +Upstream includes iOS/Metal source integrations. Installable iOS artifacts are +not part of this release: device signing and hardware testing remain necessary. +macOS portable Vulkan packages are also not currently published. + +Compare a downloaded file with `SHA256SUMS.txt` from the same release. On Linux +use `sha256sum `; on Windows use `Get-FileHash -Algorithm SHA256`. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..cc9aaec --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,47 @@ +# Creating release assets + +Run the **Portable release packages** workflow on the intended examples commit. +It builds both pinned ImGui variants, bundles all non-OS desktop libraries, +audits runtime dependencies, renders the Linux bundles and builds unsigned +Android inputs for three ABIs. Download the artifacts from that exact run. +Retain the run URL and source commits in release notes. Do not publish CI debug +APKs as release updates: their signing keys are temporary. + +The release helpers are V programs with shared build, packaging, license and +ZIP code in `scripts/release_tools/`. From prepared dependencies, run +`./scripts/build_release.v docking` (or `standard`) on Linux. On Windows +first load `. scripts/setup_windows_build.ps1` in PowerShell to select Visual +Studio, then use `v -prod run scripts/build_release.v docking`. For the other V +helpers below, Windows also uses `v run` (or `v -prod run`) before the script path. +To package existing binaries, use `./scripts/package_release.v +binary-dir imgui-dir output-dir docking`. License collection uses +`./scripts/collect_licenses.v imgui-dir output-dir`, with `android` as its +last argument for mobile notices. + +Desktop ZIPs are ready to promote after reviewing the checks. Android APKs are +signed outside CI with the persistent private release key: + +```sh +./scripts/sign_android_release.v \ + --input /path/to/android-release-input-armeabi-v7a \ + --input /path/to/android-release-input-arm64-v8a \ + --input /path/to/android-release-input-x86_64 \ + --output /path/to/release-assets \ + --build-tools "$ANDROID_SDK_ROOT/build-tools/36.0.0" \ + --keystore /private/path/release.keystore \ + --password-file /private/path/password.txt +``` + +The script rejects mismatched common APK content, combines the ABI libraries, +embeds license notices, aligns the APKs, signs them and verifies each signature. +Keep the key and password private and backed up outside the repository. Never +upload them as release assets. Use the same signing identity for updates. +The private key uses the same password as the keystore. +Increment `android:versionCode` and update `android:versionName` for each release. + +Test the final universal APK on a device, including the installation/update +path. Review [the testing checklist](testing.md) and package instructions. +Generate `SHA256SUMS.txt` from the final ZIP and APK files. Tag the verified +commits only after checks pass, then publish the assets and installation link. +The upstream imgui release has a docking tag and a companion `-standard` source +tag; the same example packages can be attached to both repositories' releases. diff --git a/docs/testing.md b/docs/testing.md index 94160c6..b4abfb7 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -30,6 +30,14 @@ scripts/smoke_desktop.sh "$PWD/build" - [x] Change plot amplitude/frequency and series visibility, including legend clicks. - [x] Pan/zoom waveforms; pause/resume/reset history and observe a full wrap. - [x] Close each application normally. +- [x] Escape quits each desktop example; active text editing and popups consume it first. +- [x] F11 toggles borderless fullscreen, preserves normal/maximized restore bounds, and does not repeat while held. + +Run `./tests/desktop/shortcuts.sh binary-dir --raw-gallery` for keyboard checks +of source builds, or omit `--raw-gallery` for accessible release builds. The +check uses its own Xvfb display with Openbox, xdotool, xprop and wmctrl. +It also checks that the display mode is unchanged and enables Vulkan validation +while toggling fullscreen. These keyboard checks were verified locally on Linux. ## Android interaction @@ -56,3 +64,30 @@ adb logcat -s vimgui-android-demo:I AndroidRuntime:E Release only after reviewing CI results and recording the interactive results. No physical-device or assistive-technology coverage is implied by compilation. + +## Integrated accessibility release checks + +The portable workflow renders each Linux bundle after copying its dependencies +and exercises the gallery through the real AT-SPI bus, including native roles, +button actions, status, high contrast, 200% text, off-screen list focus/selection +and view switching. Windows bundles are checked for missing imported runtime +DLLs. The Windows workflow also opens the packaged gallery in a runner's user +session and checks UI Automation actions, text/selection, list selection and +exact scroll percentages. Narrator speech still needs manual validation. +Unicode selected-text reads use the native `IUIAutomation` client API. The +legacy .NET selected-range wrapper also crashes against Windows' built-in +RichEdit on this runner; the comparison and reproduction instructions are in +[upstream's text checks](https://github.com/antono2/imgui/blob/master/tests/accessibility/README.md). + +On the connected Android tablet, the integrated accessibility build passed native +roles/actions, checkbox state, progress ranges, Unicode text and UTF-16 selection, +full-field clipboard copy/read and feedback, zoom, scrolling from static text, +and resume state. Repeat with the final release APK and test portrait/landscape, +visible system navigation bars and IME. Check TalkBack spoken navigation manually; +provider action tests do not establish the quality of spoken announcements. + +To run instrumentation against a signed release APK, set +`VIMGUI_ANDROID_TEST_APK`, `VIMGUI_ANDROID_TEST_KEYSTORE` and +`VIMGUI_ANDROID_TEST_PASSWORD_FILE` before running +`scripts/test_android_accessibility.sh`. A debug installation with a different +certificate must be removed first; uninstalling it removes its saved demo data. diff --git a/examples/android_touch/AndroidManifest.xml b/examples/android_touch/AndroidManifest.xml index 76f6bf1..a26edc0 100644 --- a/examples/android_touch/AndroidManifest.xml +++ b/examples/android_touch/AndroidManifest.xml @@ -1,6 +1,7 @@ + package="io.antono2.vimgui.examples.touch" + android:versionCode="1" android:versionName="1.0.0"> diff --git a/examples/android_touch/README.md b/examples/android_touch/README.md index 8169d10..eaf6a1d 100644 --- a/examples/android_touch/README.md +++ b/examples/android_touch/README.md @@ -7,6 +7,14 @@ bridge, and FreeType font. It has its own package ID, Install a JDK, CMake/Ninja, V, Android SDK platform/build-tools 36, and NDK r27c. Android API 24 and a Vulkan-capable device are required. From the repository root: +Run `./setup.vsh` and choose **Android touch and text** for guided SDK/NDK +detection, device selection, building and installation. Enable USB debugging on +the device and accept its authorization prompt. The runner asks before building +and installing; it never uninstalls an existing app to resolve a signing conflict. +If multiple NDKs or devices are available, it asks which one to use. + +For a build without prompts or device installation: + ```sh export ANDROID_SDK_ROOT=/path/to/android-sdk export ANDROID_NDK_HOME="$ANDROID_SDK_ROOT/ndk/27.3.13750724" @@ -37,3 +45,20 @@ point. Inline marked-text styling and candidate geometry remain upstream limits. Follow [the release checklist](../../docs/testing.md). APK creation and symbol checks do not establish correct behavior on a real keyboard or device. + +## Accessible build and release installation + +Release APKs use the application/accessibility layer in this same V UI. Set +`VIMGUI_ANDROID_APPLICATION_UI=1` before running the build script to enable +labelled native accessibility controls, high contrast and the touch-sized theme. +This build uses the Android SDK/NDK and Java toolchain; the upstream +script builds the native C++ accessibility bridge and Java node provider. +The host retains ownership of the input bridge, lifecycle and safe area. + +End users only download and install an APK; follow +[installation without a Play Store](../../docs/installing-releases.md). +No development tools or additional application libraries are needed on the device. +Run `scripts/test_android_accessibility.sh` against the accessible debug build on +a connected device to exercise roles/actions, Unicode selection, full-field +clipboard, text sizing, content swipe and resume. It keeps configured accessibility +services enabled. Human screen-reader navigation remains part of release testing. diff --git a/examples/android_touch/accessibility_d_release_accessibility.v b/examples/android_touch/accessibility_d_release_accessibility.v new file mode 100644 index 0000000..83ec16e --- /dev/null +++ b/examples/android_touch/accessibility_d_release_accessibility.v @@ -0,0 +1,94 @@ +module main + +import math +import antono2.imgui +import antono2.imgui.appui + +#flag -I @VMODROOT/examples/android_touch + +#include "accessibility_state.h" + +fn C.v_imgui_touch_contrast() &bool + +fn C.vimgui_app_input(u64, &char, &char, usize) bool + +// The native host initializes the appui context and Java accessibility adapter. +// The borrowed text/count/zoom buffers retain their existing lifecycle behavior. +fn draw_accessible_touch(zoom &f32, taps &int, text &char, text_capacity int, clipboard &char, clipboard_capacity int) bool { + appui.begin_frame() + storage := imgui.get_state_storage() + base_id := imgui.get_id_str(c'##accessible-base-scale') + mut base_scale := imgui.storage_get_float(storage, base_id, 0) + if base_scale == 0 { + base_scale = imgui.get_style().FontScaleMain / unsafe { *zoom } + imgui.storage_set_float(storage, base_id, base_scale) + } + contrast := C.v_imgui_touch_contrast() + appui.text(2, 'V ImGui: touch and text') + appui.text(3, 'Swipe text or empty space to scroll. Use touch, a keyboard, or your screen reader.') + appui.checkbox(4, 'High contrast', contrast) + if appui.button(5, 'Tap counter') { + unsafe { *taps += 1 } + } + count := 'Taps: ${unsafe { *taps }}' + appui.status(6, count) + appui.progress(7, 'Next hundred taps', f32(unsafe { *taps } % 100) / 100, count) + unsafe { count.free() } + appui.set_width(-1) + status_id := imgui.get_id_str(c'##clipboard-status') + if C.vimgui_app_input(10, c'Editable text', text, usize(text_capacity)) { + imgui.storage_set_int(storage, status_id, 0) + } + appui.text(11, 'Copy uses the entire editable field; no selection is needed. Read displays the clipboard below.') + if appui.button(12, 'Copy all text') { + if unsafe { text[0] == 0 } { + imgui.storage_set_int(storage, status_id, 1) + } else { + imgui.set_clipboard_text(text) + source := imgui.get_clipboard_text() + copy_preview(clipboard, clipboard_capacity, source) + imgui.storage_set_int(storage, status_id, if same_text(text, source) { 2 } else { 3 }) + } + } + if appui.button(13, 'Read clipboard') { + copy_preview(clipboard, clipboard_capacity, imgui.get_clipboard_text()) + imgui.storage_set_int(storage, status_id, if unsafe { clipboard[0] == 0 } { 4 } else { 5 }) + } + status := match imgui.storage_get_int(storage, status_id, 0) { + 1 { 'The field is empty. Enter text before copying.' } + 2 { 'Copied all text. The clipboard preview is shown below.' } + 3 { 'Copy could not be confirmed. Try again while the app is in the foreground.' } + 4 { 'The clipboard is empty or unavailable.' } + 5 { 'Read clipboard. The preview is shown below.' } + 6 { 'Preview cleared. The system clipboard is unchanged.' } + else { '' } + } + appui.status(14, status) + appui.text(15, 'Clipboard preview:') + preview := unsafe { cstring_to_vstring(clipboard) } + appui.text(16, preview) + unsafe { preview.free() } + if appui.button(17, 'Clear preview') { + unsafe { clipboard[0] = 0 } + imgui.storage_set_int(storage, status_id, 6) + } + appui.separator() + if appui.button(20, 'Smaller text') { + unsafe { *zoom = math.max(f32(0.75), *zoom - f32(0.1)) } + } + appui.same_line_for('Larger text', false) + if appui.button(21, 'Larger text') { + unsafe { *zoom = math.min(f32(2), *zoom + f32(0.1)) } + } + if appui.button(22, 'Reset text size') { + unsafe { *zoom = 1 } + } + scale := 'Text size: ${unsafe { *zoom }:.2f}x' + appui.status(23, scale) + unsafe { scale.free() } + appui.text(24, 'Rotate or resume to check retained text, taps, and text size. Navigation bars and the keyboard reserve their space automatically.') + appui.end_frame() or { panic(err) } + // This view owns its accessible theme, including touch target sizing. + appui.theme(true, unsafe { *contrast }, base_scale * unsafe { *zoom }, true) + return false +} diff --git a/examples/android_touch/accessibility_state.h b/examples/android_touch/accessibility_state.h new file mode 100644 index 0000000..e7c2a41 --- /dev/null +++ b/examples/android_touch/accessibility_state.h @@ -0,0 +1,7 @@ +#ifndef V_IMGUI_TOUCH_ACCESSIBILITY_STATE_H +#define V_IMGUI_TOUCH_ACCESSIBILITY_STATE_H +// The UI DSO stays loaded across context/window recreation. Only the render +// thread reads this retained preference; process restart restores the default. +static bool v_imgui_touch_high_contrast = false; +static bool *v_imgui_touch_contrast(void) { return &v_imgui_touch_high_contrast; } +#endif diff --git a/examples/android_touch/ui.v b/examples/android_touch/ui.v index 738064f..48aaf40 100644 --- a/examples/android_touch/ui.v +++ b/examples/android_touch/ui.v @@ -26,6 +26,9 @@ fn edit_text(mut data imgui.InputTextCallbackData) i32 { // It calls this export between NewFrame and Render on its rendering thread. @[export: 'vimgui_android_demo_draw_ui'] fn draw_ui(zoom &f32, taps &int, text &char, text_capacity int, clipboard &char, clipboard_capacity int, width f32, height f32) bool { + $if release_accessibility ? { + return draw_accessible_touch(zoom, taps, text, text_capacity, clipboard, clipboard_capacity) + } imgui.set_next_window_pos(imgui.get_main_viewport().WorkPos, imgui.Cond(imgui.Cond_.always), imgui.ImVec2_c{}) imgui.set_next_window_size(imgui.ImVec2_c{ x: width, y: height }, imgui.Cond(imgui.Cond_.always)) flags := imgui.WindowFlags(int(imgui.WindowFlags_.no_move) | int(imgui.WindowFlags_.no_resize) | int(imgui.WindowFlags_.no_collapse)) diff --git a/examples/glfw_vulkan/README.md b/examples/glfw_vulkan/README.md index d174730..ef8229d 100644 --- a/examples/glfw_vulkan/README.md +++ b/examples/glfw_vulkan/README.md @@ -3,12 +3,17 @@ From the repository root: ```sh -v run setup.vsh +./setup.vsh --example glfw_vulkan ``` On Linux/macOS, launch `build/glfw_vulkan`. Windows setup reports its executable. The root `main.v` still launches this example. +All desktop examples share **Escape** to quit and **F11** to toggle borderless +fullscreen. Escape first cancels an active edit, drag, popup or navigation +operation. Frame callbacks that bind Escape themselves must set +`app.escape_handled = true` in the frame where they consume it. + `main.c.v` owns the GLFW window, Vulkan objects, frame submission, and shutdown. `ui.v` owns the default demo's persistent state. Other desktop examples pass a frame callback and borrowed state pointer to `glfw_vulkan.run`. Optional diff --git a/examples/glfw_vulkan/imgui_bridge.h b/examples/glfw_vulkan/imgui_bridge.h index ff4dcac..9186fdb 100644 --- a/examples/glfw_vulkan/imgui_bridge.h +++ b/examples/glfw_vulkan/imgui_bridge.h @@ -26,4 +26,24 @@ static inline bool v_imgui_example_draw_data_is_minimized(const ImDrawData *draw return draw_data->DisplaySize.x <= 0.0f || draw_data->DisplaySize.y <= 0.0f; } +// Snapshot before NewFrame: Escape may clear an edit or popup during NewFrame. +static inline bool v_imgui_example_escape_reserved(void) { + const ImGuiContext *g = igGetCurrentContext(); + return igIsAnyItemActive() || igGetDragDropPayload() != NULL || + igIsPopupOpen_Str(NULL, ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel) || + g->NavLayer != ImGuiNavLayer_Main || g->NavWindowingTarget != NULL || + (g->NavWindow && g->NavWindow != g->NavWindow->RootWindow && + !(g->NavWindow->RootWindowForNav->Flags & ImGuiWindowFlags_Popup) && + g->NavWindow->RootWindowForNav->ParentWindow); +} + +static inline bool v_imgui_example_escape_pressed(void) { + // ImGuiKeyOwner_NoOwner is -1; zero means any owner. + return igIsKeyPressed_InputFlags(ImGuiKey_Escape, 0, (ImGuiID)-1); +} + +static inline bool v_imgui_example_fullscreen_pressed(void) { + return igIsKeyPressed_Bool(ImGuiKey_F11, false); +} + #endif diff --git a/examples/glfw_vulkan/main.c.v b/examples/glfw_vulkan/main.c.v index aa73067..5a4ad89 100644 --- a/examples/glfw_vulkan/main.c.v +++ b/examples/glfw_vulkan/main.c.v @@ -14,6 +14,12 @@ fn C.v_imgui_example_draw_data_is_minimized(draw_data &imgui.ImDrawData) bool fn C.v_imgui_example_smoke_frame_limit() int +fn C.v_imgui_example_escape_reserved() bool + +fn C.v_imgui_example_escape_pressed() bool + +fn C.v_imgui_example_fullscreen_pressed() bool + // Hosts own Vulkan and GLFW; examples supply a frame callback and persistent state. pub fn main() { mut state := DemoState{} @@ -41,6 +47,10 @@ pub fn run(options Options, frame fn (mut App, voidptr), state voidptr) { // Create window with Vulkan context glfw.window_hint(glfw.client_api, glfw.no_api) + // Map the window after its renderer and UI are ready. + glfw.window_hint(glfw.visible, 0) + // Borderless fullscreen should remain available when switching applications. + glfw.window_hint(glfw.auto_iconify, 0) main_scale := f32(1.0) window := glfw.create_windowed(i32(1200 * main_scale), i32(800 * main_scale), options.title) or { @@ -51,7 +61,7 @@ pub fn run(options Options, frame fn (mut App, voidptr), state voidptr) { panic('GLFW: Vulkan Not Supported') } - mut app := App{} + mut app := App{ native_window: voidptr(window) } mut extensions := []&char{} mut extensions_count := u32(0) @@ -112,12 +122,18 @@ pub fn run(options Options, frame fn (mut App, voidptr), state voidptr) { impl_vulkan.vkinit(&init_info) options.initialize(state) + // Drain startup events before GLFW's X11 visibility wait. Queued unrelated + // events can prevent that wait from reaching its timeout during creation. + glfw.poll_events() + glfw.show_window(window) // No custom fonts are loaded, so Dear ImGui uses its default font. // Let CI exercise rendering and orderly cleanup without closing the window manually. smoke_frame_limit := C.v_imgui_example_smoke_frame_limit() mut rendered_frames := 0 + mut window_mode := WindowMode{} + window_mode.remember_bounds(window) // Main loop for !glfw.window_should_close(window) { @@ -127,6 +143,8 @@ pub fn run(options Options, frame fn (mut App, voidptr), state voidptr) { // - When io.WantCaptureKeyboard is true, do not dispatch keyboard input data to your main application, or clear/overwrite your copy of the keyboard data. // Generally you may always pass all inputs to dear imgui, and hide them from your application based on those two flags. glfw.poll_events() + window_mode.remember_bounds(window) + escape_reserved := C.v_imgui_example_escape_reserved() // Resize swap chain? framebuffer := glfw.framebuffer_size(window) @@ -152,6 +170,7 @@ pub fn run(options Options, frame fn (mut App, voidptr), state voidptr) { if app.docking_available && app.dockspace_enabled { imgui.create_main_dockspace() } + app.escape_handled = false frame(mut app, state) // Rendering @@ -171,6 +190,13 @@ pub fn run(options Options, frame fn (mut App, voidptr), state voidptr) { if !is_minimized { app.frame_present(mut wd) } + if C.v_imgui_example_fullscreen_pressed() { + window_mode.toggle(window) + } + if C.v_imgui_example_escape_pressed() && !escape_reserved + && !C.v_imgui_example_escape_reserved() && !app.escape_handled { + glfw.set_window_should_close(window, 1) + } rendered_frames++ if smoke_frame_limit > 0 && rendered_frames >= smoke_frame_limit { break @@ -196,6 +222,9 @@ pub fn run(options Options, frame fn (mut App, voidptr), state voidptr) { pub struct App { pub mut: + native_window voidptr + // Set during the frame callback when an example consumes Escape itself. + escape_handled bool allocator &vk.AllocationCallbacks = unsafe { nil } instance vk.Instance physical_device vk.PhysicalDevice diff --git a/examples/glfw_vulkan/shortcuts.v b/examples/glfw_vulkan/shortcuts.v new file mode 100644 index 0000000..a0c6aef --- /dev/null +++ b/examples/glfw_vulkan/shortcuts.v @@ -0,0 +1,75 @@ +module glfw_vulkan + +import antono2.glfw +import math + +// Use the monitor's current video mode for GLFW's windowed fullscreen mode. +struct WindowMode { +mut: + fullscreen bool + x i32 + y i32 + width i32 + height i32 + decorated i32 + maximized bool +} + +fn (mut mode WindowMode) remember_bounds(window &glfw.Window) { + if mode.fullscreen || glfw.get_window_attrib(window, glfw.maximized) != 0 + || glfw.get_window_attrib(window, glfw.iconified) != 0 { + return + } + glfw.get_window_pos(window, &mode.x, &mode.y) + size := glfw.window_size(window) + mode.width = size.width + mode.height = size.height +} + +fn (mut mode WindowMode) toggle(window &glfw.Window) { + if mode.fullscreen { + glfw.set_window_attrib(window, glfw.decorated, mode.decorated) + glfw.set_window_monitor(window, unsafe { nil }, mode.x, mode.y, mode.width, mode.height, glfw.dont_care) + if mode.maximized { glfw.maximize_window(window) } + mode.fullscreen = false + return + } + mut x := i32(0) + mut y := i32(0) + glfw.get_window_pos(window, &x, &y) + size := glfw.window_size(window) + mut count := i32(0) + monitors := glfw.get_monitors(&count) + mut selected := glfw.get_primary_monitor() + mut largest := i64(-1) + for i in 0 .. count { + mut monitor := unsafe { monitors[i] } + video := glfw.get_video_mode(monitor) + if isnil(video) { + continue + } + mut mx := i32(0) + mut my := i32(0) + glfw.get_monitor_pos(monitor, &mx, &my) + overlap_width := i32(math.max(0, math.min(x + size.width, mx + video.width) - math.max(x, mx))) + overlap_height := i32(math.max(0, math.min(y + size.height, my + video.height) - math.max(y, my))) + overlap := i64(overlap_width) * overlap_height + if overlap > largest { + largest = overlap + selected = monitor + } + } + if isnil(selected) { + return + } + video := glfw.get_video_mode(selected) + if isnil(video) { + return + } + mode.decorated = glfw.get_window_attrib(window, glfw.decorated) + mode.maximized = glfw.get_window_attrib(window, glfw.maximized) != 0 + if mode.maximized { glfw.restore_window(window) } + glfw.set_window_attrib(window, glfw.decorated, 0) + glfw.set_window_monitor(window, selected, 0, 0, video.width, video.height, video.refreshRate) + mode.fullscreen = true +} diff --git a/examples/implot_dashboard/README.md b/examples/implot_dashboard/README.md index 9c67452..6ed3d7d 100644 --- a/examples/implot_dashboard/README.md +++ b/examples/implot_dashboard/README.md @@ -3,7 +3,7 @@ From the repository root: ```sh -v run setup.vsh --example implot_dashboard +./setup.vsh --example implot_dashboard ``` On Linux/macOS, launch `build/implot_dashboard`. Windows setup reports its executable. diff --git a/examples/widget_gallery/README.md b/examples/widget_gallery/README.md index 574738a..1062ede 100644 --- a/examples/widget_gallery/README.md +++ b/examples/widget_gallery/README.md @@ -3,7 +3,7 @@ From the repository root: ```sh -v run setup.vsh --example widget_gallery +./setup.vsh --example widget_gallery ``` On Linux/macOS, launch `build/widget_gallery`. Windows setup reports its executable. @@ -19,3 +19,16 @@ resize table columns; toggle the upstream demo. Docking is optional and the same source runs against the standard ImGui variant. ![widget gallery](screenshot.png) + +## Accessible release view + +The portable release builds this same example with `-d release_accessibility +-d appui_embedded` and a native library configured with `VIMGUI_APPLICATION_UI`. +It starts with labelled native accessibility controls, high contrast, 200% text, +status/progress, and a virtual file list. Focus last file reveals the list and +moves keyboard/native focus to its last row. Switch to Raw ImGui widgets for +the original tables, popups and secondary windows. See +[release installation](../../docs/installing-releases.md) for supported downloads. +Use `./scripts/build_release.v docking` to build the accessible +package from prepared dependencies (Windows uses a Visual Studio developer shell). +The normal setup command remains the direct raw API build. diff --git a/examples/widget_gallery/accessibility_bridge.h b/examples/widget_gallery/accessibility_bridge.h new file mode 100644 index 0000000..c20a6e7 --- /dev/null +++ b/examples/widget_gallery/accessibility_bridge.h @@ -0,0 +1,32 @@ +#ifndef V_IMGUI_GALLERY_ACCESSIBILITY_H +#define V_IMGUI_GALLERY_ACCESSIBILITY_H +#include "vimgui_app.h" +#ifndef GLFW_INCLUDE_NONE +#define GLFW_INCLUDE_NONE +#endif +#include +#ifdef _WIN32 +#define GLFW_EXPOSE_NATIVE_WIN32 +#include +#endif +static bool v_imgui_gallery_attach(void *handle) { + GLFWwindow *window = (GLFWwindow *)handle; + void *native = NULL; +#ifdef _WIN32 + native = glfwGetWin32Window(window); +#endif + return vimgui_accessibility_attach(vimgui_app_accessibility(), native, NULL); +} +static void v_imgui_gallery_update(void *handle) { + GLFWwindow *window = (GLFWwindow *)handle; + int x, y, width, height; + glfwGetWindowPos(window, &x, &y); + glfwGetWindowSize(window, &width, &height); + vimgui_accessibility_window_state(vimgui_app_accessibility(), + glfwGetWindowAttrib(window, GLFW_FOCUSED) != 0, x, y, width, height); + vimgui_accessibility_update(vimgui_app_accessibility()); +} +static void v_imgui_gallery_detach(void) { + vimgui_accessibility_detach(vimgui_app_accessibility()); +} +#endif diff --git a/examples/widget_gallery/accessibility_d_release_accessibility.v b/examples/widget_gallery/accessibility_d_release_accessibility.v new file mode 100644 index 0000000..f306fcb --- /dev/null +++ b/examples/widget_gallery/accessibility_d_release_accessibility.v @@ -0,0 +1,111 @@ +module main + +import examples.glfw_vulkan +import math +import antono2.imgui +import antono2.imgui.appui + +#flag -I @VMODROOT/examples/widget_gallery + +#include "accessibility_bridge.h" + +fn C.v_imgui_gallery_attach(voidptr) bool + +fn C.v_imgui_gallery_update(voidptr) + +fn C.v_imgui_gallery_detach() + +fn C.vimgui_app_input(u64, &char, &char, usize) bool + +fn draw_accessible(mut app glfw_vulkan.App, mut state Gallery) { + if !state.accessible_initialized { + appui.initialize('Widget gallery') or { panic(err) } + if !C.v_imgui_gallery_attach(app.native_window) { + panic('Could not attach native accessibility adapter') + } + appui.list_reset(40) + for i in 0 .. 1000 { + appui.list_add(40, u64(1000 + i), 'Photo ${i:04}.jpg') or { panic(err) } + } + appui.theme(true, false, 1, false) + state.accessible_initialized = true + } + appui.begin_frame() + if state.raw_widgets && appui.back_requested() { state.raw_widgets = false } + appui.text(2, 'Widget gallery') + appui.text(3, 'Use Tab and arrow keys, or your screen reader. Controls have stable names and native actions.') + if appui.radio(4, 'Accessible controls', !state.raw_widgets) { + state.raw_widgets = false + } + appui.same_line_for('Raw ImGui widgets', true) + if appui.radio(5, 'Raw ImGui widgets', state.raw_widgets) { + state.raw_widgets = true + } + mut theme_changed := appui.checkbox(6, 'High contrast', &state.high_contrast) + appui.same_line_for('200% text', true) + theme_changed = appui.checkbox(7, '200% text', &state.large_text) || theme_changed + if state.raw_widgets { + appui.text(8, 'The raw widget view demonstrates the ImGui API. These controls do not publish screen-reader semantics; use Accessible controls for native accessibility.') + draw_widgets(mut app, mut state) + } else { + appui.begin_columns(50, 240 * imgui.get_style().FontScaleMain, 0) + appui.set_width(-1) + C.vimgui_app_input(10, c'Name', &state.name[0], usize(state.name.len)) + appui.next_column(true) + appui.checkbox(11, 'Enabled', &state.enabled) + appui.end_columns() + appui.text(12, 'Priority') + for index, label in ['Low', 'Medium', 'High'] { + if index > 0 { appui.same_line_for(label, true) } + if appui.radio(u64(13 + index), label, state.choice == index) { + state.choice = index + } + } + if appui.button(16, 'Decrease amount') { + state.amount = math.max(f32(0), state.amount - f32(0.1)) + } + appui.same_line_for('Increase amount', false) + if appui.button(17, 'Increase amount') { + state.amount = math.min(f32(1), state.amount + f32(0.1)) + } + appui.progress(18, 'Amount', state.amount, '${int(state.amount * 100)}%') + appui.progress(19, 'Unknown progress', -1, 'Waiting for work') + if appui.button(20, 'Count') { state.count++ } + appui.status(21, 'Count: ${state.count}') + if appui.button(22, 'Copy name') { + imgui.set_clipboard_text(&state.name[0]) + state.clipboard_status = if unsafe { state.name[0] == 0 } { + 'Copied an empty name.' + } else { + 'Copied the name to the clipboard.' + } + } + appui.status(23, state.clipboard_status) + appui.separator() + appui.text(30, 'File list: keyboard arrows and screen-reader actions select a row; focus can reach off-screen rows.') + if appui.button(31, 'Focus last file') { + appui.reveal(40) + appui.focus(1999) + } + activated := appui.list(40, 'Files', state.selected_file, 240) + if activated != 0 { + state.selected_file = activated + } + appui.status(41, if state.selected_file == 0 { + 'No file selected.' + } else { + 'Selected Photo ${state.selected_file - 1000:04}.jpg' + }) + } + appui.end_frame() or { panic(err) } + if theme_changed { + appui.theme(true, state.high_contrast, if state.large_text { f32(2) } else { f32(1) }, false) + } + if state.raw_widgets { draw_auxiliary(mut state) } + C.v_imgui_gallery_update(app.native_window) +} + +fn close_accessibility() { + C.v_imgui_gallery_detach() + appui.shutdown() +} diff --git a/examples/widget_gallery/main.v b/examples/widget_gallery/main.v index d9c8c0e..02ccb70 100644 --- a/examples/widget_gallery/main.v +++ b/examples/widget_gallery/main.v @@ -5,72 +5,42 @@ import antono2.imgui struct Gallery { mut: - name [128]char - enabled bool = true - amount f32 = 0.5 - choice int - count int - show_demo bool - show_details bool + name [128]char + enabled bool = true + amount f32 = 0.5 + choice int + count int + show_demo bool + show_details bool + accessible_initialized bool + raw_widgets bool + high_contrast bool + large_text bool + selected_file u64 + clipboard_status string } fn main() { mut gallery := Gallery{} - glfw_vulkan.run(glfw_vulkan.Options{ title: 'V ImGui: widget gallery' }, draw, &gallery) + glfw_vulkan.run(glfw_vulkan.Options{ title: 'V ImGui: widget gallery', shutdown: close_gallery }, draw, &gallery) } fn draw(mut app glfw_vulkan.App, userdata voidptr) { mut state := unsafe { &Gallery(userdata) } + $if release_accessibility ? { + draw_accessible(mut app, mut state) + return + } imgui.set_next_window_size(imgui.ImVec2{ x: 640, y: 620 }, imgui.Cond(imgui.Cond_.first_use_ever)) if imgui.begin(c'Widget gallery', unsafe { nil }, 0) { - imgui.text_unformatted(c'Each widget edits persistent V state.', unsafe { nil }) - imgui.input_text(c'Name', &state.name[0], usize(state.name.len), 0, unsafe { nil }, unsafe { nil }) - imgui.checkbox(c'Enabled', &state.enabled) - imgui.slider_float(c'Amount', &state.amount, 0, 1, c'%.2f', 0) - imgui.progress_bar(state.amount, imgui.ImVec2{ x: -1 }, c'Slider value') - choices := [c'Low', c'Medium', c'High'] - if imgui.begin_combo(c'Priority', choices[state.choice], 0) { - for index, label in choices { - if imgui.selectable_bool(label, index == state.choice, 0, imgui.ImVec2{}) { - state.choice = index - } - } - imgui.end_combo() - } - if imgui.button(c'Count', imgui.ImVec2{}) { state.count++ } - imgui.same_line(0, -1) - count := 'Count: ${state.count}' - imgui.text_unformatted(count.str, unsafe { nil }) - if imgui.button(c'Open popup', imgui.ImVec2{}) { imgui.open_popup_str(c'Example popup', 0) } - if imgui.begin_popup(c'Example popup', 0) { - imgui.text_unformatted(c'Popups open from an event and keep their own visibility.', unsafe { nil }) - if imgui.button(c'Close popup', imgui.ImVec2{}) { imgui.close_current_popup() } - imgui.end_popup() - } - imgui.separator() - flags := imgui.TableFlags(int(imgui.TableFlags_.borders) | int(imgui.TableFlags_.row_bg) | int(imgui.TableFlags_.resizable)) - if imgui.begin_table(c'Widgets', 2, flags, imgui.ImVec2{}, 0) { - imgui.table_setup_column(c'Widget', 0, 0, 0) - imgui.table_setup_column(c'Use', 0, 0, 0) - imgui.table_headers_row() - labels := [c'InputText', c'Checkbox', c'SliderFloat', c'BeginCombo'] - uses := [c'Edit a fixed buffer', c'Toggle a bool', c'Edit a bounded value', - c'Select one item'] - for i, label in labels { - imgui.table_next_row(0, 0) - imgui.table_next_column() - imgui.text_unformatted(label, unsafe { nil }) - imgui.table_next_column() - imgui.text_unformatted(uses[i], unsafe { nil }) - } - imgui.end_table() - } - imgui.checkbox(c'Details window', &state.show_details) - imgui.checkbox(c'Upstream demo', &state.show_demo) - if app.docking_available { imgui.checkbox(c'Main dockspace', &app.dockspace_enabled) } + draw_widgets(mut app, mut state) } // End is required even when Begin reports a collapsed window. imgui.end() + draw_auxiliary(mut state) +} + +fn draw_auxiliary(mut state Gallery) { if state.show_details { if imgui.begin(c'Details', &state.show_details, 0) { imgui.text_unformatted(c'Your name:', unsafe { nil }) @@ -81,3 +51,57 @@ fn draw(mut app glfw_vulkan.App, userdata voidptr) { } if state.show_demo { imgui.show_demo_window(&state.show_demo) } } + +fn draw_widgets(mut app glfw_vulkan.App, mut state Gallery) { + imgui.text_unformatted(c'Each widget edits persistent V state.', unsafe { nil }) + imgui.input_text(c'Name', &state.name[0], usize(state.name.len), 0, unsafe { nil }, unsafe { nil }) + imgui.checkbox(c'Enabled', &state.enabled) + imgui.slider_float(c'Amount', &state.amount, 0, 1, c'%.2f', 0) + imgui.progress_bar(state.amount, imgui.ImVec2{ x: -1 }, c'Slider value') + choices := [c'Low', c'Medium', c'High'] + if imgui.begin_combo(c'Priority', choices[state.choice], 0) { + for index, label in choices { + if imgui.selectable_bool(label, index == state.choice, 0, imgui.ImVec2{}) { + state.choice = index + } + } + imgui.end_combo() + } + if imgui.button(c'Count', imgui.ImVec2{}) { state.count++ } + imgui.same_line(0, -1) + count := 'Count: ${state.count}' + imgui.text_unformatted(count.str, unsafe { nil }) + if imgui.button(c'Open popup', imgui.ImVec2{}) { imgui.open_popup_str(c'Example popup', 0) } + if imgui.begin_popup(c'Example popup', 0) { + imgui.text_unformatted(c'Popups open from an event and keep their own visibility.', unsafe { nil }) + if imgui.button(c'Close popup', imgui.ImVec2{}) { imgui.close_current_popup() } + imgui.end_popup() + } + imgui.separator() + flags := imgui.TableFlags(int(imgui.TableFlags_.borders) | int(imgui.TableFlags_.row_bg) | int(imgui.TableFlags_.resizable)) + if imgui.begin_table(c'Widgets', 2, flags, imgui.ImVec2{}, 0) { + imgui.table_setup_column(c'Widget', 0, 0, 0) + imgui.table_setup_column(c'Use', 0, 0, 0) + imgui.table_headers_row() + labels := [c'InputText', c'Checkbox', c'SliderFloat', c'BeginCombo'] + uses := [c'Edit a fixed buffer', c'Toggle a bool', c'Edit a bounded value', + c'Select one item'] + for i, label in labels { + imgui.table_next_row(0, 0) + imgui.table_next_column() + imgui.text_unformatted(label, unsafe { nil }) + imgui.table_next_column() + imgui.text_unformatted(uses[i], unsafe { nil }) + } + imgui.end_table() + } + imgui.checkbox(c'Details window', &state.show_details) + imgui.checkbox(c'Upstream demo', &state.show_demo) + if app.docking_available { imgui.checkbox(c'Main dockspace', &app.dockspace_enabled) } +} + +fn close_gallery(_ voidptr) { + $if release_accessibility ? { + close_accessibility() + } +} diff --git a/packaging/MICROSOFT-RUNTIME-NOTICE.txt b/packaging/MICROSOFT-RUNTIME-NOTICE.txt new file mode 100644 index 0000000..6de72b1 --- /dev/null +++ b/packaging/MICROSOFT-RUNTIME-NOTICE.txt @@ -0,0 +1,7 @@ +This package includes the Microsoft Visual C++ x64 Redistributable runtime +DLLs deployed beside the applications from Visual Studio's Redist directory. +Copyright Microsoft Corporation. All rights reserved. +Redistribution is governed by the Microsoft Visual Studio license and its +redistributable code list: +https://visualstudio.microsoft.com/license-terms/ +https://learn.microsoft.com/en-us/visualstudio/releases/2022/redistribution diff --git a/packaging/PROGGY-LICENSE.txt b/packaging/PROGGY-LICENSE.txt new file mode 100644 index 0000000..1dfb54d --- /dev/null +++ b/packaging/PROGGY-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2004, 2005 Tristan Grimmer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packaging/PROGGYFOREVER-LICENSE.txt b/packaging/PROGGYFOREVER-LICENSE.txt new file mode 100644 index 0000000..01ee2b5 --- /dev/null +++ b/packaging/PROGGYFOREVER-LICENSE.txt @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2026 Disco Hello +Copyright (c) 2019,2023 Tristan Grimmer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packaging/README-linux.txt b/packaging/README-linux.txt new file mode 100644 index 0000000..139e0db --- /dev/null +++ b/packaging/README-linux.txt @@ -0,0 +1,26 @@ +V ImGui examples — Ubuntu 24.04 or newer, x86_64 + +Extract the whole archive. From its directory, run: + ./run.sh + +Choose an example from the menu. Press Enter for Widget gallery. +To launch directly: + ./run.sh widget_gallery + ./run.sh implot_dashboard + ./run.sh glfw_vulkan + +In an example, Escape quits after active controls consume it to cancel an edit +or popup. F11 toggles borderless fullscreen and restores the previous window. + +The examples, ImGui/ImPlot library, GLFW, Vulkan loader, C++ runtime, and +non-system runtime dependencies are included. No V compiler, Vulkan SDK, +or additional application libraries need to be installed. + +A graphical X11/XWayland desktop and a Vulkan-capable graphics driver are +required. The graphics driver and base OS (including glibc) remain supplied +by your operating system. This is not a package for a headless server. + +Keep the lib/ directory beside the executables. Paths inside the archive +are relative, so the extracted directory can be moved. VARIANT.txt names +the ImGui variant; RUNTIME-LIBRARIES.txt lists the bundled libraries. +Third-party license notices are in licenses/. diff --git a/packaging/README-windows.txt b/packaging/README-windows.txt new file mode 100644 index 0000000..eba0f50 --- /dev/null +++ b/packaging/README-windows.txt @@ -0,0 +1,22 @@ +V ImGui examples — Windows 10/11, x64 + +Extract the whole archive and open: + examples.exe + +Choose an example from the menu. Press Enter for Widget gallery. +You can also open an example directly: + widget_gallery.exe + implot_dashboard.exe + glfw_vulkan.exe + +In an example, Escape quits after active controls consume it to cancel an edit +or popup. F11 toggles borderless fullscreen and restores the previous window. + +ImGui/ImPlot, GLFW, the Vulkan loader, and the Microsoft Visual C++ runtime +DLLs are included beside the applications. No compiler, SDK, or separate +Visual C++ runtime installation is needed. Keep the whole folder together. + +A Vulkan-capable graphics driver and a graphical Windows desktop are +required. Windows system DLLs and the device's graphics driver are supplied +by the operating system. VARIANT.txt identifies the ImGui variant. +Third-party license notices are in licenses/. diff --git a/packaging/ROBOTO-LICENSE.txt b/packaging/ROBOTO-LICENSE.txt new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/packaging/ROBOTO-LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packaging/VULKAN-LOADER-LICENSE.txt b/packaging/VULKAN-LOADER-LICENSE.txt new file mode 100644 index 0000000..6599e31 --- /dev/null +++ b/packaging/VULKAN-LOADER-LICENSE.txt @@ -0,0 +1,207 @@ +The majority of files in this project use the Apache 2.0 License. +There are a few exceptions and their license can be found in the source. +Any license deviations from Apache 2.0 are "more permissive" licenses. + +=========================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packaging/examples.v b/packaging/examples.v new file mode 100644 index 0000000..15a0de1 --- /dev/null +++ b/packaging/examples.v @@ -0,0 +1,50 @@ +module main + +import os + +fn main() { + choices := ['widget_gallery', 'implot_dashboard', 'glfw_vulkan'] + mut example := if os.args.len > 1 { os.args[1] } else { '' } + if example in ['--help', '-h'] { + println('Usage: examples [widget_gallery|implot_dashboard|glfw_vulkan] [example arguments]') + return + } + if example == '' { + println('V ImGui examples\n\n1. Widget gallery — controls, accessibility and virtual lists (default)\n2. ImPlot dashboard — interactive plots and rolling history\n3. GLFW/Vulkan — windows, docking and backend demonstration\nQ. Quit\n') + answer := os.input_opt('Choose an example [1]: ') or { return } + match answer.trim_space().to_lower() { + '', '1' { example = choices[0] } + '2' { example = choices[1] } + '3' { example = choices[2] } + 'q' { return } + else { + eprintln('Choose 1, 2, 3 or Q.') + exit(2) + } + } + } + if example !in choices { + eprintln('Unknown example: ${example}. Use --help for choices.') + exit(2) + } + mut filename := example + $if windows { + filename += '.exe' + } + path := os.join_path(os.dir(os.executable()), filename) + if !os.is_file(path) { + eprintln('Could not find ${filename}. Extract the entire release archive and keep its files together.') + exit(1) + } + mut child := os.new_process(path) + child.set_args(if os.args.len > 2 { os.args[2..] } else { []string{} }) + child.run() + child.wait() + code := child.code + child.close() + if code != 0 { + eprintln('${example} could not run (exit ${code}). Check that the whole archive is extracted and your graphics driver supports Vulkan.') + if os.is_atty(0) != 0 { os.input('Press Enter to close. ') } + } + exit(code) +} diff --git a/packaging/run.sh b/packaging/run.sh new file mode 100755 index 0000000..6c663ca --- /dev/null +++ b/packaging/run.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +package_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +if [[ $# -eq 0 ]]; then exec "$package_dir/examples"; fi +example=$1 +case "$example" in glfw_vulkan|widget_gallery|implot_dashboard) ;; *) echo 'Usage: run.sh [glfw_vulkan|widget_gallery|implot_dashboard]' >&2; exit 2 ;; esac +if [[ $# -gt 0 ]]; then shift; fi +exec "$package_dir/$example" "$@" diff --git a/scripts/build_android.sh b/scripts/build_android.sh index dd5d87e..27de250 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -19,7 +19,7 @@ if [[ "$(git -C "$imgui_dir" rev-parse HEAD)" != "$revision" ]]; then exit 2 fi git -C "$imgui_dir" submodule update --init --recursive -export VIMGUI_ANDROID_UI_SOURCE="$repo_dir/examples/android_touch/ui.v" +export VIMGUI_ANDROID_UI_SOURCE="$repo_dir/examples/android_touch" export VIMGUI_ANDROID_MANIFEST="$repo_dir/examples/android_touch/AndroidManifest.xml" export VIMGUI_ANDROID_BUILD_DIR="${VIMGUI_ANDROID_BUILD_DIR:-$repo_dir/build/android-touch-${ANDROID_ABI:-device}}" exec bash "$imgui_dir/scripts/run_android_demo.sh" "$mode" diff --git a/scripts/build_release.v b/scripts/build_release.v new file mode 100755 index 0000000..b2b316a --- /dev/null +++ b/scripts/build_release.v @@ -0,0 +1,111 @@ +#!/usr/bin/env -S v -prod run + +module main + +import os +import release_tools + +fn find_file(root string, names []string, containing string) !string { + mut files := os.walk_ext(root, '') + files.sort() + for file in files { + if os.file_name(file) in names && file.contains(containing) { + return file + } + } + return error('Missing build output ${names} in ${root}') +} + +fn main() { + run() or { + eprintln(err) + exit(1) + } +} + +fn run() ! { + if os.args.len != 2 || os.args[1] !in ['docking', 'standard'] { + return error('Usage: ./scripts/build_release.v docking|standard') + } + variant := os.args[1] + root := os.dir(os.dir(@FILE)) + imgui := if os.getenv('IMGUI_DIR') != '' { + os.getenv('IMGUI_DIR') + } else { + os.join_path(root, 'build', 'modules', 'antono2', 'imgui') + } + if os.getenv('VMODULES') == '' { + os.setenv('VMODULES', os.join_path(root, 'build', 'modules'), true) + } + mut compiler := os.getenv('V_BIN') + if compiler == '' { + $if windows { + compiler = os.join_path(@VEXEROOT, 'v.exe') + } $else { + compiler = os.join_path(@VEXEROOT, 'v') + } + } + mut native_options := ['run', os.join_path(imgui, 'build_vimgui.vsh'), '--linkage', 'shared'] + mut native_directory := '' + $if windows { + native_options << ['--glfw', 'bundled', '--glfw-version', '3.4'] + native_directory = os.join_path(imgui, 'build', 'shared-bundled-3.4') + if os.getenv('VCToolsRedistDir') == '' { + return error('Load scripts/setup_windows_build.ps1 before running the builder') + } + } $else $if linux { + native_options << ['--glfw', 'system'] + native_directory = os.join_path(imgui, 'build', 'shared-system-3.3') + os.setenv('VULKAN_SDK', '/usr', true) + os.setenv('GLFW_INCLUDE', '/usr/include', true) + os.setenv('GLFW_LIB', '/usr/lib/x86_64-linux-gnu', true) + } $else { + return error('Release builder currently supports Linux and Windows') + } + release_tools.command(compiler, native_options)! + release_tools.command('cmake', ['-S', imgui, '-B', native_directory, '-DVIMGUI_APPLICATION_UI=ON', + '-DVIMGUI_OUTPUT_DIR=' + os.join_path(imgui, 'lib')])! + release_tools.command('cmake', ['--build', native_directory, '--config', 'Release', '--parallel', + '4'])! + binaries := os.join_path(root, 'build', 'release-binaries-' + variant) + os.mkdir_all(binaries)! + mut flags := ['-d', 'release_accessibility', '-d', 'appui_embedded', '-no-memory-limit'] + mut extension := '' + mut platform := '' + $if windows { + header := find_file(native_directory, ['glfw3.h'], 'glfw-src')! + library := find_file(native_directory, ['glfw3dll.lib', 'glfw3.lib'], '')! + links := os.join_path(os.getenv('RUNNER_TEMP'), 'link') + os.mkdir_all(links)! + release_tools.copy(library, os.join_path(links, 'glfw3.lib'))! + os.setenv('GLFW_INCLUDE', os.dir(os.dir(header)), true) + os.setenv('GLFW_LIB', links, true) + flags << ['-path', os.join_path(root, 'build', 'modules', 'antono2') + '|@vlib|@vmodules', + '-cc', 'msvc'] + release_tools.copy(find_file(native_directory, ['glfw3.dll'], '')!, os.join_path(binaries, 'glfw3.dll'))! + release_tools.copy(find_file(os.join_path(imgui, 'lib'), ['vimgui.dll'], '')!, os.join_path(binaries, 'vimgui.dll'))! + extension = '.exe' + platform = 'windows' + } $else { + flags << ['-cc', 'gcc'] + platform = 'linux' + } + for example in ['glfw_vulkan', 'widget_gallery', 'implot_dashboard'] { + source := if example == 'glfw_vulkan' { + root + } else { + os.join_path(root, 'examples', example) + } + mut args := flags.clone() + args << ['-o', os.join_path(binaries, example + extension), source] + release_tools.command(compiler, args)! + } + output := os.join_path(root, 'build', 'v-imgui-examples-${platform}-x64-${variant}') + release_tools.package_release(root, binaries, imgui, output, variant)! + $if linux { + release_tools.command('bash', [ + os.join_path(root, 'scripts', 'smoke_desktop.sh'), + output, + ])! + } +} diff --git a/scripts/collect_licenses.v b/scripts/collect_licenses.v new file mode 100755 index 0000000..d9b429d --- /dev/null +++ b/scripts/collect_licenses.v @@ -0,0 +1,17 @@ +#!/usr/bin/env -S v run + +module main + +import os +import release_tools + +fn main() { + if os.args.len !in [3, 4] || (os.args.len == 4 && os.args[3] != 'android') { + eprintln('Usage: ./scripts/collect_licenses.v imgui-dir output-dir [android]') + exit(2) + } + release_tools.collect_licenses(os.dir(os.dir(@FILE)), os.args[1], os.args[2], os.args.len == 4) or { + eprintln(err) + exit(1) + } +} diff --git a/scripts/package_release.v b/scripts/package_release.v new file mode 100755 index 0000000..3dc51b7 --- /dev/null +++ b/scripts/package_release.v @@ -0,0 +1,17 @@ +#!/usr/bin/env -S v -prod run + +module main + +import os +import release_tools + +fn main() { + if os.args.len != 5 { + eprintln('Usage: ./scripts/package_release.v binary-dir imgui-dir output-dir docking|standard') + exit(2) + } + release_tools.package_release(os.dir(os.dir(@FILE)), os.args[1], os.args[2], os.args[3], os.args[4]) or { + eprintln(err) + exit(1) + } +} diff --git a/scripts/release_tools/common.v b/scripts/release_tools/common.v new file mode 100644 index 0000000..ead4a00 --- /dev/null +++ b/scripts/release_tools/common.v @@ -0,0 +1,58 @@ +module release_tools + +import os + +fn executable(program string) !string { + if os.is_file(program) { + return os.real_path(program) + } + $if windows { + if os.is_abs_path(program) || program.contains('/') || program.contains('\\') { + for suffix in ['.exe', '.bat', '.cmd'] { + if os.is_file(program + suffix) { + return os.real_path(program + suffix) + } + } + return error('Executable not found: ${program}') + } + } + return os.find_abs_path_of_executable(program) +} + +// Argument vectors keep paths and literal RPATH values out of a shell parser. +pub fn command(program string, args []string) ! { + mut process := os.new_process(executable(program)!) + process.set_args(args) + process.run() + process.wait() + code := process.code + process.close() + if code != 0 { + return error('${program} failed (${code})') + } +} + +pub fn capture(program string, args []string) !(int, string) { + mut process := os.new_process(executable(program)!) + process.set_args(args) + process.set_redirect_stdio_merged() + process.run() + output := process.stdout_slurp() + process.wait() + code := process.code + process.close() + return code, output +} + +pub fn checked_output(program string, args []string) !string { + code, output := capture(program, args)! + if code != 0 { + return error('${program} failed (${code}): ${output}') + } + return output +} + +pub fn copy(source string, destination string) ! { + os.mkdir_all(os.dir(destination))! + os.cp(os.real_path(source), destination)! +} diff --git a/scripts/release_tools/licenses.v b/scripts/release_tools/licenses.v new file mode 100644 index 0000000..172c9a1 --- /dev/null +++ b/scripts/release_tools/licenses.v @@ -0,0 +1,54 @@ +module release_tools + +import os + +// Keep the source's permission notices, including embedded component notices. +fn source_comments(source string) !string { + mut result := []string{} + mut offset := 0 + for offset < source.len { + begin := source[offset..].index('/*') or { break } + start := offset + begin + end := source[start + 2..].index('*/') or { return error('Unclosed source comment') } + offset = start + 2 + end + 2 + result << source[start..offset] + } + return result.join('\n\n') + '\n' +} + +pub fn collect_licenses(root string, imgui string, output string, android bool) ! { + files := { + 'examples.txt': os.join_path(root, 'LICENSE') + 'imgui-v.txt': os.join_path(imgui, 'LICENSE') + 'imgui.txt': os.join_path(imgui, 'cimgui', 'imgui', 'LICENSE.txt') + 'cimgui.txt': os.join_path(imgui, 'cimgui', 'LICENSE') + 'implot.txt': os.join_path(imgui, 'cimplot', 'implot', 'LICENSE') + 'cimplot.txt': os.join_path(imgui, 'cimplot', 'LICENSE') + 'proggy.txt': os.join_path(root, 'packaging', 'PROGGY-LICENSE.txt') + 'proggyforever.txt': os.join_path(root, 'packaging', 'PROGGYFOREVER-LICENSE.txt') + 'v.txt': os.join_path(@VEXEROOT, 'LICENSE') + } + os.mkdir_all(output)! + for name, source in files { + copy(source, os.join_path(output, name))! + } + if android { + freetype := os.join_path(imgui, 'third_party', 'freetype') + for name, source in { + 'roboto.txt': os.join_path(root, 'packaging', 'ROBOTO-LICENSE.txt') + 'freetype-license.txt': os.join_path(freetype, 'LICENSE.TXT') + 'freetype-FTL.txt': os.join_path(freetype, 'docs', 'FTL.TXT') + 'freetype-dlg.txt': os.join_path(freetype, 'subprojects', 'dlg', 'LICENSE') + } { + copy(source, os.join_path(output, name))! + } + zlib := os.read_file(os.join_path(freetype, 'src', 'gzip', 'zlib.h'))! + end := zlib.index('*/') or { return error('Missing FreeType zlib notice') } + os.write_file(os.join_path(output, 'freetype-zlib.txt'), zlib[..end + 2] + '\n')! + os.write_file(os.join_path(output, 'freetype-attribution.txt'), 'This app includes FreeType 2.13.3. Portions of this software are copyright\n© 2024 The FreeType Project (www.freetype.org). All rights reserved.\nFreeType is redistributed under the FreeType License (FTL).\n')! + } else { + gc_source := os.read_file(os.join_path(@VEXEROOT, 'thirdparty', 'libgc', 'gc.c'))! + os.write_file(os.join_path(output, 'boehm-gc-notices.txt'), source_comments(gc_source)!)! + copy(os.join_path(@VEXEROOT, 'thirdparty', 'libatomic_ops', 'LICENSE'), os.join_path(output, 'libatomic-ops.txt'))! + } +} diff --git a/scripts/release_tools/package.v b/scripts/release_tools/package.v new file mode 100644 index 0000000..1d1010b --- /dev/null +++ b/scripts/release_tools/package.v @@ -0,0 +1,257 @@ +module release_tools + +import os +import crypto.sha256 + +const examples = ['glfw_vulkan', 'widget_gallery', 'implot_dashboard'] + +struct Library { + name string + path string +} + +fn os_library(name string) bool { + for base in ['libc.so.', 'libm.so.', 'libpthread.so.', 'libdl.so.', 'librt.so.', 'libresolv.so.', + 'libutil.so.', 'libmvec.so.', 'ld-linux', 'linux-vdso'] { + if name.starts_with(base) { + return true + } + } + return false +} + +fn linux_dependencies(file string) ![]Library { + output := checked_output('ldd', [file])! + if output.contains('not found') { + return error('Unresolved dependency: ${file}\n${output}') + } + mut result := []Library{} + for line in output.split_into_lines() { + if !line.contains('=>') { + continue + } + parts := line.split('=>') + name := parts[0].trim_space() + destination := parts[1].all_before_last(' (').trim_space() + if destination.starts_with('/') && !os_library(name) { + result << Library{ name: name, path: destination } + } + } + return result +} + +fn debian_notice(path string, output string) ! { + for candidate in [path, os.real_path(path), path.trim_string_left('/usr')] { + code, owners := capture('dpkg-query', ['-S', candidate])! + if code != 0 { + continue + } + for line in owners.split_into_lines() { + owner := line.all_before(': ').all_before(':') + notice := os.join_path('/usr/share/doc', owner, 'copyright') + if os.is_file(notice) { copy(notice, os.join_path(output, owner + '.txt'))! } + } + } +} + +fn package_linux(root string, binaries string, imgui string, output string) ![]string { + prior := os.getenv('LD_LIBRARY_PATH') + os.setenv('LD_LIBRARY_PATH', '', true) + defer { os.setenv('LD_LIBRARY_PATH', prior, true) } + library_dir := os.join_path(output, 'lib') + os.mkdir_all(library_dir)! + mut queue := []Library{} + mut programs := examples.clone() + programs << 'examples' + for example in programs { + source := os.join_path(binaries, example) + copy(source, os.join_path(output, example))! + os.chmod(os.join_path(output, example), 0o755)! + queue << linux_dependencies(source)! + } + // GLFW loads extension libraries and Vulkan presentation with dlopen. + for name in ['libvulkan.so.1', 'libXrandr.so.2', 'libXinerama.so.1', 'libXcursor.so.1', 'libXi.so.6', + 'libXxf86vm.so.1'] { + queue << Library{ name: name, path: os.join_path('/usr/lib/x86_64-linux-gnu', name) } + } + mut digests := map[string]string{} + mut index := 0 + for index < queue.len { + library := queue[index] + index++ + digest := sha256.sum(os.read_bytes(library.path)!).hex() + if library.name in digests { + if digests[library.name] != digest { + return error('Conflicting library contents: ${library.name}') + } + continue + } + digests[library.name] = digest + copy(library.path, os.join_path(library_dir, library.name))! + queue << linux_dependencies(library.path)! + debian_notice(library.path, os.join_path(output, 'licenses'))! + } + collect_licenses(root, imgui, os.join_path(output, 'licenses'), false)! + copy(os.join_path(root, 'packaging', 'run.sh'), os.join_path(output, 'run.sh'))! + os.chmod(os.join_path(output, 'run.sh'), 0o755)! + copy(os.join_path(root, 'packaging', 'README-linux.txt'), os.join_path(output, 'README.txt'))! + mut audit_files := []string{} + for example in programs { + file := os.join_path(output, example) + command('patchelf', ['--set-rpath', r'$ORIGIN/lib', file])! + audit_files << file + } + for name in digests.keys() { + file := os.join_path(library_dir, name) + command('patchelf', ['--set-rpath', r'$ORIGIN', file])! + audit_files << file + } + for file in audit_files { + for dependency in linux_dependencies(file)! { + if !dependency.path.starts_with(library_dir + os.path_separator) { + return error('Unbundled library: ${dependency.path}') + } + } + } + return digests.keys() +} + +fn windows_os_library(name string) bool { + return name.starts_with('api-ms-') || name.starts_with('ext-ms-') || name in [ + 'kernel32.dll', + 'user32.dll', + 'gdi32.dll', + 'advapi32.dll', + 'shell32.dll', + 'ole32.dll', + 'oleaut32.dll', + 'comdlg32.dll', + 'shlwapi.dll', + 'setupapi.dll', + 'cfgmgr32.dll', + 'ntdll.dll', + 'ucrtbase.dll', + 'ws2_32.dll', + 'bcrypt.dll', + 'crypt32.dll', + 'secur32.dll', + 'rpcrt4.dll', + 'dwmapi.dll', + 'version.dll', + 'userenv.dll', + 'imm32.dll', + 'winmm.dll', + 'msvcrt.dll', + 'powrprof.dll', + 'uxtheme.dll', + 'hid.dll', + 'winspool.drv', + 'uiautomationcore.dll', + 'comctl32.dll', + 'propsys.dll', + 'netapi32.dll', + 'mswsock.dll', + 'normaliz.dll', + 'dbghelp.dll', + 'd3d11.dll', + 'dxgi.dll', + 'opengl32.dll', + ] +} + +fn package_windows(root string, binaries string, imgui string, output string) ![]string { + for example in examples { + copy(os.join_path(binaries, example + '.exe'), os.join_path(output, example + '.exe'))! + } + for name in ['vimgui.dll', 'glfw3.dll'] { + copy(os.join_path(binaries, name), os.join_path(output, name))! + } + redist := os.getenv('VCToolsRedistDir') + if redist == '' { + return error('Run in the Visual Studio developer shell') + } + mut crt := '' + for directory in os.ls(os.join_path(redist, 'x64'))! { + if directory.starts_with('Microsoft.VC') && directory.ends_with('.CRT') { + crt = os.join_path(redist, 'x64', directory) + break + } + } + if crt == '' { + return error('The redistributable x64 C runtime was not found') + } + for file in os.ls(crt)! { + if file.to_lower().ends_with('.dll') { copy(os.join_path(crt, file), os.join_path(output, file))! } + } + copy(os.join_path(os.getenv('VULKAN_SDK'), 'Bin', 'vulkan-1.dll'), os.join_path(output, 'vulkan-1.dll'))! + notices := os.join_path(output, 'licenses') + collect_licenses(root, imgui, notices, false)! + mut glfw_license := '' + for file in os.walk_ext(os.join_path(imgui, 'build', 'shared-bundled-3.4'), 'LICENSE.md') { + if file.contains('glfw-src') { + glfw_license = file + break + } + } + if glfw_license == '' { + return error('GLFW license not found') + } + copy(glfw_license, os.join_path(notices, 'glfw.txt'))! + copy(os.join_path(root, 'packaging', 'README-windows.txt'), os.join_path(output, 'README.txt'))! + for file in ['MICROSOFT-RUNTIME-NOTICE.txt', 'VULKAN-LOADER-LICENSE.txt'] { + copy(os.join_path(root, 'packaging', file), os.join_path(notices, file))! + } + mut imports := map[string]bool{} + files := os.ls(output)! + bundled := files.map(it.to_lower()) + for file in files { + if !file.to_lower().ends_with('.exe') && !file.to_lower().ends_with('.dll') { + continue + } + listing := checked_output('dumpbin', ['/nologo', '/dependents', os.join_path(output, file)])! + for line in listing.split_into_lines() { + name := line.trim_space().to_lower() + if !name.ends_with('.dll') || name.contains(' ') || name.contains('\t') { + continue + } + imports[name] = true + if name !in bundled && !windows_os_library(name) { + return error('Unbundled runtime dependency: ${file} -> ${name}') + } + } + } + return imports.keys() +} + +pub fn package_release(root string, binaries string, imgui string, destination string, variant string) ! { + if variant !in ['docking', 'standard'] { + return error('Variant must be docking or standard') + } + if os.exists(destination) || os.exists(destination + '.zip') { + return error('Output already exists: ${destination}') + } + os.mkdir_all(os.join_path(destination, 'licenses'))! + output := os.real_path(destination) + mut launcher := os.join_path(binaries, 'examples') + $if windows { + launcher += '.exe' + } + command(@VEXE, ['-prod', '-o', launcher, os.join_path(root, 'packaging', 'examples.v')])! + copy(launcher, os.join_path(output, os.file_name(launcher)))! + $if !windows { + os.chmod(os.join_path(output, 'examples'), 0o755)! + } + mut runtime := []string{} + $if windows { + runtime = package_windows(root, binaries, imgui, output)! + } $else $if linux { + runtime = package_linux(root, binaries, imgui, output)! + } $else { + return error('Portable packages currently support Linux and Windows') + } + runtime.sort() + os.write_file(os.join_path(output, 'RUNTIME-LIBRARIES.txt'), runtime.join('\n') + '\n')! + os.write_file(os.join_path(output, 'VARIANT.txt'), variant + '\n')! + zip_folder(output, output + '.zip')! + println(output + '.zip') +} diff --git a/scripts/release_tools/zip.v b/scripts/release_tools/zip.v new file mode 100644 index 0000000..d96a023 --- /dev/null +++ b/scripts/release_tools/zip.v @@ -0,0 +1,145 @@ +module release_tools + +import os +import compress.deflate +import hash.crc32 + +struct ZipEntry { + name string + crc u32 + compressed u32 + original u32 + offset u32 + mode u32 +} + +pub struct ZipWriter { +mut: + file os.File + offset u64 + entries []ZipEntry +} + +fn le16(mut bytes []u8, value u16) { + bytes << u8(value) + bytes << u8(value >> 8) +} + +fn le32(mut bytes []u8, value u32) { + for shift in [0, 8, 16, 24] { + bytes << u8(value >> shift) + } +} + +pub fn new_zip(path string) !ZipWriter { + return ZipWriter{ file: os.create(path)! } +} + +pub fn (mut zip ZipWriter) close() { + zip.file.close() +} + +fn (mut zip ZipWriter) write(bytes []u8) ! { + if zip.offset + u64(bytes.len) >= 0xffffffff { + return error('ZIP32 archive exceeds 4 GiB') + } + written := zip.file.write(bytes)! + if written != bytes.len { + return error('Incomplete ZIP write') + } + zip.offset += u64(written) +} + +// APK tools require ZIP32. szip's writer always emits ZIP64, so share this +// small writer between APK signing and desktop packages. Fixed DOS timestamps +// avoid invalid dates from source archives and make entry metadata reproducible. +pub fn (mut zip ZipWriter) add(name string, bytes []u8, mode u32) ! { + if name == '' || name.starts_with('/') || name.contains('\\') || name.split('/').contains('..') || name.len > 65535 { + return error('Invalid ZIP entry: ${name}') + } + if zip.entries.len >= 65535 || u64(bytes.len) >= 0xffffffff { + return error('ZIP32 limit exceeded') + } + compressed := deflate.compress(bytes, format: .raw_deflate)! + entry := ZipEntry{ name: name, crc: crc32.sum(bytes), compressed: u32(compressed.len), original: u32(bytes.len), offset: u32(zip.offset), mode: mode } + mut header := []u8{} + le32(mut header, 0x04034b50) + le16(mut header, 20) + le16(mut header, 0x0800) + le16(mut header, 8) + le16(mut header, 0) + le16(mut header, 0x0021) + le32(mut header, entry.crc) + le32(mut header, entry.compressed) + le32(mut header, entry.original) + le16(mut header, u16(name.len)) + le16(mut header, 0) + header << name.bytes() + zip.write(header)! + zip.write(compressed)! + zip.entries << entry +} + +pub fn (mut zip ZipWriter) finish() ! { + central_offset := u32(zip.offset) + for entry in zip.entries { + mut header := []u8{} + le32(mut header, 0x02014b50) + le16(mut header, 0x0314) + le16(mut header, 20) + le16(mut header, 0x0800) + le16(mut header, 8) + le16(mut header, 0) + le16(mut header, 0x0021) + le32(mut header, entry.crc) + le32(mut header, entry.compressed) + le32(mut header, entry.original) + le16(mut header, u16(entry.name.len)) + le16(mut header, 0) + le16(mut header, 0) + le16(mut header, 0) + le16(mut header, 0) + le32(mut header, entry.mode << 16) + le32(mut header, entry.offset) + header << entry.name.bytes() + zip.write(header)! + } + central_size := u32(zip.offset) - central_offset + mut end := []u8{} + le32(mut end, 0x06054b50) + le16(mut end, 0) + le16(mut end, 0) + le16(mut end, u16(zip.entries.len)) + le16(mut end, u16(zip.entries.len)) + le32(mut end, central_size) + le32(mut end, central_offset) + le16(mut end, 0) + zip.write(end)! +} + +pub fn zip_folder(folder string, archive string) ! { + mut zip := new_zip(archive)! + defer { zip.close() } + mut files := os.walk_ext(folder, '') + files.sort() + for file in files { + if !os.is_file(file) { + continue + } + relative := file.trim_string_left(folder + os.path_separator).replace('\\', '/') + info := os.stat(file)! + zip.add(os.file_name(folder) + '/' + relative, os.read_bytes(file)!, u32(0o100000) | (info.mode & 0o777))! + } + zip.finish()! +} + +pub fn zip_entries(path string, entries map[string][]u8) ! { + mut zip := new_zip(path)! + defer { zip.close() } + mut names := entries.keys() + names.sort() + for name in names { + zip.add(name, entries[name], 0o100644)! + } + zip.finish()! +} diff --git a/scripts/setup_windows_build.ps1 b/scripts/setup_windows_build.ps1 new file mode 100644 index 0000000..f64f53d --- /dev/null +++ b/scripts/setup_windows_build.ps1 @@ -0,0 +1,7 @@ +# Load the platform toolchain environment for the shared V release builder. +$ErrorActionPreference = 'Stop' +$VsWhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' +$InstallPath = & $VsWhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath +if (-not $InstallPath) { throw 'Visual Studio C++ tools were not found' } +Import-Module (Join-Path $InstallPath 'Common7\Tools\Microsoft.VisualStudio.DevShell.dll') +Enter-VsDevShell -VsInstallPath $InstallPath -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -host_arch=x64' diff --git a/scripts/sign_android_release.v b/scripts/sign_android_release.v new file mode 100755 index 0000000..38f3bbd --- /dev/null +++ b/scripts/sign_android_release.v @@ -0,0 +1,163 @@ +#!/usr/bin/env -S v -prod run + +// Combine trusted unsigned CI APKs and sign with the private release identity. +module main + +import os +import rand +import compress.szip +import crypto.sha256 +import release_tools + +fn archive_entries(path string) !map[string][]u8 { + mut archive := szip.open(path, .no_compression, .read_only)! + defer { archive.close() } + mut entries := map[string][]u8{} + for index in 0 .. archive.total()! { + archive.open_entry_by_index(index)! + name := archive.name() + if name.ends_with('/') || name.starts_with('META-INF/') { + archive.close_entry() + continue + } + if name.starts_with('/') || name.split('/').contains('..') { + return error('Invalid archive path: ${name}') + } + mut data := []u8{len: int(archive.size())} + archive.read_entry_buf(data.data, data.len)! + entries[name] = data + archive.close_entry() + } + return entries +} + +fn main() { + run() or { + eprintln(err) + exit(1) + } +} + +fn run() ! { + mut inputs := []string{} + mut options := map[string]string{} + mut index := 1 + for index < os.args.len { + if index + 1 >= os.args.len { + return error('Missing value for ${os.args[index]}') + } + flag := os.args[index] + value := os.args[index + 1] + if flag == '--input' { + inputs << value + } else { + options[flag] = value + } + index += 2 + } + for key in ['--output', '--build-tools', '--keystore', '--password-file'] { + if options[key] == '' { + return error('Missing ${key}') + } + } + if inputs.len != 3 { + return error('Provide the three ABI input directories with --input') + } + abis := ['armeabi-v7a', 'arm64-v8a', 'x86_64'] + mut common_entries := map[string][]u8{} + mut libraries := map[string][]u8{} + mut notices := map[string][]u8{} + for position, folder in inputs { + entries := archive_entries(os.join_path(folder, 'vimgui-demo-unsigned.apk'))! + mut common := map[string][]u8{} + for name, bytes in entries { + if name.starts_with('lib/') { + parts := name.split('/') + if parts.len != 3 || parts[1] !in abis || !parts[2].ends_with('.so') { + return error('Unexpected native library: ${name}') + } + if name in libraries { + return error('Duplicate native input: ${name}') + } + libraries[name] = bytes + } else { + common[name] = bytes + } + } + if position == 0 { + common_entries = common.clone() + } else if common_entries != common { + return error('ABI inputs disagree on the manifest, UI classes or assets') + } + license_dir := os.join_path(folder, 'licenses') + if !os.is_dir(license_dir) { + return error('Missing redistribution notices: ${license_dir}') + } + for file in os.walk_ext(license_dir, '') { + if !os.is_file(file) { + continue + } + name := 'assets/licenses/' + file.trim_string_left(license_dir + os.path_separator).replace('\\', '/') + bytes := os.read_bytes(file)! + if name in notices && notices[name] != bytes { + return error('License inputs disagree: ${name}') + } + notices[name] = bytes + } + } + for abi in abis { + for name in ['libvimgui.so', 'libvimgui_android_demo.so', 'libvimgui_android_ui.so'] { + if 'lib/${abi}/${name}' !in libraries { + return error('Incomplete ABI: ${abi}') + } + } + } + if libraries.len != 9 { + return error('Unexpected extra native libraries') + } + os.mkdir_all(options['--output'])! + temporary := os.join_path(os.temp_dir(), 'v-imgui-sign-' + rand.uuid_v4()) + os.mkdir(temporary, mode: 0o700)! + defer { os.rmdir_all(temporary) or {} } + for variant in ['universal', 'armeabi-v7a', 'arm64-v8a', 'x86_64'] { + unsigned := os.join_path(temporary, 'unsigned.apk') + aligned := os.join_path(temporary, 'aligned.apk') + output := os.join_path(options['--output'], 'v-imgui-touch-${variant}.apk') + mut contents := common_entries.clone() + for name, bytes in notices { + contents[name] = bytes + } + for name, bytes in libraries { + if variant == 'universal' || name.split('/')[1] == variant { + contents[name] = bytes + } + } + release_tools.zip_entries(unsigned, contents)! + release_tools.command(os.join_path(options['--build-tools'], 'zipalign'), ['-f', '-p', '4', + unsigned, aligned])! + alias := if options['--alias'] != '' { options['--alias'] } else { 'v-imgui-release' } + password := 'file:' + options['--password-file'] + release_tools.command('java', [ + '-jar', + os.join_path(options['--build-tools'], 'lib', 'apksigner.jar'), + 'sign', + '--ks', + options['--keystore'], + '--ks-key-alias', + alias, + '--ks-pass', + password, + '--out', + output, + aligned, + ])! + release_tools.command('java', [ + '-jar', + os.join_path(options['--build-tools'], 'lib', 'apksigner.jar'), + 'verify', + '--verbose', + output, + ])! + println('${sha256.hexhash(os.read_file(output)!)} ${os.file_name(output)}') + } +} diff --git a/scripts/smoke_desktop.sh b/scripts/smoke_desktop.sh index 4730ed0..2b1d5eb 100755 --- a/scripts/smoke_desktop.sh +++ b/scripts/smoke_desktop.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash set -euo pipefail binary_dir="${1:?Usage: scripts/smoke_desktop.sh path/to/binaries}" +command -v grep >/dev/null log_dir="$binary_dir/smoke-logs" mkdir -p "$log_dir" lavapipe=(/usr/share/vulkan/icd.d/lvp_icd*.json) @@ -12,9 +13,11 @@ for example in glfw_vulkan widget_gallery implot_dashboard; do frames=60 # Exercise the ring buffer after wrapping past its 600-sample capacity. if [[ "$example" == implot_dashboard ]]; then frames=660; fi - VIMGUI_SMOKE_FRAMES="$frames" timeout 60s xvfb-run -a "$binary_dir/$example" > "$log_dir/$example.log" 2>&1 + command=("$binary_dir/$example") + if [[ -x "$binary_dir/examples" ]]; then command=("$binary_dir/examples" "$example"); fi + VIMGUI_SMOKE_FRAMES="$frames" timeout 60s xvfb-run -a "${command[@]}" > "$log_dir/$example.log" 2>&1 cat "$log_dir/$example.log" - if rg -iq 'VUID-|validation error|segmentation|assert|fatal' "$log_dir/$example.log"; then + if grep -Eiq 'VUID-|validation error|segmentation|assert|fatal' "$log_dir/$example.log"; then echo "Rendering failed: $example" >&2 exit 1 fi diff --git a/scripts/test_android_accessibility.sh b/scripts/test_android_accessibility.sh new file mode 100755 index 0000000..ba6d294 --- /dev/null +++ b/scripts/test_android_accessibility.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Run against the accessible debug APK produced by build_android.sh. +set -euo pipefail +repo=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +sdk=${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}} +adb=("$sdk/platform-tools/adb") +if [[ -n ${ANDROID_SERIAL:-} ]]; then adb+=(-s "$ANDROID_SERIAL"); fi +abi=${ANDROID_ABI:-$("${adb[@]}" shell getprop ro.product.cpu.abi | tr -d '\r')} +build=$repo/build/android-touch-$abi +build_tools=$(printf '%s\n' "$sdk"/build-tools/* | sort -V | tail -n 1) +android_jar=$(printf '%s\n' "$sdk"/platforms/*/android.jar | sort -V | tail -n 1) +package=$build/accessibility-test +mkdir -p "$package/classes" "$package/dex" +"${JAVAC:-javac}" -source 8 -target 8 -Xlint:-options -cp "$android_jar" -d "$package/classes" \ + "$repo/tests/android/accessibility/AccessibilitySmoke.java" +mapfile -d '' class_files < <(find "$package/classes" -name '*.class' -print0) +"$build_tools/d8" --min-api 24 --lib "$android_jar" --output "$package/dex" "${class_files[@]}" +"$build_tools/aapt" package -f -M "$repo/tests/android/accessibility/AndroidManifest.xml" \ + -I "$android_jar" -F "$package/unsigned.apk" +(cd "$package/dex"; "$build_tools/aapt" add "$package/unsigned.apk" classes.dex) +"$build_tools/zipalign" -f 4 "$package/unsigned.apk" "$package/aligned.apk" +keystore=${VIMGUI_ANDROID_TEST_KEYSTORE:-$build/debug.keystore} +password=(--ks-pass pass:android) +if [[ -n ${VIMGUI_ANDROID_TEST_PASSWORD_FILE:-} ]]; then + password=(--ks-pass "file:$VIMGUI_ANDROID_TEST_PASSWORD_FILE") +fi +"$build_tools/apksigner" sign --ks "$keystore" "${password[@]}" \ + --out "$package/test.apk" "$package/aligned.apk" +"$build_tools/apksigner" verify "$package/test.apk" +"${adb[@]}" shell input keyevent 224 +"${adb[@]}" shell wm dismiss-keyguard +"${adb[@]}" shell am force-stop io.antono2.vimgui.examples.touch +"${adb[@]}" install -r "${VIMGUI_ANDROID_TEST_APK:-$build/vimgui-demo-$abi.apk}" +"${adb[@]}" install -r "$package/test.apk" +"${adb[@]}" shell am instrument -w \ + io.antono2.vimgui.examples.touch.test/io.antono2.vimgui.examples.touch.test.AccessibilitySmoke | tee "$package/instrumentation.log" +rg -q 'result=PASS:' "$package/instrumentation.log" diff --git a/setup.vsh b/setup.vsh old mode 100644 new mode 100755 index 230f87c..f327794 --- a/setup.vsh +++ b/setup.vsh @@ -1,34 +1,157 @@ #!/usr/bin/env -S v run -// Installs/builds antono2.imgui and compiles this graphical example. It does -// not launch a window; run the example normally after setup to see it. +// Guided setup, build and run; explicit flags also support automation. import os fn run(command string) ! { println('\n> ${command}') - result := os.execute(command) - if result.output.trim_space() != '' { - println(result.output.trim_right('\r\n')) + code := os.system(command) + if code != 0 { + return error('command failed with exit code ${code}; see the output above') } +} + +fn ask(prompt string) !string { + return (os.input_opt(prompt) or { return error('Input closed. Use explicit flags for noninteractive runs.') }).trim_space() +} + +fn confirm(prompt string) !bool { + return (ask(prompt + ' [y/N]: ')!).to_lower() in ['y', 'yes'] +} + +fn android_environment(interactive bool) ! { + mut sdk := os.getenv('ANDROID_SDK_ROOT') + if sdk == '' { + sdk = os.getenv('ANDROID_HOME') + } + if sdk == '' { + for candidate in [os.join_path(os.home_dir(), 'Android', 'Sdk'), + os.join_path(os.home_dir(), 'Library', 'Android', 'sdk'), + os.join_path(os.getenv('LOCALAPPDATA'), 'Android', 'Sdk')] { + if os.is_dir(candidate) { + sdk = candidate + break + } + } + } + if sdk == '' && interactive { + sdk = ask('Android SDK directory: ')! + } + if !os.is_dir(sdk) { + return error('Android SDK not found. Set ANDROID_SDK_ROOT to an installed SDK; see examples/android_touch/README.md.') + } + if !os.is_file(os.join_path(sdk, 'platforms', 'android-36', 'android.jar')) || !os.is_dir(os.join_path(sdk, 'build-tools', '36.0.0')) { + return error('Install Android SDK Platform 36 and Build-Tools 36.0.0 using the SDK Manager, then retry.') + } + os.setenv('ANDROID_SDK_ROOT', sdk, true) + mut ndk := os.getenv('ANDROID_NDK_HOME') + if ndk == '' { + ndk = os.getenv('ANDROID_NDK_ROOT') + } + if ndk == '' { + ndk_directory := os.join_path(sdk, 'ndk') + if os.is_dir(ndk_directory) { + mut versions := os.ls(ndk_directory)! + versions = versions.filter(os.is_file(os.join_path(ndk_directory, it, 'build', 'cmake', 'android.toolchain.cmake'))) + versions.sort() + if versions.len == 1 { + ndk = os.join_path(ndk_directory, versions[0]) + } else if versions.len > 1 && interactive { + println('Installed NDKs:') + for i, version in versions { + println('${i + 1}. ${version}') + } + choice := ask('Choose an NDK number: ')! + if choice.int() < 1 || choice.int() > versions.len { + return error('Invalid NDK choice') + } + ndk = os.join_path(ndk_directory, versions[choice.int() - 1]) + } + } + } + if ndk == '' && interactive { + ndk = ask('Android NDK directory: ')! + } + if !os.is_file(os.join_path(ndk, 'build', 'cmake', 'android.toolchain.cmake')) { + return error('Android NDK not found. Set ANDROID_NDK_HOME to an installed NDK.') + } + os.setenv('ANDROID_NDK_HOME', ndk, true) + os.setenv('PATH', os.join_path(sdk, 'platform-tools') + os.path_delimiter + os.getenv('PATH'), true) +} + +fn android_device(interactive bool) ! { + if !os.exists_in_system_path('adb') { + return error('Install Android SDK Platform-Tools before installing on a device.') + } + result := os.execute('adb devices') if result.exit_code != 0 { - return error('command failed with exit code ${result.exit_code}') + return error('Could not list Android devices: ${result.output}') + } + mut devices := []string{} + for line in result.output.split_into_lines() { + parts := line.fields() + if parts.len == 2 && parts[1] == 'device' { devices << parts[0] } + } + if devices.len == 0 { + return error('No authorized device. Connect it, enable USB debugging and accept its authorization prompt, then retry.') + } + mut serial := os.getenv('ANDROID_SERIAL') + if serial != '' && serial !in devices { + return error('ANDROID_SERIAL is not an authorized connected device.') + } + if serial == '' && devices.len == 1 { + serial = devices[0] + } + if serial == '' && interactive { + for i, device in devices { + println('${i + 1}. ${device}') + } + choice := ask('Choose a device number: ')! + if choice.int() < 1 || choice.int() > devices.len { + return error('Invalid device choice') + } + serial = devices[choice.int() - 1] + } + if serial == '' { + return error('Several devices are connected. Set ANDROID_SERIAL to choose one.') } + os.setenv('ANDROID_SERIAL', serial, true) + println('Device: ${serial}') } -fn main() { +fn guided_main() ! { project_dir := os.dir(os.real_path(@FILE)) + // Keep upstream setup and compilation on the same V compiler as this runner. + os.setenv('PATH', @VEXEROOT + os.path_delimiter + os.getenv('PATH'), true) mut mode := '--install' mut example := 'glfw_vulkan' + interactive := os.args.len == 1 + mut launch := false + if interactive { + println('V ImGui examples\n\n1. Widget gallery — controls, tables and popups (default)\n2. ImPlot dashboard — interactive plots\n3. GLFW/Vulkan — windows and backend demonstration\n4. Android touch and text — device app\nQ. Quit\n') + choice := (ask('Choose an example [1]: ')!).to_lower() + match choice { + '', '1' { example = 'widget_gallery' } + '2' { example = 'implot_dashboard' } + '3' { example = 'glfw_vulkan' } + '4' { example = 'android_touch' } + 'q' { return } + else { return error('Choose 1, 2, 3, 4 or Q.') } + } + launch = true + } mut index := 1 for index < os.args.len { arg := os.args[index] if arg in ['--install', '--check', '--build-only'] { mode = arg + } else if arg == '--run' { + launch = true } else if arg == '--example' && index + 1 < os.args.len { index++ example = os.args[index] } else if arg in ['-h', '--help'] { - println('Usage: v run setup.vsh [--install|--check|--build-only] [--example glfw_vulkan|widget_gallery|implot_dashboard]') + println('Usage: ./setup.vsh [--install|--check|--build-only] [--run] [--example glfw_vulkan|widget_gallery|implot_dashboard|android_touch]\nOn Windows: v run setup.vsh with the same options.\nNo arguments: guided setup, build and launch. Explicit flags skip menus; system installers may request administrator authorization. --check makes no changes.') return } else { eprintln('Unknown or incomplete option: ${arg}') @@ -36,9 +159,11 @@ fn main() { } index++ } - if example !in ['glfw_vulkan', 'widget_gallery', 'implot_dashboard'] { - eprintln('Unknown desktop example: ${example}. For Android, use scripts/build_android.sh.') - exit(2) + if example !in ['glfw_vulkan', 'widget_gallery', 'implot_dashboard', 'android_touch'] { + return error('Unknown example: ${example}. Use --help for choices.') + } + if mode == '--check' && launch { + return error('--check cannot be combined with --run.') } source := if example == 'glfw_vulkan' { project_dir @@ -52,38 +177,98 @@ fn main() { } os.setenv('VMODULES', module_dir, true) imgui_root := os.join_path(module_dir, 'antono2', 'imgui') - revision := (os.read_file(os.join_path(project_dir, 'IMGUI_REVISION')) or { panic(err) }).trim_space() + revision := (os.read_file(os.join_path(project_dir, 'IMGUI_REVISION')) or { return err }).trim_space() if revision.len != 40 || !revision.bytes().all((it >= `0` && it <= `9`) || (it >= `a` && it <= `f`)) { - panic('IMGUI_REVISION must contain a full lowercase Git commit SHA') + return error('IMGUI_REVISION must contain a full lowercase Git commit SHA') + } + mut example_binary := os.join_path(project_dir, 'build', example) + $if windows { + example_binary = os.join_path(imgui_root, 'build', 'windows-demo', example + '.exe') + } + if interactive && example != 'android_touch' && os.is_file(example_binary) { + choice := (ask('Existing build: run [Enter], rebuild [b], prepare dependencies again [s], or quit [q]: ')!).to_lower() + match choice { + '' { + run(os.quoted_path(example_binary))! + return + } + 'b' { mode = '--build-only' } + 's' {} + 'q' { return } + else { return error('Choose Enter, b, s or q.') } + } + } + if example == 'android_touch' { + android_environment(interactive)! + for tool in ['bash', 'git', 'cmake', 'ninja', 'java', 'javac'] { + if !os.exists_in_system_path(tool) { + return error('Missing ${tool}. Install it and retry; see examples/android_touch/README.md.') + } + } + if launch { android_device(interactive)! } + if mode == '--check' { + println('Android tools and SDK/NDK paths are available.') + return + } + if !launch && os.getenv('ANDROID_ABI') == '' { + return error('Set ANDROID_ABI to armeabi-v7a, arm64-v8a or x86_64 for a build without a device.') + } + if interactive && !confirm('Build a debug APK and install/open it on this device? Dependencies may be downloaded; an installed app with a different signing key must be removed manually.')! { + return + } + os.setenv('IMGUI_DIR', imgui_root, true) + os.setenv('VIMGUI_ANDROID_APPLICATION_UI', '1', true) + run('bash ${os.quoted_path(os.join_path(project_dir, 'scripts', 'build_android.sh'))} ${if launch { + 'run' + } else { + '--build-only' + }}')! + return + } + if !os.exists_in_system_path('git') { + return error('Git is required. Install Git, then run setup again.') + } + if interactive && mode == '--install' { + println('Prerequisites: V and Git are available.') + if !os.exists_in_system_path('cmake') { + println('CMake is missing. The upstream setup will install it on supported systems or show installation instructions.') + } + println('Setup will download pinned dependencies into ${module_dir} and build ${example}.\nThe upstream setup may install system packages (and request administrator access). Compilation can require about 11 GiB of RAM or swap.') + if !confirm('Continue with setup, build and launch?')! { + return + } + } + if mode == '--check' && !os.is_file(os.join_path(imgui_root, 'v.mod')) { + return error('Pinned dependencies are not prepared. Run the guided setup or --install first; no files were changed.') } if mode == '--install' { - os.mkdir_all(os.dir(imgui_root)) or { panic(err) } + os.mkdir_all(os.dir(imgui_root)) or { return err } if !os.is_file(os.join_path(imgui_root, 'v.mod')) { - run('git clone https://github.com/antono2/imgui.git ${os.quoted_path(imgui_root)}') or { panic(err) } + run('git clone https://github.com/antono2/imgui.git ${os.quoted_path(imgui_root)}') or { return err } } // Never discard changes in an existing dependency checkout. status := os.execute('git -C ${os.quoted_path(imgui_root)} status --porcelain') if status.exit_code != 0 || status.output.trim_space() != '' { - panic('ImGui dependency checkout contains changes; commit them or use a fresh VMODULES directory') + return error('ImGui dependency checkout contains changes; commit them or use a fresh VMODULES directory') } - run('git -C ${os.quoted_path(imgui_root)} fetch origin ${revision.trim_space()}') or { panic(err) } - run('git -C ${os.quoted_path(imgui_root)} checkout --detach ${revision.trim_space()}') or { panic(err) } + run('git -C ${os.quoted_path(imgui_root)} fetch origin ${revision.trim_space()}') or { return err } + run('git -C ${os.quoted_path(imgui_root)} checkout --detach ${revision.trim_space()}') or { return err } } current := os.execute('git -C ${os.quoted_path(imgui_root)} rev-parse HEAD') if current.exit_code != 0 || current.output.trim_space() != revision.trim_space() { - panic('ImGui must match IMGUI_REVISION; run --install with a clean dependency checkout') + return error('ImGui must match IMGUI_REVISION; run --install with a clean dependency checkout') } imgui_setup := os.join_path(imgui_root, 'setup.vsh') if !os.is_file(imgui_setup) { - panic('Run setup.vsh --install first to prepare the pinned ImGui dependency') + return error('Run setup.vsh --install first to prepare the pinned ImGui dependency') } if mode != '--build-only' { - run('v run ${os.quoted_path(imgui_setup)} ${mode}') or { panic(err) } + run('v run ${os.quoted_path(imgui_setup)} ${mode}') or { return err } } if mode == '--check' { return } - os.mkdir_all(os.join_path(project_dir, 'build')) or { panic(err) } + os.mkdir_all(os.join_path(project_dir, 'build')) or { return err } $if windows { vulkan_sdk := os.execute('powershell -NoProfile -Command "[Environment]::GetEnvironmentVariable(\'VULKAN_SDK\', \'Machine\')"') @@ -92,8 +277,9 @@ fn main() { } runner := os.join_path(imgui_root, 'scripts', 'run_demo_windows.ps1') run('powershell -NoProfile -ExecutionPolicy Bypass -File ${os.quoted_path(runner)} -BuildOnly -DemoDirectory ${os.quoted_path(project_dir)} -DemoSource ${os.quoted_path(source)}') or { - panic(err) + return err } + os.cp(os.join_path(imgui_root, 'build', 'windows-demo', 'v_imgui_demo.exe'), example_binary)! } $else { $if linux { os.setenv('VULKAN_SDK', '/usr', false) @@ -108,11 +294,24 @@ fn main() { os.setenv('GLFW_INCLUDE', os.join_path(prefix, 'include'), true) os.setenv('GLFW_LIB', os.join_path(prefix, 'lib'), true) } - executable := os.join_path(project_dir, 'build', example) module_path := '${module_dir}/antono2|@vlib|@vmodules' - run('v -no-memory-limit -path ${os.quoted_path(module_path)} -o ${os.quoted_path(executable)} ${os.quoted_path(source)}') or { - panic(err) + staged_binary := example_binary + '.building' + if os.exists(staged_binary) { os.rm(staged_binary)! } + run('v -no-memory-limit -path ${os.quoted_path(module_path)} -o ${os.quoted_path(staged_binary)} ${os.quoted_path(source)}') or { + return err } + if !os.is_file(staged_binary) { + return error('The compiler did not produce an executable. Review its output above.') + } + os.mv(staged_binary, example_binary)! + } + println('\nBuilt ${example_binary}') + if launch { run(os.quoted_path(example_binary))! } +} + +fn main() { + guided_main() or { + eprintln('\n${err}') + exit(1) } - println('\nBuilt ${example}. On Linux/macOS, run build/${example}; on Windows, run the executable reported above.') } diff --git a/tests/android/accessibility/AccessibilitySmoke.java b/tests/android/accessibility/AccessibilitySmoke.java new file mode 100644 index 0000000..148c94e --- /dev/null +++ b/tests/android/accessibility/AccessibilitySmoke.java @@ -0,0 +1,151 @@ +package io.antono2.vimgui.examples.touch.test; + +import android.app.Activity; +import android.app.Instrumentation; +import android.app.UiAutomation; +import android.accessibilityservice.AccessibilityServiceInfo; +import android.content.Intent; +import android.graphics.Rect; +import android.os.Bundle; +import android.os.SystemClock; +import android.view.View; +import android.view.MotionEvent; +import android.view.InputDevice; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityWindowInfo; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import java.util.ArrayDeque; +import java.util.function.Supplier; +import java.util.concurrent.atomic.AtomicReference; + +/** Exercises the native Android accessibility provider without changing the + * user's configured accessibility services. Touch coordinates come from the app's list bounds. */ +public final class AccessibilitySmoke extends Instrumentation { + private UiAutomation automation; + private String lastTree=""; + + @Override public void onCreate(Bundle arguments) { super.onCreate(arguments); start(); } + + private T await(Supplier probe, String message) { + long deadline=SystemClock.uptimeMillis()+30000; + do { + T value=probe.get(); + if (value!=null) return value; + SystemClock.sleep(100); + } while (SystemClock.uptimeMillis() queue=new ArrayDeque<>(); + StringBuilder trace=new StringBuilder(); + for (AccessibilityWindowInfo window:automation.getWindows()) { + AccessibilityNodeInfo root=window.getRoot(); + trace.append("window=").append(window.getId()).append(" package=").append(root==null?"null":root.getPackageName()).append(';'); + if (root!=null && "io.antono2.vimgui.examples.touch".contentEquals(root.getPackageName()==null?"":root.getPackageName())) queue.add(root); + } + while (!queue.isEmpty()) { + AccessibilityNodeInfo node=queue.removeFirst(); + if (trace.length()<4000) trace.append(node.getClassName()).append(':').append(node.getContentDescription()).append(':').append(node.getText()).append(" children=").append(node.getChildCount()).append(';'); + if (label.contentEquals(node.getContentDescription()==null?"":node.getContentDescription()) || + label.contentEquals(node.getText()==null?"":node.getText())) return node; + for (int i=0;i find(label),"Missing accessible control: "+label); } + private void click(String label) { + if (!node(label).performAction(AccessibilityNodeInfo.ACTION_CLICK)) throw new AssertionError("Click rejected: "+label); + } + private View inputView(View root) { + if (root.getClass().getName().equals("io.antono2.imgui.ImGuiInputView")) return root; + if (root instanceof ViewGroup) { + ViewGroup group=(ViewGroup)root; + for (int i=0;i activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)); + for(String label:new String[]{"V ImGui: touch and text","High contrast","Tap counter","Editable text"}) { + if(!node(label).isImportantForAccessibility())throw new AssertionError("Control skipped by screen readers: "+label); + } + if(!node("High contrast").isCheckable())throw new AssertionError("Checkbox role missing"); + click("High contrast"); + await(() -> node("High contrast").isChecked()?Boolean.TRUE:null,"High contrast did not toggle"); + click("Tap counter"); node("Taps: 1"); + AccessibilityNodeInfo.RangeInfo range=node("Next hundred taps").getRangeInfo(); + if(range==null || Math.abs(range.getCurrent()-.01f)>.001f)throw new AssertionError("Progress range did not update"); + Bundle text=new Bundle(); text.putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,"A📷e\u0301Z"); + if(!node("Editable text").performAction(AccessibilityNodeInfo.ACTION_SET_TEXT,text))throw new AssertionError("Set text rejected"); + await(() -> "A📷e\u0301Z".contentEquals(node("Editable text").getText())?Boolean.TRUE:null,"Unicode text damaged"); + Bundle selection=new Bundle(); + selection.putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT,1); + selection.putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT,3); + if(!node("Editable text").performAction(AccessibilityNodeInfo.ACTION_SET_SELECTION,selection))throw new AssertionError("Selection rejected"); + await(() -> node("Editable text").getTextSelectionStart()==1 && node("Editable text").getTextSelectionEnd()==3?Boolean.TRUE:null,"Selection did not round-trip"); + click("Copy all text"); node("Copied all text. The clipboard preview is shown below."); + click("Clear preview"); node("Preview cleared. The system clipboard is unchanged."); + click("Read clipboard"); node("Read clipboard. The preview is shown below."); node("A📷e\u0301Z"); + click("Larger text"); node("Text size: 1.10x"); + click("Reset text size"); node("Text size: 1.00x"); + // Restore the visible top, then swipe static instructions rather than the scrollbar. + node("Tap counter").performAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SHOW_ON_SCREEN.getId()); + automation.performGlobalAction(android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_BACK); + SystemClock.sleep(500); + Rect before=new Rect();node("Tap counter").getBoundsInScreen(before); + Rect instructions=new Rect();node("Copy uses the entire editable field; no selection is needed. Read displays the clipboard below.").getBoundsInScreen(instructions); + if(!(instructions.height()>0 && instructions.top>150))throw new AssertionError("Static swipe target is not visible"); + { + drag(instructions.centerX(),instructions.centerY(),100); + await(() -> {Rect after=new Rect();node("Tap counter").getBoundsInScreen(after);return after.top + + + + + diff --git a/tests/desktop/accessibility.cpp b/tests/desktop/accessibility.cpp new file mode 100644 index 0000000..ca1a494 --- /dev/null +++ b/tests/desktop/accessibility.cpp @@ -0,0 +1,56 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +static void settle(){while(g_main_context_pending(nullptr))g_main_context_iteration(nullptr,false);std::this_thread::sleep_for(std::chrono::milliseconds(100));} +static AtspiAccessible *find(const std::string &name){std::deque queue;queue.push_back(atspi_get_desktop(0)); + while(!queue.empty()){auto *object=queue.front();queue.pop_front();if(!object)continue;gchar *label=atspi_accessible_get_name(object,nullptr); + bool match=label && name==label;g_free(label); + if(match){for(auto *remaining:queue)if(remaining)g_object_unref(remaining);return object;} + for(int i=0;imessage);g_error_free(error);return 1;} + for(auto property:{"IsEnabled","ScreenReaderEnabled"}){auto *reply=g_dbus_connection_call_sync(bus,"org.a11y.Bus","/org/a11y/bus","org.freedesktop.DBus.Properties","Set", + g_variant_new("(ssv)","org.a11y.Status",property,g_variant_new_boolean(true)),nullptr,G_DBUS_CALL_FLAGS_NONE,10000,nullptr,&error); + if(!reply){std::fprintf(stderr,"%s\n",error->message);g_error_free(error);g_object_unref(bus);return 1;}g_variant_unref(reply);} + g_object_unref(bus);atspi_init();pid_t child=fork();if(child==0){std::freopen(argv[2],"w",stdout);dup2(fileno(stdout),STDERR_FILENO);execl(argv[1],argv[1],nullptr);_exit(127);} + int result=0; + try { + for(auto name:{"High contrast","Name","Count","Files"}){auto *node=require(name);auto *role=atspi_accessible_get_role_name(node,nullptr);std::printf("%s: %s\n",name,role);g_free(role);g_object_unref(node);} + if(std::system("xdotool search --sync --name 'V ImGui: widget gallery' windowfocus")!=0)throw std::runtime_error("Could not focus gallery"); + click("Count");expect("Count: 1");click("High contrast");has_state("High contrast",ATSPI_STATE_CHECKED); + click("200% text");click("Focus last file");has_state("Photo 0999.jpg",ATSPI_STATE_FOCUSED); + click("Photo 0999.jpg");expect("Selected Photo 0999.jpg");click("Raw ImGui widgets");settle();click("Accessible controls");expect("Files"); + auto *field=require("Name");auto *edit=atspi_accessible_get_editable_text_iface(field); + if(!edit||!atspi_editable_text_set_text_contents(edit,"A📷éZ",nullptr))throw std::runtime_error("Unicode native edit rejected");g_object_unref(edit);g_object_unref(field);settle(); + field=require("Name");auto *text=atspi_accessible_get_text_iface(field);gchar *value=atspi_text_get_text(text,0,-1,nullptr); + if(!value||std::string(value)!="A📷éZ")throw std::runtime_error("Unicode text did not round-trip");g_free(value); + if(!atspi_text_set_selection(text,0,1,2,nullptr))throw std::runtime_error("Native text selection rejected");g_object_unref(text);g_object_unref(field); + std::puts("PASS: native AT-SPI roles, actions, contrast, text size, virtual list focus and selection, views, Unicode editing and selection"); + }catch(const std::exception &error){std::fprintf(stderr,"%s\n",error.what());result=1;} + kill(child,SIGTERM);waitpid(child,nullptr,0);atspi_exit();return result; +} diff --git a/tests/desktop/accessibility.ps1 b/tests/desktop/accessibility.ps1 new file mode 100644 index 0000000..ec061ca --- /dev/null +++ b/tests/desktop/accessibility.ps1 @@ -0,0 +1,71 @@ +param([Parameter(Mandatory)] [string] $Executable, [Parameter(Mandatory)] [string] $SelectionProbe) +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName UIAutomationClient +Add-Type -AssemblyName UIAutomationTypes +$Executable = (Resolve-Path $Executable).Path +$Process = Start-Process -FilePath $Executable -WorkingDirectory (Split-Path $Executable) -PassThru +function Wait-For([scriptblock] $Probe, [string] $Message) { + $Deadline = [DateTime]::UtcNow.AddSeconds(30) + do { + if ($Process.HasExited) { throw "Gallery exited with $($Process.ExitCode): $Message" } + $ProbeResult = & $Probe + if ($ProbeResult) { return $ProbeResult } + Start-Sleep -Milliseconds 100 + } while ([DateTime]::UtcNow -lt $Deadline) + throw $Message +} +function Find-Control([string] $Name) { + $Condition = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::NameProperty, $Name) + $Root.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $Condition) +} +function Require-Control([string] $Name) { Wait-For { Find-Control $Name } "Missing UIA control: $Name" } +function Invoke-Control([string] $Name) { + Write-Output "Invoke: $Name" + $Node = Require-Control $Name + $Pattern = $Node.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) + $Pattern.Invoke() +} +function Toggle-Control([string] $Name) { + Write-Host "Toggle: $Name" + $Pattern = (Require-Control $Name).GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern) + $Pattern.Toggle() + return $Pattern +} +function Select-Control([string] $Name) { + Write-Output "Select: $Name" + $Pattern = (Require-Control $Name).GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) + $Pattern.Select() +} +try { + $Handle = Wait-For { $Process.Refresh(); if ($Process.MainWindowHandle -ne 0) { $Process.MainWindowHandle } } 'No gallery window' + $Root = [System.Windows.Automation.AutomationElement]::FromHandle($Handle) + foreach ($Name in @('Count', 'Name', 'High contrast', 'Files')) { $Node = Require-Control $Name; Write-Output "$Name : $($Node.Current.ControlType.ProgrammaticName)" } + Invoke-Control 'Count'; $null = Require-Control 'Count: 1' + $Toggle = Toggle-Control 'High contrast' + $null = Wait-For { $Toggle.Current.ToggleState -eq [System.Windows.Automation.ToggleState]::On } 'Checkbox state did not update' + $null = Toggle-Control '200% text'; Invoke-Control 'Focus last file' + $Last = Require-Control 'Photo 0999.jpg' + $Last.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Select() + $null = Require-Control 'Selected Photo 0999.jpg' + $List = (Require-Control 'Files').GetCurrentPattern([System.Windows.Automation.ScrollPattern]::Pattern) + Write-Output "Scroll before request: $($List.Current.VerticalScrollPercent)% (view $($List.Current.VerticalViewSize)%)" + $List.SetScrollPercent(-1, 50) + try { + $null = Wait-For { [Math]::Abs($List.Current.VerticalScrollPercent - 50) -lt 1 } 'Exact scroll percentage did not apply' + } finally { Write-Output "Scroll after request: $($List.Current.VerticalScrollPercent)% (view $($List.Current.VerticalViewSize)%)" } + Select-Control 'Raw ImGui widgets' + $null = Wait-For { (Require-Control 'Raw ImGui widgets').GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Current.IsSelected } 'Raw widget view did not open' + Select-Control 'Accessible controls'; $null = Require-Control 'Name' + $Unicode = 'A' + [char]0xd83d + [char]0xdcf7 + 'e' + [char]0x0301 + 'Z' + $Field = Require-Control 'Name' + $Value = $Field.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern) + $Value.SetValue($Unicode) + $null = Wait-For { $Value.Current.Value -eq $Unicode } 'Unicode native edit did not round-trip' + # The legacy .NET selected-range wrapper crashes even against system RichEdit + # on this runner. Exercise the same selection round trip with native UIA. + & $SelectionProbe ($Handle.ToInt64().ToString()) + if ($LASTEXITCODE -ne 0) { throw 'Native Unicode selection check failed' } + Write-Output 'PASS: native UIA roles, actions, checkbox state, text scaling, virtual selection, exact scrolling, views and Unicode text/selection' +} finally { + if (-not $Process.HasExited) { Stop-Process -Id $Process.Id -Force } +} diff --git a/tests/desktop/shortcuts.sh b/tests/desktop/shortcuts.sh new file mode 100755 index 0000000..1a3f5b2 --- /dev/null +++ b/tests/desktop/shortcuts.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +set -euo pipefail +binary_dir=$(cd -- "${1:?Usage: shortcuts.sh binary-dir [--raw-gallery]}" && pwd) +# Always use a private display; never send keys to the user's desktop. +if [[ ${VIMGUI_SHORTCUT_TEST_SESSION:-0} != 1 ]]; then + exec env VIMGUI_SHORTCUT_TEST_SESSION=1 xvfb-run -a -s '-screen 0 1600x1000x24' bash "$0" "$binary_dir" "${2:-}" +fi +lavapipe=(/usr/share/vulkan/icd.d/lvp_icd*.json) +test -f "${lavapipe[0]}" +export VK_DRIVER_FILES="${lavapipe[0]}" +export VK_ICD_FILENAMES="$VK_DRIVER_FILES" +export VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation +log_dir="$binary_dir/shortcut-logs" +mkdir -p "$log_dir" +app_pid='' +wm_pid='' +cleanup() { + if [[ -n "$app_pid" ]]; then kill "$app_pid" 2>/dev/null || true; fi + if [[ -n "$wm_pid" ]]; then kill "$wm_pid" 2>/dev/null || true; fi +} +trap cleanup EXIT +openbox --sm-disable > "$log_dir/window-manager.log" 2>&1 & +wm_pid=$! +for i in {1..100}; do + if xprop -root _NET_SUPPORTING_WM_CHECK 2>/dev/null | grep -q 'window id'; then break; fi + sleep 0.1 +done +window='' +start_app() { + current_example=$1 + "$binary_dir/$1" > "$log_dir/$1.log" 2>&1 & + app_pid=$! + window='' + for i in {1..300}; do + window=$(xdotool search --onlyvisible --name '^V ImGui:' 2>/dev/null | head -1 || true) + if [[ -n "$window" ]]; then break; fi + if ! kill -0 "$app_pid" 2>/dev/null; then cat "$log_dir/$1.log"; echo "Failed to open $1" >&2; exit 1; fi + sleep 0.1 + done + if [[ -z "$window" ]]; then + echo "No visible window appeared for $current_example within 30 seconds" >&2 + cat "$log_dir/$current_example.log" >&2 + cat "$log_dir/window-manager.log" >&2 + xwininfo -root -tree >&2 || true + ps -o pid,stat,wchan:24,comm -p "$app_pid" >&2 || true + if command -v gdb >/dev/null; then + timeout 10s gdb -batch -ex 'set pagination off' -ex 'thread apply all bt' -p "$app_pid" > "$log_dir/$current_example-startup-stack.log" 2>&1 || true + cat "$log_dir/$current_example-startup-stack.log" >&2 + timeout 10s gdb -batch -ex 'set pagination off' -ex 'thread apply all bt' -p "$wm_pid" > "$log_dir/window-manager-stack.log" 2>&1 || true + cat "$log_dir/window-manager-stack.log" >&2 + fi + exit 1 + fi + if ! timeout 10s xdotool windowactivate --sync "$window"; then + echo "Window manager did not activate $current_example" >&2 + xprop -id "$window" _NET_WM_STATE >&2 || true + cat "$log_dir/window-manager.log" >&2 + exit 1 + fi + sleep 0.3 +} +geometry() { + xdotool getwindowgeometry --shell "$window" | sed -n '/^X=/p; /^Y=/p; /^WIDTH=/p; /^HEIGHT=/p' +} +wait_geometry() { + for i in {1..100}; do + if [[ $(geometry) == "$1" ]]; then return; fi + sleep 0.05 + done + echo 'Window geometry did not reach expected state' >&2 + geometry >&2 + exit 1 +} +quit_app() { + xdotool key Escape + for i in {1..100}; do + if ! kill -0 "$app_pid" 2>/dev/null; then + wait "$app_pid" + app_pid='' + if grep -Eiq 'VUID-|validation error|segmentation|assert|fatal' "$log_dir/$current_example.log"; then + cat "$log_dir/$current_example.log" >&2 + return 1 + fi + return + fi + sleep 0.05 + done + echo 'Escape did not quit the example' >&2 + exit 1 +} +fullscreen=$'X=0\nY=0\nWIDTH=1600\nHEIGHT=1000' +desktop_mode=$(xrandr --current) +for example in glfw_vulkan widget_gallery implot_dashboard; do + start_app "$example" + normal=$(geometry) + normal_frame=$(xprop -id "$window" _NET_FRAME_EXTENTS) + xdotool keydown F11 + wait_geometry "$fullscreen" + # Holding F11 must not toggle repeatedly. + sleep 0.6 + test "$(geometry)" == "$fullscreen" + xdotool keyup F11 + xprop -id "$window" _NET_FRAME_EXTENTS | grep -q '= 0, 0, 0, 0' + test "$(xrandr --current)" == "$desktop_mode" + sleep 0.1 + xdotool key F11 + wait_geometry "$normal" + test "$(xprop -id "$window" _NET_FRAME_EXTENTS)" == "$normal_frame" + quit_app + echo "PASS: $example — F11, held key, restore geometry and Escape" +done +start_app widget_gallery +normal=$(geometry) +wmctrl -i -r "$window" -b add,maximized_vert,maximized_horz +for i in {1..100}; do + if xprop -id "$window" _NET_WM_STATE | grep -q '_NET_WM_STATE_MAXIMIZED_VERT'; then break; fi + sleep 0.05 +done +sleep 0.2 +maximized=$(geometry) +test "$normal" != "$maximized" +xdotool key F11 +wait_geometry "$fullscreen" +xdotool key F11 +wait_geometry "$maximized" +wmctrl -i -r "$window" -b remove,maximized_vert,maximized_horz +wait_geometry "$normal" +quit_app +echo 'PASS: Fullscreen preserves maximized state and normal restore bounds' +if [[ ${2:-} == --raw-gallery ]]; then + start_app widget_gallery + # Raw gallery's fixed initial layout: edit the Name field. + xdotool mousemove --window "$window" 200 113 click 1 + xdotool type --clearmodifiers 'Escape cancels this edit' + xdotool key Escape + sleep 0.2 + kill -0 "$app_pid" + quit_app + echo 'PASS: Escape cancels editing before quitting' + start_app widget_gallery + xdotool mousemove --window "$window" 100 252 click 1 + sleep 0.2 + xdotool key Escape + sleep 0.2 + kill -0 "$app_pid" + quit_app + echo 'PASS: Escape closes a popup before quitting' +fi diff --git a/tests/desktop/windows_text.cpp b/tests/desktop/windows_text.cpp new file mode 100644 index 0000000..fe962b1 --- /dev/null +++ b/tests/desktop/windows_text.cpp @@ -0,0 +1,53 @@ +// Use the native UIA client API for Unicode selection round trips. +#define NOMINMAX +#include +#include +#include +#include +#include +template struct Com { + T *value=nullptr; + ~Com(){if(value)value->Release();} + T **out(){return &value;} + T *operator->(){return value;} +}; +static void check(HRESULT result) { + if(FAILED(result))throw std::runtime_error("UIA HRESULT "+std::to_string(static_cast(result))); +} +static std::wstring content(IUIAutomationTextRange *range) { + BSTR value=nullptr;check(range->GetText(-1,&value)); + std::wstring result(value?value:L"",value?SysStringLen(value):0);SysFreeString(value);return result; +} +int main(int argc,char **argv) { + if(argc!=2)return 2; + check(CoInitializeEx(nullptr,COINIT_MULTITHREADED)); + int result=0; + try { + HWND window=reinterpret_cast(std::stoull(argv[1])); + Com automation;check(CoCreateInstance(__uuidof(CUIAutomation),nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(automation.out()))); + Com root;check(automation->ElementFromHandle(window,root.out())); + VARIANT name;VariantInit(&name);name.vt=VT_BSTR;name.bstrVal=SysAllocString(L"Name"); + Com condition;check(automation->CreatePropertyCondition(UIA_NamePropertyId,name,condition.out()));VariantClear(&name); + Com field;check(root->FindFirst(TreeScope_Descendants,condition.value,field.out())); + if(!field.value)throw std::runtime_error("Missing Name text field"); + Com text;check(field->GetCurrentPatternAs(UIA_TextPatternId,IID_PPV_ARGS(text.out()))); + Com document;check(text->get_DocumentRange(document.out())); + if(content(document.value)!=L"A\xd83d\xdcf7" L"e\x0301Z")throw std::runtime_error("Unicode document text mismatch"); + Com range;check(document->Clone(range.out())); + check(range->MoveEndpointByRange(TextPatternRangeEndpoint_End,range.value,TextPatternRangeEndpoint_Start)); + int moved=0;check(range->MoveEndpointByUnit(TextPatternRangeEndpoint_Start,TextUnit_Character,1,&moved)); + check(range->MoveEndpointByUnit(TextPatternRangeEndpoint_End,TextUnit_Character,1,&moved)); + if(content(range.value)!=L"\xd83d\xdcf7")throw std::runtime_error("Unicode moved range mismatch"); + check(range->Select()); + bool matched=false; + for(int attempt=0;attempt<300&&!matched;++attempt) { + Com selection;check(text->GetSelection(selection.out())); + int count=0;check(selection->get_Length(&count)); + if(count==1){Com selected;check(selection->GetElement(0,selected.out()));matched=content(selected.value)==L"\xd83d\xdcf7";} + if(!matched)Sleep(100); + } + if(!matched)throw std::runtime_error("Unicode selection did not round-trip"); + std::puts("PASS: native UIA Unicode document, moved range and selected text"); + }catch(const std::exception &error){std::fprintf(stderr,"%s\n",error.what());result=1;} + CoUninitialize();return result; +}