From 94151df112fed5196dd253cb1adf35d19504920f Mon Sep 17 00:00:00 2001 From: GGRei Date: Thu, 11 Jun 2026 16:10:52 +0200 Subject: [PATCH 1/6] windows: add native Windows support --- .github/workflows/ci.yml | 121 +++++- .github/workflows/windows-native-smoke.yml | 426 +++++++++++++++++++++ README.md | 17 +- _native_dialog_test.v | 115 +++++- _native_print_test.v | 123 +++++- _windows_preflight.vsh | 185 +++++++++ _windows_setup_preflight_test.v | 32 ++ a11y.v | 7 +- docs/GET_STARTED.md | 12 + docs/NATIVE_DIALOGS.md | 16 +- docs/PRINTING.md | 27 +- docs/ROADMAP.md | 6 +- docs/WINDOWS.md | 130 +++++++ docs/WINDOWS_MANUAL_SMOKE.md | 93 +++++ native_dialog_backend.v | 60 +++ native_notification_backend.v | 1 + native_print_backend.v | 65 +++- nativebridge/_bridge_ex_test.v | 26 ++ nativebridge/_readback_abi_test.v | 133 +++++++ nativebridge/c_bindings.v | 62 ++- nativebridge/dialog_windows.c | 329 ++++++++++++++-- nativebridge/notification_windows.c | 191 ++++++++- nativebridge/readback_bridge.h | 17 +- nativebridge/readback_d3d11_warp_test.h | 229 +++++++++++ nativebridge/readback_linux.c | 4 + nativebridge/readback_macos.m | 4 + nativebridge/readback_windows.c | 77 +++- window.v | 6 +- windows_setup_preflight.v | 7 + winsetup/preflight.v | 101 +++++ winsetup/preflight_test.v | 96 +++++ 31 files changed, 2616 insertions(+), 102 deletions(-) create mode 100644 .github/workflows/windows-native-smoke.yml create mode 100644 _windows_preflight.vsh create mode 100644 _windows_setup_preflight_test.v create mode 100644 docs/WINDOWS.md create mode 100644 docs/WINDOWS_MANUAL_SMOKE.md create mode 100644 nativebridge/_readback_abi_test.v create mode 100644 nativebridge/readback_d3d11_warp_test.h create mode 100644 windows_setup_preflight.v create mode 100644 winsetup/preflight.v create mode 100644 winsetup/preflight_test.v diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23bcbe8..ca48f10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,14 +5,22 @@ on: paths: - "**.v" - "**.vsh" + - "**.c" + - "**.h" + - "**.m" - "**.md" - "**/ci.yml" + - ".github/workflows/*.yml" pull_request: paths: - "**.v" - "**.vsh" + - "**.c" + - "**.h" + - "**.m" - "**.md" - "**/ci.yml" + - ".github/workflows/*.yml" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} @@ -57,7 +65,7 @@ jobs: runs-on: ${{ matrix.os }} timeout-minutes: 25 env: - VFLAGS: -no-parallel -cc clang + VFLAGS: -no-parallel steps: - name: Install V id: install-v @@ -77,34 +85,141 @@ jobs: with: path: gui - name: Run tests + env: + VFLAGS: -no-parallel -cc clang run: v test gui/ - name: Check compilation of examples + env: + VFLAGS: -no-parallel -cc clang run: v should-compile-all gui/examples/ - name: Check compilation of examples with -W + env: + VFLAGS: -no-parallel -cc clang run: v gui/examples/_build.vsh compiling-on-windows: runs-on: windows-latest timeout-minutes: 25 env: - VFLAGS: -no-parallel -cc msvc + VFLAGS: -no-parallel + VCPKG_BINARY_CACHE: ${{ github.workspace }}\vcpkg-binary-cache + VCPKG_BINARY_SOURCES: clear;files,${{ github.workspace }}\vcpkg-binary-cache,readwrite steps: - name: Install V id: install-v uses: vlang/setup-v@v1.4 with: check-latest: true + - name: Restore vcpkg binary cache + uses: actions/cache@v4 + with: + path: ${{ env.VCPKG_BINARY_CACHE }} + key: windows-vcpkg-x64-windows-pango-freetype-v1 + restore-keys: | + windows-vcpkg-x64-windows-pango-freetype- + - name: Restore V module cache + uses: actions/cache@v4 + with: + path: ~/.vmodules/vglyph + key: windows-vmodules-vglyph-v1 + restore-keys: | + windows-vmodules-vglyph- - name: Install pango and freetype - run: vcpkg install pango freetype + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path "$env:VCPKG_BINARY_CACHE" | Out-Null + vcpkg install pango freetype + - name: Expose vcpkg pkg-config paths + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $triplet = 'x64-windows' + $vcpkgExe = (Get-Command vcpkg -ErrorAction Stop).Source + $roots = @() + if ($vcpkgExe) { + $roots += Split-Path -Parent $vcpkgExe + } + if ($env:VCPKG_ROOT) { + $roots += $env:VCPKG_ROOT + } + $vcpkgRoot = $null + foreach ($root in ($roots | Select-Object -Unique)) { + if (Test-Path (Join-Path $root "installed\$triplet")) { + $vcpkgRoot = $root + break + } + } + if (-not $vcpkgRoot) { + throw "Could not find vcpkg installed\$triplet from vcpkg.exe or VCPKG_ROOT" + } + $tripletRoot = Join-Path $vcpkgRoot "installed\$triplet" + $pkgConfigDirs = @( + (Join-Path $tripletRoot 'lib\pkgconfig'), + (Join-Path $tripletRoot 'share\pkgconfig') + ) | Where-Object { Test-Path $_ } + if ($pkgConfigDirs.Count -eq 0) { + throw "No pkgconfig directories found under $tripletRoot" + } + $pkgConfigPath = $pkgConfigDirs -join ';' + "PKG_CONFIG_PATH=$pkgConfigPath" | Add-Content -Path $env:GITHUB_ENV + $env:PKG_CONFIG_PATH = $pkgConfigPath + + $pathEntries = @() + $binDir = Join-Path $tripletRoot 'bin' + if (Test-Path $binDir) { + $pathEntries += $binDir + } + $pkgconfDir = Join-Path $tripletRoot 'tools\pkgconf' + if (Test-Path $pkgconfDir) { + $pathEntries += $pkgconfDir + } + foreach ($entry in $pathEntries) { + $entry | Add-Content -Path $env:GITHUB_PATH + } + if ($pathEntries.Count -gt 0) { + $env:PATH = ($pathEntries + $env:PATH) -join ';' + } + + if (-not (Get-Command pkg-config -ErrorAction SilentlyContinue)) { + $pkgconf = Get-Command pkgconf -ErrorAction SilentlyContinue + if ($pkgconf) { + $shimDir = Join-Path $env:RUNNER_TEMP 'pkg-config-shim' + New-Item -ItemType Directory -Force -Path $shimDir | Out-Null + Set-Content -Path (Join-Path $shimDir 'pkg-config.cmd') -Encoding ascii -Value @( + '@echo off', + '"' + $pkgconf.Source + '" %*' + ) + $shimDir | Add-Content -Path $env:GITHUB_PATH + $env:PATH = "$shimDir;$env:PATH" + } + } + + $pkgConfig = Get-Command pkg-config -ErrorAction Stop + & $pkgConfig.Source --exists freetype2 pango pangoft2 + if ($LASTEXITCODE -ne 0) { + throw "pkg-config could not resolve freetype2 pango pangoft2" + } - name: Install vglyph run: v install vglyph - name: Checkout the gui module uses: actions/checkout@v4 with: path: gui + - name: Configure MSVC developer environment + uses: ilammy/msvc-dev-cmd@v1 + - name: Run Windows setup preflight + env: + GUI_WINDOWS_PREFLIGHT_CC: msvc + run: v -cc msvc run gui/_windows_preflight.vsh - name: Run tests + env: + VFLAGS: -no-parallel -cc msvc run: v test gui/ - name: Check compilation of examples + env: + VFLAGS: -no-parallel -cc msvc run: v should-compile-all gui/examples/ - name: Check compilation of examples with -W + env: + VFLAGS: -no-parallel -cc msvc run: v gui/examples/_build.vsh diff --git a/.github/workflows/windows-native-smoke.yml b/.github/workflows/windows-native-smoke.yml new file mode 100644 index 0000000..93225ad --- /dev/null +++ b/.github/workflows/windows-native-smoke.yml @@ -0,0 +1,426 @@ +name: Windows Native Smoke + +on: + workflow_dispatch: + inputs: + prewarm_deps_only: + description: "Install/cache MSVC vcpkg/vglyph dependencies only; skip tests and examples" + type: boolean + default: false + run_clang: + description: "Also run exploratory Clang smoke; may rebuild non-MSVC vcpkg ABI" + type: boolean + default: false + run_mingw: + description: "Also run exploratory MinGW/MSYS2 smoke" + type: boolean + default: false + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +env: + V_VERSION: 0.5.1 + VCPKG_BINARY_CACHE: ${{ github.workspace }}\vcpkg-binary-cache + VCPKG_BINARY_SOURCES: clear;files,${{ github.workspace }}\vcpkg-binary-cache,readwrite + +jobs: + prewarm-msvc-deps: + name: Prewarm MSVC/vcpkg deps + runs-on: windows-latest + timeout-minutes: 45 + if: ${{ inputs.prewarm_deps_only }} + steps: + - name: Install V + shell: pwsh + run: | + $ProgressPreference = 'SilentlyContinue' + $asset = 'v_windows.zip' + $url = "https://github.com/vlang/v/releases/download/$env:V_VERSION/$asset" + Invoke-WebRequest -Uri $url -OutFile $asset + Expand-Archive -Path $asset -DestinationPath . -Force + $vPath = (Resolve-Path .\v).Path + Add-Content -Path $env:GITHUB_PATH -Value $vPath + - name: Verify V version + shell: pwsh + run: | + $vVersion = v version + Write-Host $vVersion + if ($vVersion -notlike "*$env:V_VERSION*") { + throw "Expected V version $env:V_VERSION" + } + - name: Restore vcpkg binary cache + uses: actions/cache@v4 + with: + path: ${{ env.VCPKG_BINARY_CACHE }} + key: windows-vcpkg-x64-windows-pango-freetype-${{ env.V_VERSION }}-v1 + restore-keys: | + windows-vcpkg-x64-windows-pango-freetype- + - name: Restore V module cache + uses: actions/cache@v4 + with: + path: ~/.vmodules/vglyph + key: windows-vmodules-vglyph-${{ env.V_VERSION }}-v1 + restore-keys: | + windows-vmodules-vglyph- + - name: Install pango and freetype + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path "$env:VCPKG_BINARY_CACHE" | Out-Null + vcpkg install pango freetype + - name: Expose vcpkg pkg-config paths + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $triplet = 'x64-windows' + $vcpkgExe = (Get-Command vcpkg -ErrorAction Stop).Source + $roots = @() + if ($vcpkgExe) { + $roots += Split-Path -Parent $vcpkgExe + } + if ($env:VCPKG_ROOT) { + $roots += $env:VCPKG_ROOT + } + $vcpkgRoot = $null + foreach ($root in ($roots | Select-Object -Unique)) { + if (Test-Path (Join-Path $root "installed\$triplet")) { + $vcpkgRoot = $root + break + } + } + if (-not $vcpkgRoot) { + throw "Could not find vcpkg installed\$triplet from vcpkg.exe or VCPKG_ROOT" + } + $tripletRoot = Join-Path $vcpkgRoot "installed\$triplet" + $pkgConfigDirs = @( + (Join-Path $tripletRoot 'lib\pkgconfig'), + (Join-Path $tripletRoot 'share\pkgconfig') + ) | Where-Object { Test-Path $_ } + if ($pkgConfigDirs.Count -eq 0) { + throw "No pkgconfig directories found under $tripletRoot" + } + $pkgConfigPath = $pkgConfigDirs -join ';' + "PKG_CONFIG_PATH=$pkgConfigPath" | Add-Content -Path $env:GITHUB_ENV + $env:PKG_CONFIG_PATH = $pkgConfigPath + + $pathEntries = @() + $binDir = Join-Path $tripletRoot 'bin' + if (Test-Path $binDir) { + $pathEntries += $binDir + } + $pkgconfDir = Join-Path $tripletRoot 'tools\pkgconf' + if (Test-Path $pkgconfDir) { + $pathEntries += $pkgconfDir + } + foreach ($entry in $pathEntries) { + $entry | Add-Content -Path $env:GITHUB_PATH + } + if ($pathEntries.Count -gt 0) { + $env:PATH = ($pathEntries + $env:PATH) -join ';' + } + + if (-not (Get-Command pkg-config -ErrorAction SilentlyContinue)) { + $pkgconf = Get-Command pkgconf -ErrorAction SilentlyContinue + if ($pkgconf) { + $shimDir = Join-Path $env:RUNNER_TEMP 'pkg-config-shim' + New-Item -ItemType Directory -Force -Path $shimDir | Out-Null + Set-Content -Path (Join-Path $shimDir 'pkg-config.cmd') -Encoding ascii -Value @( + '@echo off', + '"' + $pkgconf.Source + '" %*' + ) + $shimDir | Add-Content -Path $env:GITHUB_PATH + $env:PATH = "$shimDir;$env:PATH" + } + } + + $pkgConfig = Get-Command pkg-config -ErrorAction Stop + & $pkgConfig.Source --exists freetype2 pango pangoft2 + if ($LASTEXITCODE -ne 0) { + throw "pkg-config could not resolve freetype2 pango pangoft2" + } + - name: Install vglyph + run: v install vglyph + + msvc: + name: MSVC native smoke + runs-on: windows-latest + timeout-minutes: 25 + if: ${{ !inputs.prewarm_deps_only }} + steps: + - name: Install V + shell: pwsh + run: | + $ProgressPreference = 'SilentlyContinue' + $asset = 'v_windows.zip' + $url = "https://github.com/vlang/v/releases/download/$env:V_VERSION/$asset" + Invoke-WebRequest -Uri $url -OutFile $asset + Expand-Archive -Path $asset -DestinationPath . -Force + $vPath = (Resolve-Path .\v).Path + Add-Content -Path $env:GITHUB_PATH -Value $vPath + - name: Verify V version + shell: pwsh + run: | + $vVersion = v version + Write-Host $vVersion + if ($vVersion -notlike "*$env:V_VERSION*") { + throw "Expected V version $env:V_VERSION" + } + - name: Restore vcpkg binary cache + uses: actions/cache@v4 + with: + path: ${{ env.VCPKG_BINARY_CACHE }} + key: windows-vcpkg-x64-windows-pango-freetype-${{ env.V_VERSION }}-v1 + restore-keys: | + windows-vcpkg-x64-windows-pango-freetype- + - name: Restore V module cache + uses: actions/cache@v4 + with: + path: ~/.vmodules/vglyph + key: windows-vmodules-vglyph-${{ env.V_VERSION }}-v1 + restore-keys: | + windows-vmodules-vglyph- + - name: Install pango and freetype + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path "$env:VCPKG_BINARY_CACHE" | Out-Null + vcpkg install pango freetype + - name: Expose vcpkg pkg-config paths + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $triplet = 'x64-windows' + $vcpkgExe = (Get-Command vcpkg -ErrorAction Stop).Source + $roots = @() + if ($vcpkgExe) { + $roots += Split-Path -Parent $vcpkgExe + } + if ($env:VCPKG_ROOT) { + $roots += $env:VCPKG_ROOT + } + $vcpkgRoot = $null + foreach ($root in ($roots | Select-Object -Unique)) { + if (Test-Path (Join-Path $root "installed\$triplet")) { + $vcpkgRoot = $root + break + } + } + if (-not $vcpkgRoot) { + throw "Could not find vcpkg installed\$triplet from vcpkg.exe or VCPKG_ROOT" + } + $tripletRoot = Join-Path $vcpkgRoot "installed\$triplet" + $pkgConfigDirs = @( + (Join-Path $tripletRoot 'lib\pkgconfig'), + (Join-Path $tripletRoot 'share\pkgconfig') + ) | Where-Object { Test-Path $_ } + if ($pkgConfigDirs.Count -eq 0) { + throw "No pkgconfig directories found under $tripletRoot" + } + $pkgConfigPath = $pkgConfigDirs -join ';' + "PKG_CONFIG_PATH=$pkgConfigPath" | Add-Content -Path $env:GITHUB_ENV + $env:PKG_CONFIG_PATH = $pkgConfigPath + + $pathEntries = @() + $binDir = Join-Path $tripletRoot 'bin' + if (Test-Path $binDir) { + $pathEntries += $binDir + } + $pkgconfDir = Join-Path $tripletRoot 'tools\pkgconf' + if (Test-Path $pkgconfDir) { + $pathEntries += $pkgconfDir + } + foreach ($entry in $pathEntries) { + $entry | Add-Content -Path $env:GITHUB_PATH + } + if ($pathEntries.Count -gt 0) { + $env:PATH = ($pathEntries + $env:PATH) -join ';' + } + + if (-not (Get-Command pkg-config -ErrorAction SilentlyContinue)) { + $pkgconf = Get-Command pkgconf -ErrorAction SilentlyContinue + if ($pkgconf) { + $shimDir = Join-Path $env:RUNNER_TEMP 'pkg-config-shim' + New-Item -ItemType Directory -Force -Path $shimDir | Out-Null + Set-Content -Path (Join-Path $shimDir 'pkg-config.cmd') -Encoding ascii -Value @( + '@echo off', + '"' + $pkgconf.Source + '" %*' + ) + $shimDir | Add-Content -Path $env:GITHUB_PATH + $env:PATH = "$shimDir;$env:PATH" + } + } + + $pkgConfig = Get-Command pkg-config -ErrorAction Stop + & $pkgConfig.Source --exists freetype2 pango pangoft2 + if ($LASTEXITCODE -ne 0) { + throw "pkg-config could not resolve freetype2 pango pangoft2" + } + - name: Install vglyph + run: v install vglyph + - name: Checkout gui + uses: actions/checkout@v4 + with: + path: gui + - name: Configure MSVC developer environment + uses: ilammy/msvc-dev-cmd@v1 + - name: Run Windows setup preflight + env: + GUI_WINDOWS_PREFLIGHT_CC: msvc + run: v run gui/_windows_preflight.vsh + - name: Run noninteractive native tests + run: > + v -no-parallel -cc msvc test + gui/_native_dialog_test.v + gui/_native_print_test.v + gui/_native_notification_test.v + gui/nativebridge/_bridge_ex_test.v + gui/nativebridge/_readback_abi_test.v + - name: Compile native smoke examples + run: | + v -no-parallel -cc msvc -W -o dialogs_smoke.exe gui/examples/dialogs.v + v -no-parallel -cc msvc -W -o printing_smoke.exe gui/examples/printing.v + v -no-parallel -cc msvc -W -o notification_smoke.exe gui/examples/native_notification.v + + clang: + name: Clang native smoke (exploratory) + runs-on: windows-latest + timeout-minutes: 25 + continue-on-error: true + if: ${{ !inputs.prewarm_deps_only && inputs.run_clang }} + steps: + - name: Install V + shell: pwsh + run: | + $ProgressPreference = 'SilentlyContinue' + $asset = 'v_windows.zip' + $url = "https://github.com/vlang/v/releases/download/$env:V_VERSION/$asset" + Invoke-WebRequest -Uri $url -OutFile $asset + Expand-Archive -Path $asset -DestinationPath . -Force + $vPath = (Resolve-Path .\v).Path + Add-Content -Path $env:GITHUB_PATH -Value $vPath + - name: Verify V version + shell: pwsh + run: | + $vVersion = v version + Write-Host $vVersion + if ($vVersion -notlike "*$env:V_VERSION*") { + throw "Expected V version $env:V_VERSION" + } + - name: Restore vcpkg binary cache + uses: actions/cache@v4 + with: + path: ${{ env.VCPKG_BINARY_CACHE }} + key: windows-vcpkg-x64-windows-pango-freetype-${{ env.V_VERSION }}-v1 + restore-keys: | + windows-vcpkg-x64-windows-pango-freetype- + - name: Restore V module cache + uses: actions/cache@v4 + with: + path: ~/.vmodules/vglyph + key: windows-vmodules-vglyph-${{ env.V_VERSION }}-v1 + restore-keys: | + windows-vmodules-vglyph- + - name: Install pango and freetype + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path "$env:VCPKG_BINARY_CACHE" | Out-Null + vcpkg install pango freetype + - name: Install vglyph + run: v install vglyph + - name: Checkout gui + uses: actions/checkout@v4 + with: + path: gui + - name: Run Windows setup preflight + env: + GUI_WINDOWS_PREFLIGHT_CC: clang + run: v run gui/_windows_preflight.vsh + - name: Run noninteractive native tests + run: > + v -no-parallel -cc clang test + gui/_native_dialog_test.v + gui/_native_print_test.v + gui/_native_notification_test.v + gui/nativebridge/_bridge_ex_test.v + gui/nativebridge/_readback_abi_test.v + - name: Compile native smoke examples + run: | + v -no-parallel -cc clang -W -o dialogs_smoke.exe gui/examples/dialogs.v + v -no-parallel -cc clang -W -o printing_smoke.exe gui/examples/printing.v + v -no-parallel -cc clang -W -o notification_smoke.exe gui/examples/native_notification.v + + mingw: + name: MinGW native smoke (exploratory) + runs-on: windows-latest + timeout-minutes: 30 + continue-on-error: true + if: ${{ !inputs.prewarm_deps_only && inputs.run_mingw }} + steps: + - name: Install V + shell: pwsh + run: | + $ProgressPreference = 'SilentlyContinue' + $asset = 'v_windows.zip' + $url = "https://github.com/vlang/v/releases/download/$env:V_VERSION/$asset" + Invoke-WebRequest -Uri $url -OutFile $asset + Expand-Archive -Path $asset -DestinationPath . -Force + $vPath = (Resolve-Path .\v).Path + Add-Content -Path $env:GITHUB_PATH -Value $vPath + - name: Verify V version + shell: pwsh + run: | + $vVersion = v version + Write-Host $vVersion + if ($vVersion -notlike "*$env:V_VERSION*") { + throw "Expected V version $env:V_VERSION" + } + - name: Install MSYS2 MinGW dependencies + uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + update: true + path-type: inherit + install: >- + mingw-w64-x86_64-gcc + mingw-w64-x86_64-pkgconf + mingw-w64-x86_64-pango + mingw-w64-x86_64-freetype + - name: Verify V version in MSYS2 + shell: msys2 {0} + run: | + v_version="$(v version)" + echo "$v_version" + case "$v_version" in + *"$V_VERSION"*) ;; + *) + echo "Expected V version $V_VERSION" + exit 1 + ;; + esac + - name: Install vglyph + shell: msys2 {0} + run: v install vglyph + - name: Checkout gui + uses: actions/checkout@v4 + with: + path: gui + - name: Run Windows setup preflight + shell: msys2 {0} + env: + GUI_WINDOWS_PREFLIGHT_CC: gcc + run: v run gui/_windows_preflight.vsh + - name: Run noninteractive native tests + shell: msys2 {0} + run: > + v -no-parallel -cc gcc test + gui/_native_dialog_test.v + gui/_native_print_test.v + gui/_native_notification_test.v + gui/nativebridge/_bridge_ex_test.v + gui/nativebridge/_readback_abi_test.v + - name: Compile native smoke examples + shell: msys2 {0} + run: | + v -no-parallel -cc gcc -W -o dialogs_smoke.exe gui/examples/dialogs.v + v -no-parallel -cc gcc -W -o printing_smoke.exe gui/examples/printing.v + v -no-parallel -cc gcc -W -o notification_smoke.exe gui/examples/native_notification.v diff --git a/README.md b/README.md index 8a5ae73..b59da23 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,10 @@ **An immediate-mode UI framework for V that stays out of your way.** -> Note: I don't own a Windows computer and cannot help with Windows issues. -Windows may be supported in the future, it is not supported now. Some -users have successfully made this framework run on Windows. If you choose -to go down that path, I suggest joining the V community on Discord where -people way more smarter than me can help you. - mrw +> Windows native support is under active validation. The current documented path +uses native Windows with MSVC/vcpkg and is tracked in +[`docs/WINDOWS.md`](docs/WINDOWS.md); this is not yet a claim of full Windows +parity. ![showcase](assets/showcase.png) @@ -54,7 +53,7 @@ V is a simple language. It deserves a simple UI framework. - Dropdowns, listboxes, tables, data grids, trees, markdown - Menus, menubars, tabs, splitters, dialogs - Progress bars, tooltips, date pickers -- Native open/save/folder dialogs (macOS + Linux) +- Native open/save/folder dialogs (macOS + Linux; Windows under validation) **Rendering** - SDF-based drop shadows @@ -151,7 +150,9 @@ v install gui | [FORMS.md](docs/FORMS.md) | Form runtime, validation model, and field adapters | | [GRADIENTS.md](docs/GRADIENTS.md) | Linear and radial gradients | | [SHADERS.md](docs/SHADERS.md) | Custom fragment shaders | -| [PRINTING.md](docs/PRINTING.md) | PDF export and native print dialog | +| [PRINTING.md](docs/PRINTING.md) | PDF export and platform print flow | +| [WINDOWS.md](docs/WINDOWS.md) | Windows setup and validation notes, not a final support claim | +| [WINDOWS_MANUAL_SMOKE.md](docs/WINDOWS_MANUAL_SMOKE.md) | Manual Windows smoke checklist | | [SPLITTER.md](docs/SPLITTER.md) | Splitter component (drag, collapse, keyboard) | Generate API docs with: @@ -171,7 +172,7 @@ v run examples/tiger.v # SVG rendering demo v run examples/markdown.v # Markdown rendering v run examples/dialogs.v # Custom + native dialogs v run examples/split_panel.v # Splitter + nested splitter -v run examples/printing.v # PDF export + native print +v run examples/printing.v # PDF export + platform print flow v run examples/input_masks.v # Input mask presets and custom tokens v run examples/numeric_input.v # Locale-aware numeric input + step controls v run examples/form_validation.v # Form validation with sync+async validators diff --git a/_native_dialog_test.v b/_native_dialog_test.v index 9c9f8bc..b52b4d4 100644 --- a/_native_dialog_test.v +++ b/_native_dialog_test.v @@ -35,7 +35,120 @@ fn test_native_save_extensions_appends_default_once() { assert extensions == ['jpg', 'png'] } -$if !(macos || linux) { +fn test_native_filter_specs_from_filters_preserves_names_and_groups() { + specs := native_filter_specs_from_filters([ + NativeFileFilter{ + name: 'Images: raster, raw' + extensions: ['.PNG', ' jpg ', 'png'] + }, + NativeFileFilter{ + name: 'Docs' + extensions: ['txt', 'md'] + }, + ], '') or { panic(err.msg()) } + + assert specs == native_dialog_filter_spec_prefix + + '19:Images: raster, raw7:png,jpg4:Docs6:txt,md' +} + +fn test_native_filter_specs_from_filters_uses_legacy_when_no_names() { + specs := native_filter_specs_from_filters([ + NativeFileFilter{ + extensions: ['png', 'jpg'] + }, + ], '') or { panic(err.msg()) } + + assert specs == '' +} + +fn test_native_filter_specs_from_filters_appends_save_default_when_named() { + specs := native_filter_specs_from_filters([ + NativeFileFilter{ + name: 'Images' + extensions: ['png'] + }, + ], '.txt') or { panic(err.msg()) } + + assert specs == native_dialog_filter_spec_prefix + '6:Images3:png0:3:txt' +} + +fn test_native_result_from_bridge_ex_maps_ok_paths_without_native_ui() { + mut w := Window{} + result := native_result_from_bridge_ex(nativebridge.BridgeDialogResultEx{ + status: .ok + entries: [ + nativebridge.BridgeBookmarkEntry{ + path: 'C:/tmp/example.txt' + }, + ] + }, mut w) + + assert result.status == .ok + assert result.paths.len == 1 + assert result.paths[0].path == 'C:/tmp/example.txt' + assert result.error_code == '' + assert result.error_message == '' +} + +fn test_native_result_from_bridge_ex_maps_cancel_without_native_ui() { + mut w := Window{} + result := native_result_from_bridge_ex(nativebridge.BridgeDialogResultEx{ + status: .cancel + }, mut w) + + assert result.status == .cancel + assert result.paths.len == 0 + assert result.error_code == '' + assert result.error_message == '' +} + +fn test_native_result_from_bridge_ex_maps_error_without_native_ui() { + mut w := Window{} + result := native_result_from_bridge_ex(nativebridge.BridgeDialogResultEx{ + status: .error + error_code: 'windows_dialog' + error_message: 'dialog failed' + }, mut w) + + assert result.status == .error + assert result.paths.len == 0 + assert result.error_code == 'windows_dialog' + assert result.error_message == 'dialog failed' +} + +fn test_native_alert_result_from_bridge_maps_ok_without_native_ui() { + result := native_alert_result_from_bridge(nativebridge.BridgeAlertResult{ + status: .ok + }) + + assert result.status == .ok + assert result.error_code == '' + assert result.error_message == '' +} + +fn test_native_alert_result_from_bridge_maps_cancel_without_native_ui() { + result := native_alert_result_from_bridge(nativebridge.BridgeAlertResult{ + status: .cancel + }) + + assert result.status == .cancel + assert result.error_code == '' + assert result.error_message == '' +} + +fn test_native_alert_result_from_bridge_maps_error_without_native_ui() { + result := native_alert_result_from_bridge(nativebridge.BridgeAlertResult{ + status: .error + error_code: 'message_box' + error_message: 'message box failed' + }) + + assert result.status == .error + assert result.error_code == 'message_box' + assert result.error_message == 'message box failed' +} + +$if !(macos || linux || windows) { fn test_nativebridge_stub_returns_unsupported() { open_result := nativebridge.open_dialog(nativebridge.BridgeOpenCfg{}) assert open_result.status == .error diff --git a/_native_print_test.v b/_native_print_test.v index 957f5dd..dba6331 100644 --- a/_native_print_test.v +++ b/_native_print_test.v @@ -9,7 +9,7 @@ fn test_nativebridge_print_module_loads() { } fn test_print_supported_matches_platform() { - $if macos || linux { + $if macos || linux || windows { assert print_job_supported() } $else { assert !print_job_supported() @@ -82,7 +82,126 @@ fn test_print_page_ranges_normalize_merges_overlaps() { assert ranges[1].to == 8 } -$if !(macos || linux) { +fn test_windows_shell_execute_warnings_keep_current_view_export_options_quiet() { + warnings := print_windows_shell_execute_warnings(PrintJob{ + paper: .letter + orientation: .landscape + margins: PrintMargins{ + top: 12 + right: 13 + bottom: 14 + left: 15 + } + scale_mode: .actual_size + }) + assert warnings.len == 0 +} + +fn test_windows_shell_execute_warnings_include_pdf_path_shape_options() { + warnings := print_windows_shell_execute_warnings(PrintJob{ + paper: .letter + orientation: .landscape + margins: PrintMargins{ + top: 12 + right: 13 + bottom: 14 + left: 15 + } + scale_mode: .actual_size + source: PrintJobSource{ + kind: .pdf_path + pdf_path: 'existing.pdf' + } + }) + joined := warnings.join('\n') + assert joined.contains('paper size') + assert joined.contains('orientation') + assert joined.contains('margins') + assert joined.contains('scale mode') +} + +fn test_windows_shell_execute_warnings_include_pdf_path_title() { + warnings := print_windows_shell_execute_warnings(PrintJob{ + title: 'Quarterly Report' + source: PrintJobSource{ + kind: .pdf_path + pdf_path: 'existing.pdf' + } + }) + assert warnings.join('\n').contains('title') +} + +fn test_windows_shell_execute_warnings_include_job_name_for_current_view() { + warnings := print_windows_shell_execute_warnings(PrintJob{ + job_name: 'Native Job Name' + }) + assert warnings.join('\n').contains('job name') +} + +fn test_windows_shell_execute_warnings_include_print_options() { + warnings := print_windows_shell_execute_warnings(PrintJob{ + copies: 2 + page_ranges: [PrintPageRange{ from: 1, to: 2 }] + duplex: .long_edge + color_mode: .grayscale + }) + joined := warnings.join('\n') + assert joined.contains('copies') + assert joined.contains('page ranges') + assert joined.contains('duplex mode') + assert joined.contains('color mode') +} + +fn test_print_bridge_conversion_keeps_extra_warnings() { + result := print_run_result_from_bridge_with_warnings(nativebridge.BridgePrintResult{ + status: .ok + warnings: ['bridge warning'] + }, 'out.pdf', ['windows warning']) + assert result.status == .ok + assert result.warnings.len == 2 + assert result.warnings[0].code == 'unsupported_option' + assert result.warnings[0].message == 'bridge warning' + assert result.warnings[1].message == 'windows warning' +} + +fn test_print_bridge_conversion_filters_empty_warnings() { + result := print_run_result_from_bridge_with_warnings(nativebridge.BridgePrintResult{ + status: .ok + warnings: ['', ' bridge warning '] + }, 'out.pdf', [' ', 'windows warning']) + assert result.status == .ok + assert result.warnings.len == 2 + assert result.warnings[0].message == ' bridge warning ' + assert result.warnings[1].message == 'windows warning' +} + +fn test_print_bridge_conversion_keeps_cancel_warnings() { + result := print_run_result_from_bridge_with_warnings(nativebridge.BridgePrintResult{ + status: .cancel + warnings: ['bridge warning'] + }, 'out.pdf', ['windows warning']) + assert result.status == .cancel + assert result.warnings.len == 2 + assert result.warnings[0].message == 'bridge warning' + assert result.warnings[1].message == 'windows warning' +} + +fn test_print_bridge_conversion_keeps_error_warnings() { + result := print_run_result_from_bridge_with_warnings(nativebridge.BridgePrintResult{ + status: .error + error_code: 'print_failed' + error_message: 'handler unavailable' + warnings: ['bridge warning'] + }, 'out.pdf', ['windows warning']) + assert result.status == .error + assert result.error_code == 'print_failed' + assert result.error_message == 'handler unavailable' + assert result.warnings.len == 2 + assert result.warnings[0].message == 'bridge warning' + assert result.warnings[1].message == 'windows warning' +} + +$if !(macos || linux || windows) { fn test_nativebridge_print_stub_returns_unsupported() { result := nativebridge.print_pdf_dialog(nativebridge.BridgePrintCfg{}) assert result.status == .error diff --git a/_windows_preflight.vsh b/_windows_preflight.vsh new file mode 100644 index 0000000..28542a8 --- /dev/null +++ b/_windows_preflight.vsh @@ -0,0 +1,185 @@ +#!/usr/bin/env -S v + +import os +import time +import winsetup + +const windows_preflight_prefix = 'Failed Windows setup preflight.' +const windows_preflight_temp_prefix = 'gui_windows_preflight_' + +fn main() { + $if !windows { + eprintln('${windows_preflight_prefix}\n\nLikely cause: this command is not running on native Windows.\n\nAction: run `v run _windows_preflight.vsh` from native Windows, not WSL or Wine.\n\nDetails: ${os.user_os()} is not a Windows validation gate.') + exit(1) + } + + println('Windows setup preflight: native Windows detected.') + v_version := os.execute('v version') + if v_version.exit_code == 0 { + println(v_version.output.trim_space()) + } + + temp_dir := windows_preflight_create_temp_dir() or { + eprintln('${windows_preflight_prefix}\n\nLikely cause: unable to create a temporary probe directory.\n\nAction: check that the Windows temp directory is writable, then rerun the preflight.\n\nDetails: ${err}') + exit(1) + } + + compiler := windows_preflight_compiler() + if !windows_preflight_check_compiler(compiler) { + windows_preflight_cleanup_then_exit(temp_dir) + } + if compiler == 'msvc' && !windows_preflight_check_vcpkg_packages() { + windows_preflight_cleanup_then_exit(temp_dir) + } + if !windows_preflight_check_vglyph_import(temp_dir) { + windows_preflight_cleanup_then_exit(temp_dir) + } + if !windows_preflight_check_text_probe(compiler, temp_dir) { + windows_preflight_cleanup_then_exit(temp_dir) + } + + windows_preflight_remove_temp_dir(temp_dir) + println('Windows setup preflight passed.') +} + +fn windows_preflight_compiler() string { + raw := os.getenv('GUI_WINDOWS_PREFLIGHT_CC') + if raw == '' { + return 'msvc' + } + return raw.trim_space().to_lower() +} + +fn windows_preflight_check_compiler(compiler string) bool { + match compiler { + 'msvc' { + return + windows_preflight_run('MSVC compiler', 'where.exe cl.exe', 'cl.exe was not found; Windows SDK may be missing') + && windows_preflight_run('MSVC linker', 'where.exe link.exe', 'link.exe was not found; Windows SDK may be missing') + } + 'gcc' { + return + windows_preflight_run('MinGW GCC compiler', 'where.exe gcc.exe', 'msys2 mingw64 gcc.exe was not found') + && windows_preflight_run('MinGW pkg-config text packages', 'pkg-config --exists pango freetype2', 'msys2 mingw64 pkg-config could not find pango or freetype2') + } + 'clang' { + return windows_preflight_run('Clang compiler', 'where.exe clang.exe', + 'clang.exe was not found; MSVC/vcpkg remains the supported path') + } + else { + windows_preflight_report_failure('compiler selection', + 'unsupported compiler selection `${compiler}`. supported values are msvc, clang, gcc') + return false + } + } +} + +fn windows_preflight_check_vcpkg_packages() bool { + if !windows_preflight_run('vcpkg command', 'where.exe vcpkg.exe', 'vcpkg was not found') { + return false + } + result := os.execute('vcpkg list') + if result.exit_code != 0 { + windows_preflight_report_failure('vcpkg package list', + 'vcpkg list failed\n${result.output}') + return false + } + lower := result.output.to_lower() + mut missing := []string{} + if !lower.contains('pango') { + missing << 'pango' + } + if !lower.contains('freetype') { + missing << 'freetype' + } + if missing.len > 0 { + windows_preflight_report_failure('vcpkg packages', + 'vcpkg missing required packages: ${missing.join(', ')}') + return false + } + println('vcpkg packages ... ok') + return true +} + +fn windows_preflight_check_vglyph_import(temp_dir string) bool { + probe_path := windows_preflight_probe_path(temp_dir, 'vglyph_import.v') + os.write_file(probe_path, 'import vglyph\n\nfn main() {\n\t_ := vglyph.TextConfig{}\n}\n') or { + eprintln('failed to write temporary preflight probe: ${err}') + return false + } + return windows_preflight_run('vglyph import', + 'v -check ${windows_preflight_quote(probe_path)}', 'module vglyph not found') +} + +fn windows_preflight_check_text_probe(compiler string, temp_dir string) bool { + probe_path := windows_preflight_probe_path(temp_dir, 'text_stack_probe.v') + exe_path := windows_preflight_probe_path(temp_dir, 'text_stack_probe.exe') + os.write_file(probe_path, 'import vglyph\n\nfn main() {\n\t_ := vglyph.TextConfig{}\n}\n') or { + eprintln('failed to write temporary preflight probe: ${err}') + return false + } + if !windows_preflight_run('Pango/Freetype compile probe', + 'v -no-parallel -cc ${compiler} -o ${windows_preflight_quote(exe_path)} ${windows_preflight_quote(probe_path)}', + 'Pango/Freetype compile probe failed') { + return false + } + return windows_preflight_run('Pango/Freetype startup probe', windows_preflight_quote(exe_path), + 'Pango/Freetype startup probe failed') +} + +fn windows_preflight_run(label string, cmd string, failure_hint string) bool { + print('${label} ... ') + result := os.execute(cmd) + if result.exit_code == 0 { + println('ok') + return true + } + println('failed') + windows_preflight_report_failure(label, '${failure_hint}\n${result.output}') + return false +} + +fn windows_preflight_report_failure(label string, raw_error string) { + eprintln('\n${label} failed:') + eprintln(winsetup.script_message(raw_error)) +} + +fn windows_preflight_cleanup_then_exit(temp_dir string) { + windows_preflight_remove_temp_dir(temp_dir) + exit(1) +} + +fn windows_preflight_create_temp_dir() !string { + base := os.temp_dir() + pid := os.getpid() + for attempt in 0 .. 20 { + name := '${windows_preflight_temp_prefix}${pid}_${time.now().unix_micro()}_${attempt}' + path := os.join_path(base, name) + os.mkdir(path) or { continue } + return path + } + return error('could not create ${windows_preflight_temp_prefix}* under ${base}') +} + +fn windows_preflight_remove_temp_dir(path string) { + if !windows_preflight_is_own_temp_dir(path) { + eprintln('not removing unexpected preflight temp path: ${path}') + return + } + os.rmdir_all(path) or { eprintln('failed to remove preflight temp directory ${path}: ${err}') } +} + +fn windows_preflight_is_own_temp_dir(path string) bool { + if path == '' || !os.exists(path) || !os.is_dir(path) { + return false + } + return os.dir(path) == os.temp_dir() && os.base(path).starts_with(windows_preflight_temp_prefix) +} + +fn windows_preflight_probe_path(temp_dir string, name string) string { + return os.join_path(temp_dir, name) +} + +fn windows_preflight_quote(path string) string { + return '"${path.replace('"', '\\"')}"' +} diff --git a/_windows_setup_preflight_test.v b/_windows_setup_preflight_test.v new file mode 100644 index 0000000..31415ea --- /dev/null +++ b/_windows_setup_preflight_test.v @@ -0,0 +1,32 @@ +module gui + +import os +import winsetup + +fn test_windows_text_system_wrapper_uses_shared_winsetup_message() { + raw_error := 'builder error: module vglyph not found' + + assert windows_text_system_setup_message(raw_error) == winsetup.text_system_message(raw_error) +} + +fn test_windows_text_system_wrapper_keeps_msvc_actionable_message() { + message := windows_text_system_setup_message('cl.exe was not found; Windows SDK is missing') + + assert message.contains('Failed to initialize text rendering system on Windows.') + assert message.contains('x64 Developer PowerShell') + assert message.contains('x64 Native Tools shell') +} + +fn test_windows_preflight_script_imports_winsetup_source_of_truth() { + script := os.read_file('_windows_preflight.vsh') or { panic(err) } + + assert script.contains('import winsetup') + assert script.contains('winsetup.script_message') + assert !script.contains('windows_setup_preflight.v') + assert !script.contains('windows_script_setup_message') + assert !script.contains('windows_preflight_shared_actionable_message') + assert !script.contains('Pango headers are missing') + assert !script.contains('Freetype headers are missing') + assert !script.contains('MSYS2/GCC is exploratory') + assert !script.contains('Pango/Freetype/HarfBuzz/FriBidi/Fontconfig runtime DLL') +} diff --git a/a11y.v b/a11y.v index 4d49c8c..c6bd37e 100644 --- a/a11y.v +++ b/a11y.v @@ -10,9 +10,10 @@ pub: } // AccessRole identifies a shape's semantic role for assistive -// technology. Maps 1:1 to NSAccessibilityRole (macOS) and -// UIA Control Type (Windows). Zero value .none means the shape -// is invisible to the accessibility tree. +// technology. Maps 1:1 to NSAccessibilityRole on macOS. +// Windows UIA control types are the intended future mapping; +// the current Windows backend does not expose UIA yet. Zero +// value .none means the shape is invisible to the accessibility tree. pub enum AccessRole as u8 { none button diff --git a/docs/GET_STARTED.md b/docs/GET_STARTED.md index b0596d4..f112232 100644 --- a/docs/GET_STARTED.md +++ b/docs/GET_STARTED.md @@ -182,6 +182,18 @@ v-gui comes with everything you need: All follow the same pattern: call the function, set some options, done. +## Windows Setup Status + +Windows native support is being validated. Treat it as a native smoke-tested +path, not a final support claim yet. + +- Use the MSVC/vcpkg path in [`WINDOWS.md`](WINDOWS.md) for `vglyph`, Pango and + Freetype setup/preflight. +- Use [`WINDOWS_MANUAL_SMOKE.md`](WINDOWS_MANUAL_SMOKE.md) for manual native + Windows validation of dialogs, notifications, printing and D3D11 readback. +- MSYS2/GCC, WSL and Wine are diagnostic aids only until native Windows smoke + proves them as supported user paths. + ## Why v-gui Feels Different **No widget objects to manage.** Traditional frameworks make you create button objects, store diff --git a/docs/NATIVE_DIALOGS.md b/docs/NATIVE_DIALOGS.md index 7cb4c83..7240bd8 100644 --- a/docs/NATIVE_DIALOGS.md +++ b/docs/NATIVE_DIALOGS.md @@ -13,8 +13,9 @@ This guide covers native dialogs: support for sandboxed apps. - Linux: XDG Desktop Portal via D-Bus (preferred), falling back to `zenity` or `kdialog`. -- Windows: returns `.error` with - `error_code == 'unsupported'`. Not yet implemented. +- Windows: native Win32 file/folder dialogs and message/confirm + boxes. CI covers non-interactive result mapping; live modal + behavior is still pending Windows smoke/manual validation. Linux notes: - portal mode requires `org.freedesktop.portal.Desktop` on @@ -23,6 +24,17 @@ Linux notes: - if all are missing, callback returns `.error` with `error_code == 'unsupported'`. +Windows notes: +- file/folder dialogs use native Win32 Common Item Dialog COM APIs; message and + confirm dialogs use native Win32 message boxes. +- dependency failures from `vglyph`, Pango or Freetype are setup/build + preflight failures. They happen before native dialog callbacks can report a + `NativeDialogResult`. +- WSL and Wine are not validation gates for real Windows COM dialogs. +- Windows setup/preflight is documented in [`WINDOWS.md`](WINDOWS.md). Manual + modal-dialog validation is tracked in + [`WINDOWS_MANUAL_SMOKE.md`](WINDOWS_MANUAL_SMOKE.md). + ## Result Model `on_done` receives `NativeDialogResult`: diff --git a/docs/PRINTING.md b/docs/PRINTING.md index 9badd3c..1949f3f 100644 --- a/docs/PRINTING.md +++ b/docs/PRINTING.md @@ -8,12 +8,33 @@ This guide covers: - macOS: uses native print panel. - Linux: prefers opener (`xdg-open` / `gio open`), falls back to `lp` direct dispatch. +- Windows: delegates generated or existing PDFs to the native `ShellExecute` + `print` verb. This is PDF-handler delegation, not `PrintDlgEx` or printer-DC + option parity, so warnings are returned for options the backend cannot + guarantee. - other platforms: returns `.error` with `error_code == 'unsupported'`. Linux notes: - opener path cannot guarantee copies/ranges/duplex/color; warnings are returned. - direct `lp` path applies supported options (`copies`, `page_ranges`, `duplex`, `color`). +Windows notes: +- CI covers non-interactive result mapping and validation; live PDF handler + behavior is still pending Windows smoke/manual validation. +- `source: .current_view` first exports a PDF through the rendering/text stack. + Missing `vglyph`, Pango or Freetype dependencies are setup/build preflight + failures, not print-dialog results. +- `source: .pdf_path` delegates an existing PDF to the default Windows PDF + handler through `ShellExecute`. +- copies, ranges, duplex, color, job names, and shape/export options for an + existing PDF may be ignored by the PDF handler. +- Windows does not currently implement a `PrintDlgExW` or printer DC flow. + Treat full printer-option parity as an advanced limitation until that path is + implemented and smoke-tested. +- Windows setup/preflight is documented in [`WINDOWS.md`](WINDOWS.md). Manual + print-handler validation is tracked in + [`WINDOWS_MANUAL_SMOKE.md`](WINDOWS_MANUAL_SMOKE.md). + ## Export PDF `export_print_job` exports current renderers to PDF. @@ -88,9 +109,11 @@ result := w.export_print_job(gui.PrintJob{ }) ``` -## Native print dialog +## Native Print Flow -`run_print_job` opens native print flow and returns `PrintRunResult`. +`run_print_job` opens the platform print flow and returns `PrintRunResult`. +On Windows this dispatches a PDF through the default handler via `ShellExecute`; +it does not expose a native printer-options dialog yet. ```v ignore result := w.run_print_job(gui.PrintJob{ diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5251c6c..ecdb507 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -15,8 +15,8 @@ This file is a forward-only todo list for professional-grade `v-gui`. - [x] Core widgets: input, button family, table, tree, markdown, dialogs, menus - [x] SVG + shaders + gradients + blur + shadows - [x] IME, clipboard text, async image loading, drag/drop inbound files -- [x] Desktop targets: macOS, Windows, Linux -- [x] Print: native OS print dialog, PDF export, raster export +- [x] Desktop targets: macOS, Linux; Windows native target under validation +- [x] Print: PDF export, raster export, platform print flow ## 2026 H1: Professional Desktop Baseline (P0) @@ -36,7 +36,7 @@ This file is a forward-only todo list for professional-grade `v-gui`. - [x] Native folder-picker dialog - [?] Native color picker dialog - [x] Native message/alert fallback adapter (opt-in over custom GUI dialog) -- [x] Permission + sandbox-safe path handling on macOS/Windows/Linux portals +- [x] macOS sandbox-safe grants; Linux/Windows no-op grants/native paths ### Markdown + Rich Text diff --git a/docs/WINDOWS.md b/docs/WINDOWS.md new file mode 100644 index 0000000..56ad713 --- /dev/null +++ b/docs/WINDOWS.md @@ -0,0 +1,130 @@ +# Windows Setup And Validation + +Windows native support is under active validation. This page documents the +current setup path and failure triage; it is not a final README-level support +claim. + +## Supported Path Under Validation + +Use native Windows, not WSL, as the validation target. + +- Windows 10 or 11. +- MSVC from Visual Studio Build Tools or Visual Studio with the Desktop C++ + workload and Windows SDK. +- `vcpkg` packages for `pango` and `freetype`. +- The V module dependency `vglyph`. + +The current CI/smoke path uses MSVC first. Clang and MinGW/MSYS2 remain +exploratory until smoke evidence proves that normal users do not need manual +PATH edits, copied DLLs, or non-default package paths. + +## Install Checklist + +Run commands from a native x64 Developer PowerShell or x64 Native Tools shell so +`cl.exe`, `link.exe`, the Windows SDK and `vcpkg` are visible to the same +process that runs `v`. + +```powershell +v version +vcpkg install pango freetype +v install vglyph +``` + +Then run the non-interactive native checks: + +```powershell +v run _windows_preflight.vsh +``` + +This preflight is safe to run before building GUI examples. It checks that the +command is running on native Windows, that the selected compiler is visible, +that `vglyph` can be imported, and that a minimal text-stack probe can be built. +For the supported MSVC path it also checks that `vcpkg` is visible and that +`pango` and `freetype` packages are installed. It writes only temporary probe +files under the system temp directory and removes them before exit. + +```powershell +v -no-parallel -cc msvc test ` + _native_dialog_test.v ` + _native_print_test.v ` + _native_notification_test.v ` + nativebridge/_bridge_ex_test.v ` + nativebridge/_readback_abi_test.v +``` + +Compile the focused examples before running any manual UI smoke: + +```powershell +v -no-parallel -cc msvc -W -o dialogs_smoke.exe examples/dialogs.v +v -no-parallel -cc msvc -W -o printing_smoke.exe examples/printing.v +v -no-parallel -cc msvc -W -o notification_smoke.exe examples/native_notification.v +``` + +The manual UI matrix lives in +[`WINDOWS_MANUAL_SMOKE.md`](WINDOWS_MANUAL_SMOKE.md). + +## Dependency Preflight + +Most Windows setup failures happen before native dialog or print callbacks can +return a structured GUI result. Treat these as build/setup preflight failures: + +- `module vglyph not found`: run `v install vglyph`. +- `pango/pango.h` or `ft2build.h` missing: install the C headers with + `vcpkg install pango freetype` in the same native Windows environment used + for `v`. +- unresolved `pango_*`, `FT_*`, HarfBuzz, FriBidi or Fontconfig symbols: keep + one toolchain at a time. For the supported path, use MSVC plus matching + `x64-windows` vcpkg packages. +- missing Pango/Freetype/HarfBuzz/FriBidi/Fontconfig DLL on smoke executable + startup: record it as a Windows setup blocker. Do not treat random DLL + copying or global PATH edits as the final user setup path. +- WSL or Wine passes while native Windows fails: validate again on native + Windows. Prefilters are not gates. + +When reporting a setup failure, include: + +- `v version` +- compiler path (`where cl` or `where gcc`) +- `_windows_preflight.vsh` output +- `vcpkg list` +- the exact `v` command +- the first missing header, library, symbol or DLL + +## Feature Contract During Validation + +- Windows dialogs use native Win32 Common Item Dialog COM APIs for file/folder + pickers and Win32 message boxes for message/confirm dialogs. +- Windows printing delegates PDFs through the native `ShellExecute` `print` + verb. It is native PDF-handler delegation, not full printer-option parity. +- Windows notifications currently use `Shell_NotifyIconW` balloons. Toast or + AppNotification parity is not claimed yet. +- D3D11 readback/export has bridge coverage, but runtime evidence must come + from native Windows smoke/manual validation. +- Accessibility on Windows is not screen-reader parity yet. The current backend + is a safe stub until a UI Automation provider is implemented. + +## Windows Notification And Accessibility Guardrails + +Windows notifications currently mean notification-area balloon delivery through +[`Shell_NotifyIconW`](https://learn.microsoft.com/windows/win32/api/shellapi/nf-shellapi-shell_notifyiconw) +and `NOTIFYICONDATAW`. This is the supported native fallback during validation; +it is not Windows App SDK Toast/AppNotification parity. + +Do not treat Toast/AppNotification as implemented until a separate Windows App +SDK design exists for `AppNotificationManager`, runtime/bootstrap requirements, +AppUserModelID or COM registration, packaged/unpackaged behavior, and smoke +evidence on native Windows. + +Windows accessibility is also deliberately limited. The current backend does +not implement a server-side UI Automation provider, does not handle +`WM_GETOBJECT` for `UiaReturnRawElementProvider`, and does not expose an +`IRawElementProviderSimple` tree. Do not claim Narrator, Accessibility Insights, +or screen-reader parity until that provider exists and has manual Windows +evidence. + +## Prefilters That Are Not Gates + +WSL and Wine can catch simple compile/startup mistakes, but they cannot prove +MSVC ABI behavior, COM dialogs, Shell notifications, D3D11 readback, native PDF +handler printing, or normal user setup. Passing a prefilter is useful; failing a +native Windows smoke remains authoritative. diff --git a/docs/WINDOWS_MANUAL_SMOKE.md b/docs/WINDOWS_MANUAL_SMOKE.md new file mode 100644 index 0000000..d9cf8d9 --- /dev/null +++ b/docs/WINDOWS_MANUAL_SMOKE.md @@ -0,0 +1,93 @@ +# Windows Manual Smoke Matrix + +Use this matrix after the non-interactive Windows tests and focused example +compiles pass. These checks are intentionally manual because they exercise real +OS UI, handlers and GPU behavior. + +Record every run with: + +- Windows version +- V version +- compiler and architecture +- `vcpkg list` +- command used to build each smoke executable +- pass/fail result, notes and screenshots where useful + +## Preconditions + +- Native Windows machine or GitHub Windows runner with interactive/manual + access. +- MSVC/vcpkg path from [`WINDOWS.md`](WINDOWS.md). +- `v run _windows_preflight.vsh` passes on native Windows. +- `dialogs_smoke.exe`, `printing_smoke.exe` and `notification_smoke.exe` + compiled with `-cc msvc -W`. +- WSL and Wine results are marked as prefilters only, never as final passes. + +## Matrix + +### Setup + +- Check: run the non-interactive tests from `WINDOWS.md`. +- Expected: all targeted tests pass without opening modal UI. +- Evidence: command output. + +### Dialogs + +- Open one file with named filters. Expect a native picker, a returned path and + meaningful filter labels. Evidence: screenshot plus callback log. +- Select multiple files. Expect every selected path in the callback log. +- Save with default extension and `confirm_overwrite: true`. Expect native + overwrite confirmation and the created file path in the callback log. +- Try an existing path with `confirm_overwrite: false`. Expect `.error` + instead of overwrite. +- Pick a folder. Expect the selected folder path. +- Show info/warning/error messages. Expect native message boxes and `.ok`. +- Accept and reject a confirm dialog. Expect Yes as `.ok` and No as `.cancel`. + +### Printing + +- Print/export current view through the example. Expect PDF generation before + native print dispatch. +- Print an existing PDF with the default handler installed. Expect + `ShellExecute` print dispatch or a structured error. +- Repeat with no default PDF handler if practical. Expect error without crash + or hang. +- Request copies/ranges/duplex/color. Expect warnings for unsupported or + unverifiable options; do not claim full option parity. + +### Notifications + +- Send a notification. Expect a `Shell_NotifyIconW` balloon or a structured + setup/runtime error. +- Treat this as notification-area fallback evidence only. Do not record it as + Toast, AppNotification, Action Center, or Windows App SDK parity. + +### D3D11 Readback And Export + +- Exercise raster export/readback. Expect completion without invalid dimensions, + overflow, format or startup failure. + +### Examples + +- Launch dialogs, printing and notification smoke executables. Expect startup + without missing DLLs and the manual behavior listed above. + +### Accessibility + +- Inspect with Narrator or Accessibility Insights if available. Current expected + result is limited: no real UI Automation provider is implemented, so a pass + can only mean "no crash/no misleading parity claim". +- Do not close Windows accessibility parity until a server-side UIA provider is + implemented and exposes a useful tree through Windows accessibility tooling. + +## Failure Classification + +- Missing `vglyph` module imports are setup/preflight failures. +- Missing Pango/Freetype/HarfBuzz/FriBidi/Fontconfig headers, libraries or DLLs + are setup/preflight failures. +- Modal UI hangs are native Windows behavior blockers. +- Missing PDF handler behavior belongs to printing validation, not dependency + setup. +- Notification delivery failures are Windows notification backend validation + items, not dialog/print failures. +- WSL/Wine-only results cannot close a native Windows validation item. diff --git a/native_dialog_backend.v b/native_dialog_backend.v index 0943f8f..b7dfce2 100644 --- a/native_dialog_backend.v +++ b/native_dialog_backend.v @@ -4,6 +4,7 @@ import nativebridge import sokol.sapp const native_dialog_error_code_invalid_cfg = 'invalid_cfg' +const native_dialog_filter_spec_prefix = 'gfd1;' fn native_open_dialog_impl(mut w Window, cfg NativeOpenDialogCfg) { extensions := native_extensions_from_filters(cfg.filters) or { @@ -11,12 +12,18 @@ fn native_open_dialog_impl(mut w Window, cfg NativeOpenDialogCfg) { err.msg())) return } + filter_specs := native_filter_specs_from_filters(cfg.filters, '') or { + native_dispatch_dialog_done(mut w, cfg.on_done, native_dialog_error_result(native_dialog_error_code_invalid_cfg, + err.msg())) + return + } bridge_result_ex := nativebridge.open_dialog_ex(nativebridge.BridgeOpenCfg{ ns_window: native_dialog_ns_window() title: cfg.title start_dir: cfg.start_dir extensions: extensions + filter_specs: filter_specs allow_multiple: cfg.allow_multiple }) native_dispatch_dialog_done(mut w, cfg.on_done, native_result_from_bridge_ex(bridge_result_ex, mut @@ -34,6 +41,11 @@ fn native_save_dialog_impl(mut w Window, cfg NativeSaveDialogCfg) { err.msg())) return } + filter_specs := native_filter_specs_from_filters(cfg.filters, default_extension) or { + native_dispatch_dialog_done(mut w, cfg.on_done, native_dialog_error_result(native_dialog_error_code_invalid_cfg, + err.msg())) + return + } bridge_result_ex := nativebridge.save_dialog_ex(nativebridge.BridgeSaveCfg{ ns_window: native_dialog_ns_window() @@ -42,6 +54,7 @@ fn native_save_dialog_impl(mut w Window, cfg NativeSaveDialogCfg) { default_name: cfg.default_name default_extension: default_extension extensions: extensions + filter_specs: filter_specs confirm_overwrite: cfg.confirm_overwrite }) native_dispatch_dialog_done(mut w, cfg.on_done, native_result_from_bridge_ex(bridge_result_ex, mut @@ -74,6 +87,7 @@ fn native_result_from_bridge_ex(bridge_result nativebridge.BridgeDialogResultEx, .cancel { NativeDialogStatus.cancel } .error { NativeDialogStatus.error } } + mut paths := []AccessiblePath{cap: bridge_result.entries.len} for entry in bridge_result.entries { grant := w.store_bookmark(entry.path, entry.data) @@ -123,6 +137,51 @@ fn native_save_extensions(filters []NativeFileFilter, default_extension string) return extensions } +fn native_filter_specs_from_filters(filters []NativeFileFilter, default_extension string) !string { + def_ext := native_normalize_extension(default_extension) or { return err } + mut has_named_filter := false + mut has_default := def_ext.len == 0 + mut groups := []string{} + for filter in filters { + name := filter.name.trim_space() + if name.len > 0 { + has_named_filter = true + } + mut extensions := []string{} + mut seen := map[string]bool{} + for raw_extension in filter.extensions { + extension := native_normalize_extension(raw_extension) or { return err } + if extension.len == 0 || seen[extension] { + continue + } + if extension == def_ext { + has_default = true + } + seen[extension] = true + extensions << extension + } + if extensions.len == 0 { + continue + } + groups << native_filter_spec_group(name, extensions) + } + if !has_named_filter { + return '' + } + if !has_default { + groups << native_filter_spec_group('', [def_ext]) + } + if groups.len == 0 { + return '' + } + return native_dialog_filter_spec_prefix + groups.join('') +} + +fn native_filter_spec_group(name string, extensions []string) string { + extension_csv := extensions.join(',') + return '${name.len}:${name}${extension_csv.len}:${extension_csv}' +} + fn native_extensions_from_filters(filters []NativeFileFilter) ![]string { mut extensions := []string{} mut seen := map[string]bool{} @@ -182,6 +241,7 @@ fn native_alert_result_from_bridge(bridge_result nativebridge.BridgeAlertResult) .cancel { NativeDialogStatus.cancel } .error { NativeDialogStatus.error } } + return NativeAlertResult{ status: status error_code: bridge_result.error_code diff --git a/native_notification_backend.v b/native_notification_backend.v index f7c9e2b..51bcb34 100644 --- a/native_notification_backend.v +++ b/native_notification_backend.v @@ -22,6 +22,7 @@ fn native_notification_result_from_bridge(br nativebridge.BridgeNotificationResu .denied { NativeNotificationStatus.denied } .error { NativeNotificationStatus.error } } + return NativeNotificationResult{ status: status error_code: br.error_code diff --git a/native_print_backend.v b/native_print_backend.v index 660b6b3..6b0d172 100644 --- a/native_print_backend.v +++ b/native_print_backend.v @@ -9,7 +9,8 @@ fn run_print_job_impl(mut w Window, job PrintJob) PrintRunResult { return print_run_error_result(native_print_error_code_invalid_cfg, err.msg()) } if !print_job_supported() { - return print_run_error_result('unsupported', 'native print is not implemented on this platform') + return print_run_error_result('unsupported', + 'native print is not implemented on this platform') } pdf_path := print_job_resolve_pdf_path(mut w, job) or { @@ -37,7 +38,11 @@ fn run_print_job_impl(mut w Window, job PrintJob) PrintRunResult { color_mode: int(job.color_mode) scale_mode: int(job.scale_mode) }) - return print_run_result_from_bridge(bridge_result, pdf_path) + mut extra_warnings := []string{} + $if windows { + extra_warnings = print_windows_shell_execute_warnings(job) + } + return print_run_result_from_bridge_with_warnings(bridge_result, pdf_path, extra_warnings) } fn print_job_supported() bool { @@ -103,7 +108,18 @@ fn print_orientation_to_int(orientation PrintOrientation) int { } fn print_run_result_from_bridge(bridge_result nativebridge.BridgePrintResult, pdf_path string) PrintRunResult { - warnings := bridge_warnings_to_print_warnings(bridge_result.warnings) + return print_run_result_from_bridge_with_warnings(bridge_result, pdf_path, []string{}) +} + +fn print_run_result_from_bridge_with_warnings(bridge_result nativebridge.BridgePrintResult, pdf_path string, extra_warnings []string) PrintRunResult { + mut raw_warnings := []string{cap: bridge_result.warnings.len + extra_warnings.len} + for warning in bridge_result.warnings { + raw_warnings << warning + } + for warning in extra_warnings { + raw_warnings << warning + } + warnings := bridge_warnings_to_print_warnings(raw_warnings) return match bridge_result.status { .ok { print_run_ok_result(pdf_path, warnings) @@ -125,6 +141,49 @@ fn print_run_result_from_bridge(bridge_result nativebridge.BridgePrintResult, pd } } +fn print_windows_shell_execute_warnings(job PrintJob) []string { + mut warnings := []string{} + if job.copies > 1 { + warnings << 'copies may be ignored by Windows ShellExecute print' + } + if job.page_ranges.len > 0 { + warnings << 'page ranges may be ignored by Windows ShellExecute print' + } + if job.duplex != .default_mode { + warnings << 'duplex mode may be ignored by Windows ShellExecute print' + } + if job.color_mode != .default_mode { + warnings << 'color mode may be ignored by Windows ShellExecute print' + } + if job.job_name.trim_space().len > 0 { + warnings << 'job name may be ignored by Windows ShellExecute print' + } + if job.source.kind == .pdf_path { + if job.title.trim_space().len > 0 { + warnings << 'title may be ignored by Windows ShellExecute print' + } + if job.paper != .a4 { + warnings << 'paper size cannot be applied to an existing PDF by Windows ShellExecute print' + } + if job.orientation != .portrait { + warnings << 'orientation cannot be applied to an existing PDF by Windows ShellExecute print' + } + if !print_margins_are_default(job.margins) { + warnings << 'margins cannot be applied to an existing PDF by Windows ShellExecute print' + } + if job.scale_mode != .fit_to_page { + warnings << 'scale mode cannot be applied to an existing PDF by Windows ShellExecute print' + } + } + return warnings +} + +fn print_margins_are_default(margins PrintMargins) bool { + defaults := default_print_margins() + return margins.top == defaults.top && margins.right == defaults.right + && margins.bottom == defaults.bottom && margins.left == defaults.left +} + fn bridge_warnings_to_print_warnings(items []string) []PrintWarning { mut out := []PrintWarning{cap: items.len} for item in items { diff --git a/nativebridge/_bridge_ex_test.v b/nativebridge/_bridge_ex_test.v index 90df725..fe38876 100644 --- a/nativebridge/_bridge_ex_test.v +++ b/nativebridge/_bridge_ex_test.v @@ -41,3 +41,29 @@ fn test_bridge_dialog_unsupported_result_ex() { assert ex.status == .error assert ex.error_code == 'unsupported' } + +fn test_bridge_print_unsupported_result() { + result := bridge_print_unsupported_result() + assert result.status == .error + assert result.error_code == 'unsupported' + assert result.warnings.len == 0 +} + +fn test_bridge_alert_unsupported_result() { + result := bridge_alert_unsupported_result() + assert result.status == .error + assert result.error_code == 'unsupported' +} + +fn test_bridge_notification_status_from_int() { + assert bridge_notification_status_from_int(0) == .ok + assert bridge_notification_status_from_int(1) == .denied + assert bridge_notification_status_from_int(2) == .error + assert bridge_notification_status_from_int(999) == .error +} + +fn test_bridge_notification_unsupported_result() { + result := bridge_notification_unsupported_result() + assert result.status == .error + assert result.error_code == 'unsupported' +} diff --git a/nativebridge/_readback_abi_test.v b/nativebridge/_readback_abi_test.v new file mode 100644 index 0000000..01c06a5 --- /dev/null +++ b/nativebridge/_readback_abi_test.v @@ -0,0 +1,133 @@ +module nativebridge + +#include + +$if windows { + #flag windows -ld3d11 + #insert "@VMODROOT/nativebridge/readback_d3d11_warp_test.h" + + fn C.gui_readback_d3d11_warp_roundtrip_test() int + fn C.gui_readback_d3d11_rejects_unsupported_format_test() int + fn C.gui_readback_d3d11_rejects_msaa_test() int +} + +fn C.malloc(size usize) &u8 + +fn test_readback_buffer_free_accepts_c_allocated_buffer() { + $if macos || linux || windows { + ptr := C.malloc(16) + assert ptr != unsafe { nil } + C.gui_readback_buffer_free(ptr) + } +} + +fn test_readback_buffer_free_accepts_null() { + $if macos || linux || windows { + C.gui_readback_buffer_free(unsafe { nil }) + } +} + +fn test_readback_metal_rejects_invalid_native_args() { + $if macos { + readback_metal_texture(unsafe { nil }, unsafe { nil }, 0, 1) or { + assert err.msg().contains('dimensions') + return + } + assert false + } +} + +fn test_readback_metal_rejects_nil_native_handles() { + $if macos { + readback_metal_texture(unsafe { nil }, unsafe { nil }, 1, 1) or { + assert err.msg().contains('texture and device') + return + } + assert false + } +} + +fn test_readback_metal_reports_unsupported_off_macos() { + $if !macos { + readback_metal_texture(unsafe { nil }, unsafe { nil }, 1, 1) or { + assert err.msg().contains('not available') + return + } + assert false + } +} + +fn test_readback_gl_rejects_invalid_native_dimensions() { + $if linux { + readback_gl_framebuffer(0, 0, 1) or { + assert err.msg().contains('dimensions') + return + } + assert false + } +} + +fn test_readback_gl_reports_unsupported_off_linux() { + $if !linux { + readback_gl_framebuffer(0, 1, 1) or { + assert err.msg().contains('not available') + return + } + assert false + } +} + +fn test_readback_d3d11_rejects_invalid_native_dimensions() { + $if windows { + readback_d3d11_texture(unsafe { nil }, unsafe { nil }, unsafe { nil }, 0, 1) or { + assert err.msg().contains('dimensions') + return + } + assert false + } +} + +fn test_readback_d3d11_rejects_nil_native_handles() { + $if windows { + readback_d3d11_texture(unsafe { nil }, unsafe { nil }, unsafe { nil }, 1, 1) or { + assert err.msg().contains('texture, device, and context') + return + } + assert false + } +} + +fn test_readback_d3d11_c_rejects_invalid_native_args() { + $if windows { + assert C.gui_readback_d3d11_texture(unsafe { nil }, unsafe { nil }, unsafe { nil }, 0, 1) == unsafe { nil } + assert C.gui_readback_d3d11_texture(unsafe { nil }, unsafe { nil }, unsafe { nil }, 1, 1) == unsafe { nil } + } +} + +fn test_readback_d3d11_warp_roundtrip() { + $if windows { + assert C.gui_readback_d3d11_warp_roundtrip_test() == 1 + } +} + +fn test_readback_d3d11_rejects_unsupported_format() { + $if windows { + assert C.gui_readback_d3d11_rejects_unsupported_format_test() == 1 + } +} + +fn test_readback_d3d11_rejects_msaa() { + $if windows { + assert C.gui_readback_d3d11_rejects_msaa_test() == 1 + } +} + +fn test_readback_d3d11_reports_unsupported_off_windows() { + $if !windows { + readback_d3d11_texture(unsafe { nil }, unsafe { nil }, unsafe { nil }, 1, 1) or { + assert err.msg().contains('not available') + return + } + assert false + } +} diff --git a/nativebridge/c_bindings.v b/nativebridge/c_bindings.v index 21db3fd..12c9baf 100644 --- a/nativebridge/c_bindings.v +++ b/nativebridge/c_bindings.v @@ -32,6 +32,7 @@ module nativebridge #flag windows @VMODROOT/nativebridge/notification_windows.c #flag windows -lole32 #flag windows -lshell32 +#flag windows -luser32 #flag windows -luuid #include "@VMODROOT/nativebridge/a11y_bridge.h" #include "@VMODROOT/nativebridge/dialog_bridge.h" @@ -73,6 +74,7 @@ pub: title string start_dir string extensions []string + filter_specs string allow_multiple bool } @@ -84,6 +86,7 @@ pub: default_name string default_extension string extensions []string + filter_specs string confirm_overwrite bool } @@ -187,6 +190,7 @@ fn C.gui_native_print_result_free(C.GuiNativePrintResult) fn C.gui_readback_metal_texture(mtl_texture voidptr, mtl_device voidptr, width int, height int) &u8 fn C.gui_readback_gl_framebuffer(framebuffer u32, width int, height int) &u8 fn C.gui_readback_d3d11_texture(d3d11_texture voidptr, d3d11_device voidptr, d3d11_context voidptr, width int, height int) &u8 +fn C.gui_readback_buffer_free(buffer &u8) fn bridge_print_unsupported_result() BridgePrintResult { return BridgePrintResult{ @@ -349,9 +353,9 @@ pub fn folder_dialog(cfg BridgeFolderCfg) BridgeDialogResult { // retain file access across app relaunches in sandboxed // apps. On Linux the grant data is empty; paths are usable // directly. Prefers XDG Desktop Portal when available, -// falling back to zenity/kdialog. On Windows returns -// .error with error_code 'unsupported'; grants are not -// yet implemented. +// falling back to zenity/kdialog. On Windows the native +// Win32 file picker is used; grant data is empty and paths +// are usable directly. pub fn open_dialog_ex(cfg BridgeOpenCfg) BridgeDialogResultEx { $if macos { extensions := cfg.extensions.join(',') @@ -368,8 +372,9 @@ pub fn open_dialog_ex(cfg BridgeOpenCfg) BridgeDialogResultEx { return bridge_result_ex_from_legacy(linux_open_dialog(cfg)) } $else $if windows { extensions := cfg.extensions.join(',') + filter_arg := if cfg.filter_specs.len > 0 { cfg.filter_specs } else { extensions } c_result := C.gui_native_open_dialog_ex(cfg.ns_window, cfg.title.str, cfg.start_dir.str, - extensions.str, bool_to_int(cfg.allow_multiple)) + filter_arg.str, bool_to_int(cfg.allow_multiple)) return bridge_dialog_result_ex_from_c(c_result) } $else { return bridge_dialog_unsupported_result_ex() @@ -382,27 +387,30 @@ pub fn open_dialog_ex(cfg BridgeOpenCfg) BridgeDialogResultEx { // write access across relaunches in sandboxed apps. On // Linux the grant data is empty; the path is usable // directly. Prefers XDG Desktop Portal when available, -// falling back to zenity/kdialog. On Windows returns -// .error with error_code 'unsupported'; grants are not -// yet implemented. +// falling back to zenity/kdialog. On Windows the native +// Win32 save dialog is used; grant data is empty and the +// path is usable directly. pub fn save_dialog_ex(cfg BridgeSaveCfg) BridgeDialogResultEx { $if macos { extensions := cfg.extensions.join(',') c_result := C.gui_native_save_dialog_ex(cfg.ns_window, cfg.title.str, cfg.start_dir.str, - cfg.default_name.str, cfg.default_extension.str, extensions.str, bool_to_int(cfg.confirm_overwrite)) + cfg.default_name.str, cfg.default_extension.str, extensions.str, + bool_to_int(cfg.confirm_overwrite)) return bridge_dialog_result_ex_from_c(c_result) } $else $if linux { if C.gui_portal_available() != 0 { extensions := cfg.extensions.join(',') - c_result := C.gui_portal_save_file(cfg.title.str, cfg.start_dir.str, cfg.default_name.str, - cfg.default_extension.str, extensions.str) + c_result := C.gui_portal_save_file(cfg.title.str, cfg.start_dir.str, + cfg.default_name.str, cfg.default_extension.str, extensions.str) return bridge_dialog_result_ex_from_c(c_result) } return bridge_result_ex_from_legacy(linux_save_dialog(cfg)) } $else $if windows { extensions := cfg.extensions.join(',') + filter_arg := if cfg.filter_specs.len > 0 { cfg.filter_specs } else { extensions } c_result := C.gui_native_save_dialog_ex(cfg.ns_window, cfg.title.str, cfg.start_dir.str, - cfg.default_name.str, cfg.default_extension.str, extensions.str, bool_to_int(cfg.confirm_overwrite)) + cfg.default_name.str, cfg.default_extension.str, filter_arg.str, + bool_to_int(cfg.confirm_overwrite)) return bridge_dialog_result_ex_from_c(c_result) } $else { return bridge_dialog_unsupported_result_ex() @@ -415,9 +423,9 @@ pub fn save_dialog_ex(cfg BridgeSaveCfg) BridgeDialogResultEx { // persisting access across relaunches in sandboxed apps. On // Linux the grant data is empty; the path is usable // directly. Prefers XDG Desktop Portal when available, -// falling back to zenity/kdialog. On Windows returns -// .error with error_code 'unsupported'; grants are not -// yet implemented. +// falling back to zenity/kdialog. On Windows the native +// Win32 folder picker is used; grant data is empty and the +// path is usable directly. pub fn folder_dialog_ex(cfg BridgeFolderCfg) BridgeDialogResultEx { $if macos { c_result := C.gui_native_folder_dialog_ex(cfg.ns_window, cfg.title.str, cfg.start_dir.str, @@ -491,6 +499,12 @@ pub fn bookmark_stop_access(data []u8) { // Caller must gfx.commit() before calling. macOS only. pub fn readback_metal_texture(mtl_texture voidptr, mtl_device voidptr, width int, height int) ![]u8 { $if macos { + if width <= 0 || height <= 0 { + return error('readback dimensions must be positive') + } + if mtl_texture == unsafe { nil } || mtl_device == unsafe { nil } { + return error('Metal texture and device are required') + } ptr := C.gui_readback_metal_texture(mtl_texture, mtl_device, width, height) if ptr == unsafe { nil } { return error('Metal texture readback failed') @@ -499,7 +513,7 @@ pub fn readback_metal_texture(mtl_texture voidptr, mtl_device voidptr, width int mut pixels := []u8{len: size} unsafe { vmemcpy(pixels.data, ptr, size) - free(ptr) + C.gui_readback_buffer_free(ptr) } return pixels } $else { @@ -512,6 +526,9 @@ pub fn readback_metal_texture(mtl_texture voidptr, mtl_device voidptr, width int // order. Caller must gfx.commit() before calling. Linux only. pub fn readback_gl_framebuffer(framebuffer u32, width int, height int) ![]u8 { $if linux { + if width <= 0 || height <= 0 { + return error('readback dimensions must be positive') + } ptr := C.gui_readback_gl_framebuffer(framebuffer, width, height) if ptr == unsafe { nil } { return error('GL framebuffer readback failed') @@ -520,7 +537,7 @@ pub fn readback_gl_framebuffer(framebuffer u32, width int, height int) ![]u8 { mut pixels := []u8{len: size} unsafe { vmemcpy(pixels.data, ptr, size) - free(ptr) + C.gui_readback_buffer_free(ptr) } return pixels } $else { @@ -533,8 +550,15 @@ pub fn readback_gl_framebuffer(framebuffer u32, width int, height int) ![]u8 { // must gfx.commit() before calling. Windows only. pub fn readback_d3d11_texture(d3d11_texture voidptr, d3d11_device voidptr, d3d11_context voidptr, width int, height int) ![]u8 { $if windows { - ptr := C.gui_readback_d3d11_texture(d3d11_texture, d3d11_device, d3d11_context, - width, height) + if width <= 0 || height <= 0 { + return error('readback dimensions must be positive') + } + if d3d11_texture == unsafe { nil } || d3d11_device == unsafe { nil } + || d3d11_context == unsafe { nil } { + return error('D3D11 texture, device, and context are required') + } + ptr := C.gui_readback_d3d11_texture(d3d11_texture, d3d11_device, d3d11_context, width, + height) if ptr == unsafe { nil } { return error('D3D11 texture readback failed') } @@ -542,7 +566,7 @@ pub fn readback_d3d11_texture(d3d11_texture voidptr, d3d11_device voidptr, d3d11 mut pixels := []u8{len: size} unsafe { vmemcpy(pixels.data, ptr, size) - free(ptr) + C.gui_readback_buffer_free(ptr) } return pixels } $else { diff --git a/nativebridge/dialog_windows.c b/nativebridge/dialog_windows.c index 625aefd..a31d902 100644 --- a/nativebridge/dialog_windows.c +++ b/nativebridge/dialog_windows.c @@ -12,8 +12,10 @@ #include #include #include +#include #include #include +#include #include "dialog_bridge.h" @@ -23,6 +25,9 @@ enum { gui_win_status_error = 2, }; +#define GUI_WIN_FILTER_SPEC_PREFIX "gfd1;" +#define GUI_WIN_FILTER_SPEC_PREFIX_LEN 5 + static char* gui_win_strdup(const char* s) { if (s == NULL) return NULL; size_t len = strlen(s); @@ -31,6 +36,15 @@ static char* gui_win_strdup(const char* s) { return out; } +static char* gui_win_strndup(const char* s, size_t len) { + if (s == NULL) return NULL; + char* out = (char*)malloc(len + 1); + if (out == NULL) return NULL; + memcpy(out, s, len); + out[len] = '\0'; + return out; +} + static GuiNativeDialogResultEx gui_win_result_empty(void) { GuiNativeDialogResultEx r; r.status = gui_win_status_error; @@ -78,7 +92,233 @@ static char* gui_wide_to_utf8(const wchar_t* w) { return s; } -// Parse "jpg,png,gif" CSV into COMDLG_FILTERSPEC array. +typedef struct GuiWinComScope { + HRESULT hr; + int must_uninit; +} GuiWinComScope; + +static GuiWinComScope gui_win_com_scope_init(void) { + GuiWinComScope scope; + scope.hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); + scope.must_uninit = SUCCEEDED(scope.hr) ? 1 : 0; + return scope; +} + +static void gui_win_com_scope_uninit(GuiWinComScope scope) { + if (scope.must_uninit) CoUninitialize(); +} + +static void gui_win_swprintf( + wchar_t* dst, + size_t dst_count, + const wchar_t* fmt, + const wchar_t* a, + const wchar_t* b +) { + if (dst == NULL || dst_count == 0) return; +#ifdef _MSC_VER + swprintf_s(dst, dst_count, fmt, a, b); +#else + swprintf(dst, dst_count, fmt, a, b); +#endif + dst[dst_count - 1] = L'\0'; +} + +static void gui_free_filter_specs( + COMDLG_FILTERSPEC* specs, int count +); + +static int gui_parse_len_token( + const char** cursor, const char* end, size_t* out +) { + if (cursor == NULL || *cursor == NULL || out == NULL) return 0; + const char* p = *cursor; + if (p >= end || *p < '0' || *p > '9') return 0; + + size_t value = 0; + while (p < end && *p >= '0' && *p <= '9') { + size_t digit = (size_t)(*p - '0'); + if (value > (((size_t)-1) - digit) / 10) return 0; + value = value * 10 + digit; + p++; + } + if (p >= end || *p != ':') return 0; + + *cursor = p + 1; + *out = value; + return 1; +} + +static wchar_t* gui_filter_pattern_from_csv(const char* csv) { + if (csv == NULL || csv[0] == '\0') return NULL; + + size_t csv_len = strlen(csv); + size_t buf_cap = csv_len * 4 + 16; + wchar_t* pattern = (wchar_t*)calloc(buf_cap, sizeof(wchar_t)); + if (pattern == NULL) return NULL; + + char* dup = gui_win_strdup(csv); + if (dup == NULL) { + free(pattern); + return NULL; + } + + wchar_t* cp = pattern; + char* token = strtok(dup, ","); + while (token != NULL) { + while (*token == ' ' || *token == '.') token++; + char* token_end = token + strlen(token); + while (token_end > token + && (token_end[-1] == ' ' || token_end[-1] == '.')) { + token_end--; + } + *token_end = '\0'; + if (*token != '\0') { + if (cp != pattern) *cp++ = L';'; + *cp++ = L'*'; + *cp++ = L'.'; + MultiByteToWideChar( + CP_UTF8, 0, token, -1, cp, + (int)(buf_cap - (cp - pattern))); + cp += wcslen(cp); + } + token = strtok(NULL, ","); + } + free(dup); + + if (cp == pattern) { + free(pattern); + return NULL; + } + *cp = L'\0'; + return pattern; +} + +static wchar_t* gui_filter_name_from_pattern(const wchar_t* pattern) { + if (pattern == NULL || pattern[0] == L'\0') return NULL; + size_t nlen = wcslen(pattern) + 16; + wchar_t* name = (wchar_t*)calloc(nlen, sizeof(wchar_t)); + if (name == NULL) return NULL; +#ifdef _MSC_VER + swprintf_s(name, nlen, L"Files (%ls)", pattern); +#else + swprintf(name, nlen, L"Files (%ls)", pattern); +#endif + name[nlen - 1] = L'\0'; + return name; +} + +static int gui_win_path_exists(const char* path) { + wchar_t* wpath = gui_utf8_to_wide(path); + if (wpath == NULL) return 0; + DWORD attrs = GetFileAttributesW(wpath); + free(wpath); + return attrs != INVALID_FILE_ATTRIBUTES; +} + +static int gui_parse_named_filter_specs( + const char* spec, COMDLG_FILTERSPEC** out +) { + *out = NULL; + if (spec == NULL + || strncmp(spec, GUI_WIN_FILTER_SPEC_PREFIX, + GUI_WIN_FILTER_SPEC_PREFIX_LEN) != 0) { + return 0; + } + + const char* p = spec + GUI_WIN_FILTER_SPEC_PREFIX_LEN; + const char* end = spec + strlen(spec); + int capacity = 4; + int count = 0; + COMDLG_FILTERSPEC* specs = (COMDLG_FILTERSPEC*)calloc( + capacity, sizeof(COMDLG_FILTERSPEC)); + if (specs == NULL) return 0; + + while (p < end) { + size_t name_len = 0; + size_t csv_len = 0; + char* name_utf8 = NULL; + char* csv = NULL; + wchar_t* pattern = NULL; + wchar_t* name = NULL; + + if (!gui_parse_len_token(&p, end, &name_len) + || name_len > (size_t)(end - p)) { + goto fail; + } + name_utf8 = gui_win_strndup(p, name_len); + if (name_utf8 == NULL) goto fail; + p += name_len; + + if (!gui_parse_len_token(&p, end, &csv_len) + || csv_len > (size_t)(end - p)) { + free(name_utf8); + goto fail; + } + csv = gui_win_strndup(p, csv_len); + if (csv == NULL) { + free(name_utf8); + goto fail; + } + p += csv_len; + + pattern = gui_filter_pattern_from_csv(csv); + if (pattern != NULL) { + if (name_utf8[0] != '\0') { + name = gui_utf8_to_wide(name_utf8); + } + if (name == NULL) { + name = gui_filter_name_from_pattern(pattern); + } + if (name == NULL) { + free(pattern); + free(name_utf8); + free(csv); + goto fail; + } + + if (count == capacity) { + int new_capacity = capacity * 2; + COMDLG_FILTERSPEC* grown = + (COMDLG_FILTERSPEC*)realloc( + specs, + new_capacity * sizeof(COMDLG_FILTERSPEC)); + if (grown == NULL) { + free((void*)name); + free((void*)pattern); + free(name_utf8); + free(csv); + goto fail; + } + memset(grown + capacity, 0, + (new_capacity - capacity) + * sizeof(COMDLG_FILTERSPEC)); + specs = grown; + capacity = new_capacity; + } + + specs[count].pszName = name; + specs[count].pszSpec = pattern; + count++; + } + free(name_utf8); + free(csv); + } + + if (count == 0) { + free(specs); + return 0; + } + *out = specs; + return count; + +fail: + gui_free_filter_specs(specs, count); + return 0; +} + +// Parse named "gfd1;" filter specs or legacy "jpg,png,gif" CSV +// into COMDLG_FILTERSPEC array. // Returns count; *out receives malloc'd array. Caller frees // each spec's pszName and pszSpec, then the array itself. static int gui_parse_filter_specs( @@ -86,6 +326,10 @@ static int gui_parse_filter_specs( ) { *out = NULL; if (csv == NULL || csv[0] == '\0') return 0; + if (strncmp(csv, GUI_WIN_FILTER_SPEC_PREFIX, + GUI_WIN_FILTER_SPEC_PREFIX_LEN) == 0) { + return gui_parse_named_filter_specs(csv, out); + } // Count extensions. int count = 1; @@ -131,7 +375,7 @@ static int gui_parse_filter_specs( } // Build display name (uppercase ext). - size_t nlen = elen + 16; + size_t nlen = elen * 2 + 16; wchar_t* name = (wchar_t*)calloc(nlen, sizeof(wchar_t)); if (name && pattern) { // e.g. "JPG Files (*.jpg)" @@ -139,8 +383,9 @@ static int gui_parse_filter_specs( MultiByteToWideChar( CP_UTF8, 0, token, -1, ext_upper, 63); CharUpperW(ext_upper); - _snwprintf(name, nlen - 1, - L"%s Files (%s)", ext_upper, pattern); + gui_win_swprintf( + name, nlen, L"%ls Files (%ls)", + ext_upper, pattern); } specs[idx].pszName = name; @@ -256,9 +501,9 @@ GuiNativeDialogResultEx gui_native_open_dialog_ex( int allow_multiple ) { HRESULT hr; - hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); - if (FAILED(hr) && hr != RPC_E_CHANGED_MODE - && hr != S_FALSE) { + GuiWinComScope com = gui_win_com_scope_init(); + hr = com.hr; + if (FAILED(hr)) { return gui_win_result_error( "com_init", "CoInitializeEx failed"); } @@ -268,7 +513,7 @@ GuiNativeDialogResultEx gui_native_open_dialog_ex( &CLSID_FileOpenDialog, NULL, CLSCTX_INPROC_SERVER, &IID_IFileOpenDialog, (void**)&dlg); if (FAILED(hr) || dlg == NULL) { - CoUninitialize(); + gui_win_com_scope_uninit(com); return gui_win_result_error( "com_create", "IFileOpenDialog create failed"); } @@ -305,13 +550,13 @@ GuiNativeDialogResultEx gui_native_open_dialog_ex( if (hr == HRESULT_FROM_WIN32(ERROR_CANCELLED)) { IFileOpenDialog_Release(dlg); gui_free_filter_specs(specs, spec_count); - CoUninitialize(); + gui_win_com_scope_uninit(com); return gui_win_result_cancel(); } if (FAILED(hr)) { IFileOpenDialog_Release(dlg); gui_free_filter_specs(specs, spec_count); - CoUninitialize(); + gui_win_com_scope_uninit(com); return gui_win_result_error( "show", "dialog Show failed"); } @@ -322,22 +567,38 @@ GuiNativeDialogResultEx gui_native_open_dialog_ex( if (FAILED(hr) || items == NULL) { IFileOpenDialog_Release(dlg); gui_free_filter_specs(specs, spec_count); - CoUninitialize(); + gui_win_com_scope_uninit(com); return gui_win_result_error( "results", "GetResults failed"); } DWORD item_count = 0; - IShellItemArray_GetCount(items, &item_count); + hr = IShellItemArray_GetCount(items, &item_count); + if (FAILED(hr)) { + IShellItemArray_Release(items); + IFileOpenDialog_Release(dlg); + gui_free_filter_specs(specs, spec_count); + gui_win_com_scope_uninit(com); + return gui_win_result_error( + "results", "GetCount failed"); + } if (item_count == 0) { IShellItemArray_Release(items); IFileOpenDialog_Release(dlg); gui_free_filter_specs(specs, spec_count); - CoUninitialize(); + gui_win_com_scope_uninit(com); return gui_win_result_cancel(); } char** paths = (char**)calloc(item_count, sizeof(char*)); + if (paths == NULL) { + IShellItemArray_Release(items); + IFileOpenDialog_Release(dlg); + gui_free_filter_specs(specs, spec_count); + gui_win_com_scope_uninit(com); + return gui_win_result_error( + "allocation", "allocation failed"); + } int valid = 0; for (DWORD i = 0; i < item_count; i++) { IShellItem* item = NULL; @@ -361,7 +622,7 @@ GuiNativeDialogResultEx gui_native_open_dialog_ex( "internal", "no valid paths"); } free(paths); - CoUninitialize(); + gui_win_com_scope_uninit(com); return result; } @@ -375,9 +636,9 @@ GuiNativeDialogResultEx gui_native_save_dialog_ex( int confirm_overwrite ) { HRESULT hr; - hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); - if (FAILED(hr) && hr != RPC_E_CHANGED_MODE - && hr != S_FALSE) { + GuiWinComScope com = gui_win_com_scope_init(); + hr = com.hr; + if (FAILED(hr)) { return gui_win_result_error( "com_init", "CoInitializeEx failed"); } @@ -387,7 +648,7 @@ GuiNativeDialogResultEx gui_native_save_dialog_ex( &CLSID_FileSaveDialog, NULL, CLSCTX_INPROC_SERVER, &IID_IFileSaveDialog, (void**)&dlg); if (FAILED(hr) || dlg == NULL) { - CoUninitialize(); + gui_win_com_scope_uninit(com); return gui_win_result_error( "com_create", "IFileSaveDialog create failed"); } @@ -445,13 +706,13 @@ GuiNativeDialogResultEx gui_native_save_dialog_ex( if (hr == HRESULT_FROM_WIN32(ERROR_CANCELLED)) { IFileSaveDialog_Release(dlg); gui_free_filter_specs(specs, spec_count); - CoUninitialize(); + gui_win_com_scope_uninit(com); return gui_win_result_cancel(); } if (FAILED(hr)) { IFileSaveDialog_Release(dlg); gui_free_filter_specs(specs, spec_count); - CoUninitialize(); + gui_win_com_scope_uninit(com); return gui_win_result_error( "show", "dialog Show failed"); } @@ -462,7 +723,7 @@ GuiNativeDialogResultEx gui_native_save_dialog_ex( if (FAILED(hr) || item == NULL) { IFileSaveDialog_Release(dlg); gui_free_filter_specs(specs, spec_count); - CoUninitialize(); + gui_win_com_scope_uninit(com); return gui_win_result_error( "result", "GetResult failed"); } @@ -474,12 +735,18 @@ GuiNativeDialogResultEx gui_native_save_dialog_ex( GuiNativeDialogResultEx result; if (path != NULL) { - result = gui_win_result_paths(&path, 1); + if (confirm_overwrite == 0 && gui_win_path_exists(path)) { + free(path); + result = gui_win_result_error( + "overwrite_disallowed", "file already exists"); + } else { + result = gui_win_result_paths(&path, 1); + } } else { result = gui_win_result_error( "internal", "empty path from save dialog"); } - CoUninitialize(); + gui_win_com_scope_uninit(com); return result; } @@ -490,9 +757,9 @@ GuiNativeDialogResultEx gui_native_folder_dialog_ex( int can_create_directories ) { HRESULT hr; - hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); - if (FAILED(hr) && hr != RPC_E_CHANGED_MODE - && hr != S_FALSE) { + GuiWinComScope com = gui_win_com_scope_init(); + hr = com.hr; + if (FAILED(hr)) { return gui_win_result_error( "com_init", "CoInitializeEx failed"); } @@ -502,7 +769,7 @@ GuiNativeDialogResultEx gui_native_folder_dialog_ex( &CLSID_FileOpenDialog, NULL, CLSCTX_INPROC_SERVER, &IID_IFileOpenDialog, (void**)&dlg); if (FAILED(hr) || dlg == NULL) { - CoUninitialize(); + gui_win_com_scope_uninit(com); return gui_win_result_error( "com_create", "IFileOpenDialog create failed"); } @@ -529,12 +796,12 @@ GuiNativeDialogResultEx gui_native_folder_dialog_ex( hr = IFileDialog_Show((IFileDialog*)dlg, owner); if (hr == HRESULT_FROM_WIN32(ERROR_CANCELLED)) { IFileOpenDialog_Release(dlg); - CoUninitialize(); + gui_win_com_scope_uninit(com); return gui_win_result_cancel(); } if (FAILED(hr)) { IFileOpenDialog_Release(dlg); - CoUninitialize(); + gui_win_com_scope_uninit(com); return gui_win_result_error( "show", "dialog Show failed"); } @@ -544,7 +811,7 @@ GuiNativeDialogResultEx gui_native_folder_dialog_ex( hr = IFileDialog_GetResult((IFileDialog*)dlg, &item); if (FAILED(hr) || item == NULL) { IFileOpenDialog_Release(dlg); - CoUninitialize(); + gui_win_com_scope_uninit(com); return gui_win_result_error( "result", "GetResult failed"); } @@ -560,7 +827,7 @@ GuiNativeDialogResultEx gui_native_folder_dialog_ex( result = gui_win_result_error( "internal", "empty path from folder dialog"); } - CoUninitialize(); + gui_win_com_scope_uninit(com); return result; } diff --git a/nativebridge/notification_windows.c b/nativebridge/notification_windows.c index 6f3d8b8..46e78e4 100644 --- a/nativebridge/notification_windows.c +++ b/nativebridge/notification_windows.c @@ -11,9 +11,130 @@ #include #include +#include #include "notification_bridge.h" +#define GUI_NOTIF_CALLBACK_MESSAGE (WM_APP + 0x4e01) +#define GUI_NOTIF_ICON_ID 1 +#define GUI_NOTIF_WAIT_MS 5000 + +typedef struct GuiNotifWindowState { + int balloon_done; +} GuiNotifWindowState; + +static const wchar_t gui_notif_window_class[] = + L"VGuiNativeNotificationOwner"; + +static UINT gui_notif_callback_code(LPARAM lparam) { + UINT low = (UINT)LOWORD(lparam); + UINT high = (UINT)HIWORD(lparam); + if (low == NIN_BALLOONHIDE || + low == NIN_BALLOONTIMEOUT || + low == NIN_BALLOONUSERCLICK) { + return low; + } + if (high == NIN_BALLOONHIDE || + high == NIN_BALLOONTIMEOUT || + high == NIN_BALLOONUSERCLICK) { + return high; + } + return (UINT)lparam; +} + +static LRESULT CALLBACK gui_notif_wnd_proc( + HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam +) { + (void)wparam; + if (msg == GUI_NOTIF_CALLBACK_MESSAGE) { + GuiNotifWindowState* state = + (GuiNotifWindowState*)GetWindowLongPtrW( + hwnd, GWLP_USERDATA); + UINT code = gui_notif_callback_code(lparam); + if (state != NULL && + (code == NIN_BALLOONHIDE || + code == NIN_BALLOONTIMEOUT || + code == NIN_BALLOONUSERCLICK)) { + state->balloon_done = 1; + } + return 0; + } + if (msg == WM_NCDESTROY) { + SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0); + } + return DefWindowProcW(hwnd, msg, wparam, lparam); +} + +static int gui_notif_register_window_class(void) { + WNDCLASSW wc; + ZeroMemory(&wc, sizeof(wc)); + wc.lpfnWndProc = gui_notif_wnd_proc; + wc.hInstance = GetModuleHandleW(NULL); + wc.lpszClassName = gui_notif_window_class; + + if (RegisterClassW(&wc) != 0) { + return 1; + } + return GetLastError() == ERROR_CLASS_ALREADY_EXISTS; +} + +static HWND gui_notif_create_owner_window( + GuiNotifWindowState* state +) { + if (!gui_notif_register_window_class()) { + return NULL; + } + HWND hwnd = CreateWindowExW( + 0, + gui_notif_window_class, + L"", + 0, + 0, 0, 0, 0, + HWND_MESSAGE, + NULL, + GetModuleHandleW(NULL), + NULL); + if (hwnd == NULL) { + return NULL; + } + SetWindowLongPtrW(hwnd, GWLP_USERDATA, (LONG_PTR)state); + return hwnd; +} + +static void gui_notif_pump_bounded( + HWND hwnd, GuiNotifWindowState* state, DWORD timeout_ms +) { + DWORD start = GetTickCount(); + MSG msg; + while (!state->balloon_done) { + DWORD elapsed = GetTickCount() - start; + if (elapsed >= timeout_ms) { + break; + } + while (PeekMessageW(&msg, hwnd, 0, 0, PM_REMOVE)) { + elapsed = GetTickCount() - start; + if (elapsed >= timeout_ms) { + return; + } + TranslateMessage(&msg); + DispatchMessageW(&msg); + if (state->balloon_done) { + break; + } + } + if (state->balloon_done) { + break; + } + DWORD remaining = timeout_ms - elapsed; + DWORD slice = remaining < 50 ? remaining : 50; + if (slice == 0) { + break; + } + MsgWaitForMultipleObjectsEx( + 0, NULL, slice, QS_ALLINPUT, MWMO_INPUTAVAILABLE); + } +} + // Convert UTF-8 to wide string. Caller must free result. static wchar_t* gui_notif_utf8_to_wide(const char* utf8) { if (utf8 == NULL || utf8[0] == '\0') return NULL; @@ -61,38 +182,84 @@ GuiNativeNotificationResult gui_native_send_notification( } wchar_t* w_body = gui_notif_utf8_to_wide(body); + GuiNotifWindowState state; + ZeroMemory(&state, sizeof(state)); + HWND hwnd = gui_notif_create_owner_window(&state); + if (hwnd == NULL) { + free(w_title); + if (w_body) free(w_body); + return gui_notif_result_error( + "shell", "notification owner window creation failed"); + } + NOTIFYICONDATAW nid; ZeroMemory(&nid, sizeof(nid)); nid.cbSize = sizeof(NOTIFYICONDATAW); - nid.uFlags = NIF_INFO | NIF_ICON | NIF_TIP; + nid.hWnd = hwnd; + nid.uID = GUI_NOTIF_ICON_ID; + nid.uCallbackMessage = GUI_NOTIF_CALLBACK_MESSAGE; + nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP; nid.dwInfoFlags = NIIF_INFO; nid.hIcon = LoadIcon(NULL, IDI_APPLICATION); - gui_notif_copy_wide( - nid.szInfoTitle, 64, w_title); - gui_notif_copy_wide( - nid.szInfo, 256, w_body); gui_notif_copy_wide( nid.szTip, 128, w_title); - // Add icon, show balloon, then remove icon. BOOL ok = Shell_NotifyIconW(NIM_ADD, &nid); if (!ok) { + DestroyWindow(hwnd); free(w_title); if (w_body) free(w_body); return gui_notif_result_error( "shell", "Shell_NotifyIconW NIM_ADD failed"); } - Shell_NotifyIconW(NIM_MODIFY, &nid); - // Brief sleep so the balloon has time to appear before - // the tray icon is removed. Without this the balloon - // may never display on some Windows versions. - Sleep(100); - Shell_NotifyIconW(NIM_DELETE, &nid); + nid.uVersion = NOTIFYICON_VERSION_4; + ok = Shell_NotifyIconW(NIM_SETVERSION, &nid); + if (!ok) { + BOOL delete_ok = Shell_NotifyIconW(NIM_DELETE, &nid); + DestroyWindow(hwnd); + free(w_title); + if (w_body) free(w_body); + if (!delete_ok) { + return gui_notif_result_error( + "shell_cleanup", + "Shell_NotifyIconW NIM_SETVERSION failed; cleanup NIM_DELETE also failed"); + } + return gui_notif_result_error( + "shell", "Shell_NotifyIconW NIM_SETVERSION failed"); + } + + nid.uFlags = NIF_INFO; + gui_notif_copy_wide( + nid.szInfoTitle, 64, w_title); + gui_notif_copy_wide( + nid.szInfo, 256, w_body); + ok = Shell_NotifyIconW(NIM_MODIFY, &nid); + if (!ok) { + BOOL delete_ok = Shell_NotifyIconW(NIM_DELETE, &nid); + DestroyWindow(hwnd); + free(w_title); + if (w_body) free(w_body); + if (!delete_ok) { + return gui_notif_result_error( + "shell_cleanup", + "Shell_NotifyIconW NIM_MODIFY failed; cleanup NIM_DELETE also failed"); + } + return gui_notif_result_error( + "shell", "Shell_NotifyIconW NIM_MODIFY failed"); + } + + gui_notif_pump_bounded(hwnd, &state, GUI_NOTIF_WAIT_MS); + ok = Shell_NotifyIconW(NIM_DELETE, &nid); + DestroyWindow(hwnd); free(w_title); if (w_body) free(w_body); + if (!ok) { + return gui_notif_result_error( + "shell", "Shell_NotifyIconW NIM_DELETE failed"); + } return gui_notif_result_ok(); } diff --git a/nativebridge/readback_bridge.h b/nativebridge/readback_bridge.h index c71c74b..e5927f0 100644 --- a/nativebridge/readback_bridge.h +++ b/nativebridge/readback_bridge.h @@ -11,7 +11,8 @@ extern "C" { // Uses a blit to a shared staging texture for reliable // readback from private-storage render targets. // mtl_device is used to create a transient command queue. -// Caller must free returned buffer. Returns NULL on failure. +// Returned buffer must be released with gui_readback_buffer_free. +// Returns NULL on failure. uint8_t* gui_readback_metal_texture( void* mtl_texture, void* mtl_device, @@ -21,7 +22,8 @@ uint8_t* gui_readback_metal_texture( // Read RGBA pixels from an OpenGL framebuffer via // glReadPixels. Rows are flipped to top-down order. -// Caller must free returned buffer. Returns NULL on failure. +// Returned buffer must be released with gui_readback_buffer_free. +// Returns NULL on failure. uint8_t* gui_readback_gl_framebuffer( uint32_t framebuffer, int width, @@ -29,8 +31,12 @@ uint8_t* gui_readback_gl_framebuffer( ); // Read BGRA pixels from a D3D11 render-target texture via -// staging texture copy. Caller must free returned buffer. -// Returns NULL on failure. Windows only. +// staging texture copy. Supports single-sample BGRA8 textures +// (DXGI_FORMAT_B8G8R8A8_UNORM or _SRGB), one mip and one array +// slice. Dimensions must match the source texture. Unsupported +// formats, MSAA textures and unsafe row/size layouts fail by +// returning NULL. Returned buffer must be released with +// gui_readback_buffer_free. Windows only. uint8_t* gui_readback_d3d11_texture( void* d3d11_texture, void* d3d11_device, @@ -39,6 +45,9 @@ uint8_t* gui_readback_d3d11_texture( int height ); +// Releases buffers returned by readback functions using the backend C allocator. +void gui_readback_buffer_free(uint8_t* buffer); + #ifdef __cplusplus } #endif diff --git a/nativebridge/readback_d3d11_warp_test.h b/nativebridge/readback_d3d11_warp_test.h new file mode 100644 index 0000000..616deae --- /dev/null +++ b/nativebridge/readback_d3d11_warp_test.h @@ -0,0 +1,229 @@ +#ifndef GUI_READBACK_D3D11_WARP_TEST_H +#define GUI_READBACK_D3D11_WARP_TEST_H + +#ifdef _WIN32 + +#ifndef COBJMACROS +#define COBJMACROS +#endif + +#include +#include +#include + +uint8_t* gui_readback_d3d11_texture( + void* d3d11_texture, + void* d3d11_device, + void* d3d11_context, + int width, + int height +); +void gui_readback_buffer_free(uint8_t* buffer); + +static int gui_readback_d3d11_create_warp( + ID3D11Device** out_device, + ID3D11DeviceContext** out_context +) { + static const D3D_FEATURE_LEVEL levels[] = { + D3D_FEATURE_LEVEL_11_0, + D3D_FEATURE_LEVEL_10_1, + D3D_FEATURE_LEVEL_10_0, + }; + D3D_FEATURE_LEVEL feature_level; + HRESULT hr = D3D11CreateDevice( + NULL, + D3D_DRIVER_TYPE_WARP, + NULL, + D3D11_CREATE_DEVICE_BGRA_SUPPORT, + levels, + sizeof(levels) / sizeof(levels[0]), + D3D11_SDK_VERSION, + out_device, + &feature_level, + out_context); + return SUCCEEDED(hr) && *out_device != NULL && *out_context != NULL; +} + +static void gui_readback_d3d11_release_all( + ID3D11Texture2D* texture, + ID3D11DeviceContext* context, + ID3D11Device* device +) { + if (texture != NULL) { + ID3D11Texture2D_Release(texture); + } + if (context != NULL) { + ID3D11DeviceContext_Release(context); + } + if (device != NULL) { + ID3D11Device_Release(device); + } +} + +static int gui_readback_d3d11_create_texture( + ID3D11Device* device, + DXGI_FORMAT format, + UINT sample_count, + const uint8_t* pixels, + ID3D11Texture2D** out_texture +) { + D3D11_TEXTURE2D_DESC desc; + memset(&desc, 0, sizeof(desc)); + desc.Width = 2; + desc.Height = 2; + desc.MipLevels = 1; + desc.ArraySize = 1; + desc.Format = format; + desc.SampleDesc.Count = sample_count; + desc.SampleDesc.Quality = 0; + desc.Usage = D3D11_USAGE_DEFAULT; + desc.BindFlags = D3D11_BIND_RENDER_TARGET; + desc.CPUAccessFlags = 0; + desc.MiscFlags = 0; + + D3D11_SUBRESOURCE_DATA data; + D3D11_SUBRESOURCE_DATA* data_ptr = NULL; + if (pixels != NULL) { + memset(&data, 0, sizeof(data)); + data.pSysMem = pixels; + data.SysMemPitch = 2 * 4; + data_ptr = &data; + } + + HRESULT hr = ID3D11Device_CreateTexture2D( + device, + &desc, + data_ptr, + out_texture); + return SUCCEEDED(hr) && *out_texture != NULL; +} + +static int gui_readback_d3d11_warp_roundtrip_test(void) { + ID3D11Device* device = NULL; + ID3D11DeviceContext* context = NULL; + ID3D11Texture2D* texture = NULL; + static const uint8_t expected[] = { + 0x01, 0x02, 0x03, 0xff, + 0x10, 0x20, 0x30, 0xff, + 0x40, 0x50, 0x60, 0xff, + 0x70, 0x80, 0x90, 0xff, + }; + + if (!gui_readback_d3d11_create_warp(&device, &context)) { + gui_readback_d3d11_release_all(texture, context, device); + return 0; + } + if (!gui_readback_d3d11_create_texture( + device, + DXGI_FORMAT_B8G8R8A8_UNORM, + 1, + expected, + &texture)) { + gui_readback_d3d11_release_all(texture, context, device); + return 0; + } + + uint8_t* actual = gui_readback_d3d11_texture( + texture, + device, + context, + 2, + 2); + if (actual == NULL) { + gui_readback_d3d11_release_all(texture, context, device); + return 0; + } + + int ok = memcmp(actual, expected, sizeof(expected)) == 0; + gui_readback_buffer_free(actual); + gui_readback_d3d11_release_all(texture, context, device); + return ok ? 1 : 0; +} + +static int gui_readback_d3d11_rejects_unsupported_format_test(void) { + ID3D11Device* device = NULL; + ID3D11DeviceContext* context = NULL; + ID3D11Texture2D* texture = NULL; + static const uint8_t pixels[] = { + 0x01, 0x02, 0x03, 0xff, + 0x10, 0x20, 0x30, 0xff, + 0x40, 0x50, 0x60, 0xff, + 0x70, 0x80, 0x90, 0xff, + }; + + if (!gui_readback_d3d11_create_warp(&device, &context)) { + gui_readback_d3d11_release_all(texture, context, device); + return 0; + } + if (!gui_readback_d3d11_create_texture( + device, + DXGI_FORMAT_R8G8B8A8_UNORM, + 1, + pixels, + &texture)) { + gui_readback_d3d11_release_all(texture, context, device); + return 0; + } + + uint8_t* actual = gui_readback_d3d11_texture( + texture, + device, + context, + 2, + 2); + int ok = actual == NULL; + if (actual != NULL) { + gui_readback_buffer_free(actual); + } + gui_readback_d3d11_release_all(texture, context, device); + return ok ? 1 : 0; +} + +static int gui_readback_d3d11_rejects_msaa_test(void) { + ID3D11Device* device = NULL; + ID3D11DeviceContext* context = NULL; + ID3D11Texture2D* texture = NULL; + UINT quality_levels = 0; + + if (!gui_readback_d3d11_create_warp(&device, &context)) { + gui_readback_d3d11_release_all(texture, context, device); + return 0; + } + + HRESULT hr = ID3D11Device_CheckMultisampleQualityLevels( + device, + DXGI_FORMAT_B8G8R8A8_UNORM, + 2, + &quality_levels); + if (FAILED(hr) || quality_levels == 0) { + gui_readback_d3d11_release_all(texture, context, device); + return 1; + } + + if (!gui_readback_d3d11_create_texture( + device, + DXGI_FORMAT_B8G8R8A8_UNORM, + 2, + NULL, + &texture)) { + gui_readback_d3d11_release_all(texture, context, device); + return 0; + } + + uint8_t* actual = gui_readback_d3d11_texture( + texture, + device, + context, + 2, + 2); + int ok = actual == NULL; + if (actual != NULL) { + gui_readback_buffer_free(actual); + } + gui_readback_d3d11_release_all(texture, context, device); + return ok ? 1 : 0; +} + +#endif + +#endif diff --git a/nativebridge/readback_linux.c b/nativebridge/readback_linux.c index 080473f..01807a1 100644 --- a/nativebridge/readback_linux.c +++ b/nativebridge/readback_linux.c @@ -5,6 +5,10 @@ #include #include "readback_bridge.h" +void gui_readback_buffer_free(uint8_t* buffer) { + free(buffer); +} + uint8_t* gui_readback_gl_framebuffer( uint32_t framebuffer, int width, int height ) { diff --git a/nativebridge/readback_macos.m b/nativebridge/readback_macos.m index 4472b2f..01cc420 100644 --- a/nativebridge/readback_macos.m +++ b/nativebridge/readback_macos.m @@ -2,6 +2,10 @@ #include #include "readback_bridge.h" +void gui_readback_buffer_free(uint8_t* buffer) { + free(buffer); +} + uint8_t* gui_readback_metal_texture( void* mtl_texture, void* mtl_device, diff --git a/nativebridge/readback_windows.c b/nativebridge/readback_windows.c index e5475f4..78e2f66 100644 --- a/nativebridge/readback_windows.c +++ b/nativebridge/readback_windows.c @@ -1,15 +1,59 @@ // readback_windows.c — D3D11 GPU texture readback. // Mirrors the Metal readback pattern: create a staging // texture, CopyResource, Map, copy rows, Unmap. -// Returns malloc'd BGRA buffer. Caller must free(). +// Returns malloc'd BGRA buffer owned by the readback bridge. #ifdef _WIN32 +#ifndef COBJMACROS +#define COBJMACROS +#endif + #include +#include +#include #include #include #include "readback_bridge.h" +enum { + gui_d3d11_readback_bytes_per_pixel = 4, +}; + +static int gui_d3d11_readback_format_supported(DXGI_FORMAT format) { + return format == DXGI_FORMAT_B8G8R8A8_UNORM + || format == DXGI_FORMAT_B8G8R8A8_UNORM_SRGB; +} + +static int gui_d3d11_readback_size_checked( + int width, + int height, + size_t* out_row_bytes, + size_t* out_size +) { + if (width <= 0 || height <= 0) return 0; + size_t w = (size_t)width; + size_t h = (size_t)height; + if (w > SIZE_MAX / gui_d3d11_readback_bytes_per_pixel) { + return 0; + } + size_t row_bytes = w * gui_d3d11_readback_bytes_per_pixel; + if (h > SIZE_MAX / row_bytes) { + return 0; + } + size_t size = row_bytes * h; + if (size > (size_t)INT_MAX) { + return 0; + } + *out_row_bytes = row_bytes; + *out_size = size; + return 1; +} + +void gui_readback_buffer_free(uint8_t* buffer) { + free(buffer); +} + uint8_t* gui_readback_d3d11_texture( void* texture_ptr, void* device_ptr, @@ -23,17 +67,29 @@ uint8_t* gui_readback_d3d11_texture( return NULL; } + size_t row_bytes = 0; + size_t size = 0; + if (!gui_d3d11_readback_size_checked( + width, height, &row_bytes, &size)) { + return NULL; + } + ID3D11Texture2D* src = (ID3D11Texture2D*)texture_ptr; ID3D11Device* device = (ID3D11Device*)device_ptr; ID3D11DeviceContext* ctx = (ID3D11DeviceContext*)context_ptr; - // Describe staging texture matching the render target. + // This bridge returns BGRA bytes for the common Sokol/D3D11 render-target + // path. Other formats need an explicit conversion path before support. D3D11_TEXTURE2D_DESC desc; ID3D11Texture2D_GetDesc(src, &desc); - desc.Width = (UINT)width; - desc.Height = (UINT)height; - desc.MipLevels = 1; - desc.ArraySize = 1; + if (desc.Width != (UINT)width || desc.Height != (UINT)height + || desc.MipLevels != 1 || desc.ArraySize != 1 + || desc.SampleDesc.Count != 1 + || !gui_d3d11_readback_format_supported(desc.Format)) { + return NULL; + } + + // Describe staging texture matching the validated render target. desc.Usage = D3D11_USAGE_STAGING; desc.BindFlags = 0; desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ; @@ -62,9 +118,14 @@ uint8_t* gui_readback_d3d11_texture( return NULL; } + if (mapped.pData == NULL || mapped.RowPitch < row_bytes) { + ID3D11DeviceContext_Unmap( + ctx, (ID3D11Resource*)staging, 0); + ID3D11Texture2D_Release(staging); + return NULL; + } + // Copy pixel data row by row (pitch may differ). - size_t row_bytes = (size_t)width * 4; - size_t size = row_bytes * (size_t)height; uint8_t* buf = (uint8_t*)malloc(size); if (buf != NULL) { const uint8_t* src_data = (const uint8_t*)mapped.pData; diff --git a/window.v b/window.v index 7de5f09..45c8f54 100644 --- a/window.v +++ b/window.v @@ -144,7 +144,11 @@ pub fn window(cfg &WindowCfg) &Window { // Initialize text rendering system w.text_system = vglyph.new_text_system(mut w.ui) or { - w.init_error = 'Failed to initialize text rendering system: ${err.str()}\n\nThis is typically caused by OpenGL compatibility issues.' + $if windows { + w.init_error = windows_text_system_setup_message(err.str()) + } $else { + w.init_error = 'Failed to initialize text rendering system: ${err.str()}\n\nThis is typically caused by OpenGL compatibility issues.' + } log.error(w.init_error) sapp.quit() return diff --git a/windows_setup_preflight.v b/windows_setup_preflight.v new file mode 100644 index 0000000..4c5507b --- /dev/null +++ b/windows_setup_preflight.v @@ -0,0 +1,7 @@ +module gui + +import winsetup + +fn windows_text_system_setup_message(raw_error string) string { + return winsetup.text_system_message(raw_error) +} diff --git a/winsetup/preflight.v b/winsetup/preflight.v new file mode 100644 index 0000000..26cb8b7 --- /dev/null +++ b/winsetup/preflight.v @@ -0,0 +1,101 @@ +module winsetup + +const text_system_prefix = 'Failed to initialize text rendering system on Windows.' +const script_prefix = 'Failed Windows setup preflight.' +const text_system_default_cause = 'The text stack did not initialize before the GUI could start.' +const script_default_cause = 'The Windows text setup probe failed before the GUI build could be validated.' + +pub fn text_system_message(raw_error string) string { + return message(raw_error, text_system_prefix, text_system_default_cause) +} + +pub fn script_message(raw_error string) string { + return message(raw_error, script_prefix, script_default_cause) +} + +pub fn message(raw_error string, prefix string, default_cause string) string { + detail := raw_error.trim_space() + lower := detail.to_lower() + mut cause := default_cause + mut action := 'Use native Windows with MSVC and vcpkg. Run `vcpkg install pango freetype` and `v install vglyph` from the same x64 Developer PowerShell or Native Tools shell that runs `v`.' + + if mentions_vglyph_module(lower) { + cause = '`vglyph` is not installed or is not visible to this V toolchain.' + action = 'Run `v install vglyph`, then retry the same command with the same V executable.' + } else if mentions_pango_header(lower) { + cause = 'Pango headers are missing or not discoverable.' + action = 'Run `vcpkg install pango freetype` for the active MSVC/vcpkg triplet, then retry from the same native Windows shell.' + } else if mentions_freetype_header(lower) { + cause = 'Freetype headers are missing or not discoverable.' + action = 'Run `vcpkg install freetype pango` for the active MSVC/vcpkg triplet, then retry from the same native Windows shell.' + } else if mentions_unresolved_text_symbol(lower) { + cause = 'Pango/Freetype libraries are not linked for the compiler that is building the app.' + action = 'Keep one Windows toolchain at a time. For the supported path, use MSVC plus matching vcpkg `x64-windows` packages; do not mix MSVC objects with MSYS2 libraries.' + } else if mentions_missing_dll(lower) { + cause = 'A Pango/Freetype/HarfBuzz/FriBidi/Fontconfig runtime DLL is missing when the executable starts.' + action = 'Treat this as a setup blocker. The final user path should not rely on random DLL copying or global PATH edits; validate dependency deployment through the Windows smoke path.' + } else if mentions_vcpkg(lower) { + cause = 'vcpkg is not visible or the required text packages are not installed.' + action = 'Install or expose vcpkg in the same native Windows shell, then run `vcpkg install pango freetype` for the active MSVC triplet.' + } else if mentions_msvc(lower) { + cause = 'The MSVC compiler or Windows SDK is not visible to the build.' + action = 'Open an x64 Developer PowerShell or x64 Native Tools shell with the Desktop C++ workload installed, then rerun `v`.' + } else if mentions_msys2(lower) { + cause = 'MSYS2/MinGW dependency discovery is being used.' + action = 'MSYS2/GCC is exploratory for this project. Prefer MSVC/vcpkg; if validating MinGW, run inside MINGW64 with matching `mingw-w64-x86_64-pango` and `mingw-w64-x86_64-freetype` packages.' + } + + return '${prefix}\n\nLikely cause: ${cause}\n\nAction: ${action}\n\nDetails: ${detail_text(detail)}' +} + +fn detail_text(detail string) string { + if detail == '' { + return 'No lower-level error was provided.' + } + return detail +} + +fn mentions_vglyph_module(lower string) bool { + return (lower.contains('module vglyph') && lower.contains('not found')) + || lower.contains('cannot import module "vglyph"') + || lower.contains("cannot import module 'vglyph'") +} + +fn mentions_pango_header(lower string) bool { + return lower.contains('pango/pango.h') || lower.contains('pango.h') + || (lower.contains('pango') && lower.contains('no such file or directory')) +} + +fn mentions_freetype_header(lower string) bool { + return lower.contains('ft2build.h') || lower.contains('freetype.h') + || lower.contains('freetype/freetype.h') +} + +fn mentions_unresolved_text_symbol(lower string) bool { + has_text_symbol := lower.contains('pango_') || lower.contains('ft_') || lower.contains('hb_') + || lower.contains('fribidi') || lower.contains('fontconfig') + return (lower.contains('unresolved external symbol') && has_text_symbol) + || (lower.contains('undefined reference') && has_text_symbol) +} + +fn mentions_missing_dll(lower string) bool { + return lower.contains('.dll') && (lower.contains('was not found') + || lower.contains('could not be found') || lower.contains('missing') + || lower.contains('cannot find')) +} + +fn mentions_vcpkg(lower string) bool { + return lower.contains('vcpkg') && (lower.contains('not found') + || lower.contains('missing') || lower.contains('not installed') + || lower.contains('failed')) +} + +fn mentions_msvc(lower string) bool { + return lower.contains('cl.exe') || lower.contains('msvc') || lower.contains('link.exe') + || lower.contains('windows sdk') +} + +fn mentions_msys2(lower string) bool { + return lower.contains('msys2') || lower.contains('mingw') || lower.contains('ucrt64') + || lower.contains('mingw64') +} diff --git a/winsetup/preflight_test.v b/winsetup/preflight_test.v new file mode 100644 index 0000000..be83434 --- /dev/null +++ b/winsetup/preflight_test.v @@ -0,0 +1,96 @@ +module winsetup + +fn test_text_system_message_maps_missing_vglyph_module() { + message := text_system_message('builder error: module vglyph not found') + + assert message.contains('Failed to initialize text rendering system on Windows.') + assert message.contains('`vglyph` is not installed') + assert message.contains('v install vglyph') + assert message.contains('module vglyph not found') +} + +fn test_text_system_message_maps_pango_header() { + message := text_system_message('fatal error C1083: cannot open include file: pango/pango.h') + + assert message.contains('Pango headers are missing') + assert message.contains('vcpkg install pango freetype') + assert message.contains('native Windows shell') +} + +fn test_text_system_message_maps_freetype_header() { + message := text_system_message('fatal error: ft2build.h: No such file or directory') + + assert message.contains('Freetype headers are missing') + assert message.contains('vcpkg install freetype pango') + assert message.contains('MSVC/vcpkg') +} + +fn test_text_system_message_maps_unresolved_text_symbols() { + message := + text_system_message('LNK2019: unresolved external symbol pango_layout_new referenced in function') + + assert message.contains('Pango/Freetype libraries are not linked') + assert message.contains('matching vcpkg `x64-windows` packages') + assert message.contains('do not mix MSVC objects with MSYS2 libraries') +} + +fn test_text_system_message_maps_missing_dll() { + message := + text_system_message('The code execution cannot proceed because libpango-1.0-0.dll was not found.') + + assert message.contains('runtime DLL is missing') + assert message.contains('setup blocker') + assert message.contains('should not rely on random DLL copying') + assert !message.contains('vglyph' + ' runtime DLL') +} + +fn test_text_system_message_maps_vcpkg_setup() { + message := text_system_message('vcpkg missing required packages: pango') + + assert message.contains('vcpkg is not visible or the required text packages are not installed') + assert message.contains('vcpkg install pango freetype') + assert message.contains('MSVC triplet') +} + +fn test_text_system_message_maps_msvc_setup() { + message := text_system_message('cl.exe was not found; Windows SDK is missing') + + assert message.contains('MSVC compiler or Windows SDK is not visible') + assert message.contains('x64 Developer PowerShell') + assert message.contains('x64 Native Tools shell') +} + +fn test_text_system_message_maps_msys2_mingw_setup() { + message := text_system_message('msys2 mingw64 pkg-config could not find pango') + + assert message.contains('MSYS2/MinGW dependency discovery is being used') + assert message.contains('MSYS2/GCC is exploratory') + assert message.contains('MINGW64') + assert message.contains('mingw-w64-x86_64-pango') + assert message.contains('mingw-w64-x86_64-freetype') +} + +fn test_text_system_message_maps_generic_fallback() { + message := text_system_message('backend returned unknown text init failure') + + assert message.contains('The text stack did not initialize') + assert message.contains('Use native Windows with MSVC and vcpkg') + assert message.contains('vcpkg install pango freetype') + assert message.contains('backend returned unknown text init failure') +} + +fn test_script_message_uses_same_rules_with_script_prefix() { + message := script_message('cl.exe was not found; Windows SDK is missing') + + assert message.contains('Failed Windows setup preflight.') + assert message.contains('MSVC compiler or Windows SDK is not visible') + assert message.contains('x64 Developer PowerShell') + assert !message.contains('Failed to initialize text rendering system') +} + +fn test_script_message_maps_empty_detail() { + message := script_message('') + + assert message.contains('Failed Windows setup preflight.') + assert message.contains('No lower-level error was provided.') +} From db59fbeca753305717adc8499df3acd2cddad6d1 Mon Sep 17 00:00:00 2001 From: GGRei Date: Fri, 12 Jun 2026 14:46:53 +0200 Subject: [PATCH 2/6] ci: stabilize Windows PR checks --- .github/workflows/ci.yml | 111 ++++++++++++++++++++++++++++++-- _windows_setup_preflight_test.v | 3 +- 2 files changed, 109 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca48f10..b6895b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,11 +50,48 @@ jobs: uses: actions/checkout@v4 with: path: gui + fetch-depth: 0 + - name: Verify formatting of changed V files + if: github.event_name == 'pull_request' + working-directory: gui + run: | + base_ref="${{ github.base_ref }}" + git fetch --no-tags origin "$base_ref:refs/remotes/origin/$base_ref" + git diff --name-only -z --diff-filter=ACMRT "origin/${base_ref}...HEAD" -- '*.v' '*.vsh' > /tmp/vfmt-files + if [ ! -s /tmp/vfmt-files ]; then + echo "No changed V/VSH files to format-check." + exit 0 + fi + xargs -0 v fmt -verify -inprocess < /tmp/vfmt-files - name: Verify formatting + if: github.event_name != 'pull_request' run: v fmt -verify -inprocess gui/ - name: Check formatting of MD files run: v check-md gui/ + - name: Check syntax of changed examples + if: github.event_name == 'pull_request' + working-directory: gui + run: | + base_ref="${{ github.base_ref }}" + files_list="${RUNNER_TEMP}/gui-example-syntax-files" + git fetch --no-tags origin "$base_ref:refs/remotes/origin/$base_ref" + git diff --name-only -z --diff-filter=ACMRT "origin/${base_ref}...HEAD" -- 'examples/*.v' 'examples/*.vsh' > "$files_list" + if [ ! -s "$files_list" ]; then + echo "No changed example V/VSH files to syntax-check." + exit 0 + fi + while IFS= read -r -d '' file; do + case "$file" in + *.v) + v -check -N -W "$file" + ;; + *.vsh) + v -check -W "$file" + ;; + esac + done < "$files_list" - name: Check syntax of examples + if: github.event_name != 'pull_request' run: v gui/examples/_check.vsh compiling: @@ -84,6 +121,7 @@ jobs: uses: actions/checkout@v4 with: path: gui + fetch-depth: 0 - name: Run tests env: VFLAGS: -no-parallel -cc clang @@ -92,7 +130,27 @@ jobs: env: VFLAGS: -no-parallel -cc clang run: v should-compile-all gui/examples/ + - name: Check compilation of changed examples with -W + if: github.event_name == 'pull_request' + env: + VFLAGS: -no-parallel -cc clang + working-directory: gui + run: | + base_ref="${{ github.base_ref }}" + files_list="${RUNNER_TEMP}/gui-example-compile-files" + git fetch --no-tags origin "$base_ref:refs/remotes/origin/$base_ref" + git diff --name-only -z --diff-filter=ACMRT "origin/${base_ref}...HEAD" -- 'examples/*.v' > "$files_list" + if [ ! -s "$files_list" ]; then + echo "No changed example .v files to compile with -W." + exit 0 + fi + mkdir -p examples/bin + while IFS= read -r -d '' file; do + output_file="examples/bin/$(basename "${file%.v}")" + v -no-parallel -W -o "$output_file" "$file" + done < "$files_list" - name: Check compilation of examples with -W + if: github.event_name != 'pull_request' env: VFLAGS: -no-parallel -cc clang run: v gui/examples/_build.vsh @@ -101,15 +159,29 @@ jobs: runs-on: windows-latest timeout-minutes: 25 env: + V_VERSION: 0.5.1 VFLAGS: -no-parallel VCPKG_BINARY_CACHE: ${{ github.workspace }}\vcpkg-binary-cache VCPKG_BINARY_SOURCES: clear;files,${{ github.workspace }}\vcpkg-binary-cache,readwrite steps: - name: Install V - id: install-v - uses: vlang/setup-v@v1.4 - with: - check-latest: true + shell: pwsh + run: | + $ProgressPreference = 'SilentlyContinue' + $asset = 'v_windows.zip' + $url = "https://github.com/vlang/v/releases/download/$env:V_VERSION/$asset" + Invoke-WebRequest -Uri $url -OutFile $asset + Expand-Archive -Path $asset -DestinationPath . -Force + $vPath = (Resolve-Path .\v).Path + Add-Content -Path $env:GITHUB_PATH -Value $vPath + - name: Verify V version + shell: pwsh + run: | + $vVersion = v version + Write-Host $vVersion + if ($vVersion -notlike "*$env:V_VERSION*") { + throw "Expected V version $env:V_VERSION" + } - name: Restore vcpkg binary cache uses: actions/cache@v4 with: @@ -205,6 +277,7 @@ jobs: uses: actions/checkout@v4 with: path: gui + fetch-depth: 0 - name: Configure MSVC developer environment uses: ilammy/msvc-dev-cmd@v1 - name: Run Windows setup preflight @@ -219,7 +292,37 @@ jobs: env: VFLAGS: -no-parallel -cc msvc run: v should-compile-all gui/examples/ + - name: Check compilation of changed examples with -W + if: github.event_name == 'pull_request' + env: + VFLAGS: -no-parallel -cc msvc + shell: pwsh + working-directory: gui + run: | + $baseRef = '${{ github.base_ref }}' + git fetch --no-tags origin "${baseRef}:refs/remotes/origin/${baseRef}" + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + $files = @(git diff --name-only --diff-filter=ACMRT "origin/${baseRef}...HEAD" -- 'examples/*.v') + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + if ($files.Count -eq 0) { + Write-Host 'No changed example .v files to compile with -W.' + exit 0 + } + New-Item -ItemType Directory -Force -Path 'examples/bin' | Out-Null + foreach ($file in $files) { + $name = [System.IO.Path]::GetFileNameWithoutExtension($file) + $outputFile = "examples/bin/$name" + v -no-parallel -W -o $outputFile $file + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + } - name: Check compilation of examples with -W + if: github.event_name != 'pull_request' env: VFLAGS: -no-parallel -cc msvc run: v gui/examples/_build.vsh diff --git a/_windows_setup_preflight_test.v b/_windows_setup_preflight_test.v index 31415ea..96c1856 100644 --- a/_windows_setup_preflight_test.v +++ b/_windows_setup_preflight_test.v @@ -18,7 +18,8 @@ fn test_windows_text_system_wrapper_keeps_msvc_actionable_message() { } fn test_windows_preflight_script_imports_winsetup_source_of_truth() { - script := os.read_file('_windows_preflight.vsh') or { panic(err) } + script_path := os.join_path(os.dir(@FILE), '_windows_preflight.vsh') + script := os.read_file(script_path) or { panic(err) } assert script.contains('import winsetup') assert script.contains('winsetup.script_message') From 0e5c7552df5213c7a6e506c95f21352b4bf68fb0 Mon Sep 17 00:00:00 2001 From: JalonSolov Date: Fri, 12 Jun 2026 21:35:20 -0400 Subject: [PATCH 3/6] Update ci.yml --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6895b5..b6fffb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: if: runner.os == 'macOS' run: v retry -- brew install pango harfbuzz freetype2 - name: Checkout the gui module - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: path: gui fetch-depth: 0 @@ -157,7 +157,7 @@ jobs: compiling-on-windows: runs-on: windows-latest - timeout-minutes: 25 + timeout-minutes: 35 env: V_VERSION: 0.5.1 VFLAGS: -no-parallel @@ -274,7 +274,7 @@ jobs: - name: Install vglyph run: v install vglyph - name: Checkout the gui module - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: path: gui fetch-depth: 0 From 25bbef128ea6be88bb0392a81da6007bc41536bc Mon Sep 17 00:00:00 2001 From: Dylan Donnell Date: Sat, 13 Jun 2026 13:24:34 +0100 Subject: [PATCH 4/6] bump windows CI timeout to 50 minutes --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6fffb4..abd4172 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -157,7 +157,7 @@ jobs: compiling-on-windows: runs-on: windows-latest - timeout-minutes: 35 + timeout-minutes: 50 env: V_VERSION: 0.5.1 VFLAGS: -no-parallel From 697d3646b1a6dd32d10446decd8910ee8f0660d6 Mon Sep 17 00:00:00 2001 From: GGRei Date: Sat, 13 Jun 2026 19:27:12 +0200 Subject: [PATCH 5/6] ci: stabilize Windows bundle checks --- .github/workflows/ci.yml | 26 +++++++++++++++++++----- _draw_canvas_test.v | 6 +++--- _locale_bundle_test.v | 33 ++++++++++++++++++++++++++++-- _theme_bundle_test.v | 44 +++++++++++++++++++++++++++++++++------- locale_bundle.v | 9 ++++++-- locale_registry.v | 8 ++++++++ theme_bundle.v | 29 ++++++++++++++++++-------- theme_registry.v | 8 ++++++++ 8 files changed, 135 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0adad35..c64a8ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -157,7 +157,7 @@ jobs: compiling-on-windows: runs-on: windows-latest - timeout-minutes: 50 + timeout-minutes: 60 env: V_VERSION: 0.5.1 VFLAGS: -no-parallel @@ -183,17 +183,19 @@ jobs: throw "Expected V version $env:V_VERSION" } - name: Restore vcpkg binary cache - uses: actions/cache@v4 + id: restore-vcpkg-cache + uses: actions/cache/restore@v4 with: path: ${{ env.VCPKG_BINARY_CACHE }} - key: windows-vcpkg-x64-windows-pango-freetype-v1 + key: windows-vcpkg-x64-windows-pango-freetype-${{ env.V_VERSION }}-v1 restore-keys: | windows-vcpkg-x64-windows-pango-freetype- - name: Restore V module cache - uses: actions/cache@v4 + id: restore-vglyph-cache + uses: actions/cache/restore@v4 with: path: ~/.vmodules/vglyph - key: windows-vmodules-vglyph-v1 + key: windows-vmodules-vglyph-${{ env.V_VERSION }}-v1 restore-keys: | windows-vmodules-vglyph- - name: Install pango and freetype @@ -201,6 +203,12 @@ jobs: run: | New-Item -ItemType Directory -Force -Path "$env:VCPKG_BINARY_CACHE" | Out-Null vcpkg install pango freetype + - name: Save vcpkg binary cache + if: ${{ steps.restore-vcpkg-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: ${{ env.VCPKG_BINARY_CACHE }} + key: ${{ steps.restore-vcpkg-cache.outputs.cache-primary-key }} - name: Expose vcpkg pkg-config paths shell: pwsh run: | @@ -273,6 +281,12 @@ jobs: } - name: Install vglyph run: v install vglyph + - name: Save V module cache + if: ${{ steps.restore-vglyph-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: ~/.vmodules/vglyph + key: ${{ steps.restore-vglyph-cache.outputs.cache-primary-key }} - name: Checkout the gui module uses: actions/checkout@v6 with: @@ -287,6 +301,8 @@ jobs: - name: Run tests env: VFLAGS: -no-parallel -cc msvc + VJOBS: 1 + VTEST_ONLY_FN: test_* run: v test gui/ - name: Check compilation of examples env: diff --git a/_draw_canvas_test.v b/_draw_canvas_test.v index 78eef50..e34633b 100644 --- a/_draw_canvas_test.v +++ b/_draw_canvas_test.v @@ -202,10 +202,10 @@ fn test_arc_to_polyline_min_segments() { } fn test_arc_to_polyline_segment_count_scales_with_radius() { - small := arc_to_polyline(0, 0, 5, 5, 0, 2 * math.pi) - large := arc_to_polyline(0, 0, 200, 200, 0, 2 * math.pi) + small_pts := arc_to_polyline(0, 0, 5, 5, 0, 2 * math.pi) + large_pts := arc_to_polyline(0, 0, 200, 200, 0, 2 * math.pi) // Larger radius should produce more segments. - assert large.len > small.len + assert large_pts.len > small_pts.len } // --------------------- diff --git a/_locale_bundle_test.v b/_locale_bundle_test.v index 86bdd3c..1cfd1dd 100644 --- a/_locale_bundle_test.v +++ b/_locale_bundle_test.v @@ -116,6 +116,22 @@ fn test_locale_parse_bad_json() { assert false, 'expected error for bad JSON' } +fn test_locale_parse_empty_json() { + for content in ['', ' ', '\n\t '] { + if _ := locale_parse(content) { + assert false, 'expected error for empty JSON' + } + } +} + +fn test_locale_parse_non_object_json() { + for content in ['[]', 'null', '"x"', '42'] { + if _ := locale_parse(content) { + assert false, 'expected error for non-object JSON' + } + } +} + fn test_locale_parse_wrong_array_length() { content := '{ "id": "bad-arrays", @@ -133,6 +149,11 @@ fn test_locale_parse_wrong_array_length() { } fn test_locale_registry() { + old_registry := locale_registry_snapshot() + defer { + locale_registry_restore(old_registry) + } + // Built-in locales registered by init() en := locale_get('en-US') or { assert false, 'en-US not found' @@ -192,8 +213,16 @@ fn test_locale_t() { } fn test_locale_load_dir() { - tmp := os.join_path(os.temp_dir(), 'gui_test_locales') - os.mkdir_all(tmp) or {} + old_registry := locale_registry_snapshot() + defer { + locale_registry_restore(old_registry) + } + tmp := os.join_path(os.temp_dir(), 'gui_test_locales_${os.getpid()}') + os.rmdir_all(tmp) or {} + os.mkdir_all(tmp) or { + assert false, err.str() + return + } defer { os.rmdir_all(tmp) or {} } diff --git a/_theme_bundle_test.v b/_theme_bundle_test.v index 46a925a..7860fdd 100644 --- a/_theme_bundle_test.v +++ b/_theme_bundle_test.v @@ -150,6 +150,22 @@ fn test_theme_parse_bad_json() { } } +fn test_theme_parse_empty_json() { + for content in ['', ' ', '\n\t '] { + if _ := theme_parse(content) { + assert false, 'expected error for empty JSON' + } + } +} + +fn test_theme_parse_non_object_json() { + for content in ['[]', 'null', '"x"', '42'] { + if _ := theme_parse(content) { + assert false, 'expected error for non-object JSON' + } + } +} + fn test_theme_to_json_roundtrip() { cfg := theme_dark_cfg json_str := theme_to_json(cfg) @@ -176,8 +192,12 @@ fn test_theme_to_json_roundtrip() { } fn test_theme_save_load() { - dir := os.join_path(os.temp_dir(), 'gui_theme_test') - os.mkdir_all(dir) or {} + dir := os.join_path(os.temp_dir(), 'gui_theme_test_${os.getpid()}') + os.rmdir_all(dir) or {} + os.mkdir_all(dir) or { + assert false, err.str() + return + } defer { os.rmdir_all(dir) or {} } @@ -196,6 +216,11 @@ fn test_theme_save_load() { } fn test_theme_registry() { + old_registry := theme_registry_snapshot() + defer { + theme_registry_restore(old_registry) + } + // Built-in themes registered by init() t := theme_get('dark') or { assert false, err.str() @@ -225,14 +250,19 @@ fn test_theme_registry() { return } assert t3.color_background.eq(rgb(10, 20, 30)) - - // Restore original - theme_register(theme_dark) } fn test_theme_load_dir() { - dir := os.join_path(os.temp_dir(), 'gui_theme_dir_test') - os.mkdir_all(dir) or {} + old_registry := theme_registry_snapshot() + defer { + theme_registry_restore(old_registry) + } + dir := os.join_path(os.temp_dir(), 'gui_theme_dir_test_${os.getpid()}') + os.rmdir_all(dir) or {} + os.mkdir_all(dir) or { + assert false, err.str() + return + } defer { os.rmdir_all(dir) or {} } diff --git a/locale_bundle.v b/locale_bundle.v index 4336cbb..b1eb26c 100644 --- a/locale_bundle.v +++ b/locale_bundle.v @@ -49,7 +49,11 @@ struct LocaleBundle { // locale_parse decodes a JSON string into a Locale struct. // Missing keys fall back to en-US defaults. pub fn locale_parse(content string) !Locale { - bundle := json.decode(LocaleBundle, content) or { return error('invalid JSON: ${err}') } + trimmed := content.trim_space() + if trimmed.len == 0 || !trimmed.starts_with('{') || !trimmed.ends_with('}') { + return error('invalid JSON: expected object') + } + bundle := json.decode(LocaleBundle, trimmed) or { return error('invalid JSON: ${err}') } return bundle.to_locale() } @@ -96,7 +100,8 @@ fn (b LocaleBundle) to_locale() Locale { str_copy_link: str_or(b.strings, 'copy_link', d.str_copy_link) str_copied: str_or(b.strings, 'copied', d.str_copied) // Scrollbar - str_horizontal_scrollbar: str_or(b.strings, 'horizontal_scrollbar', d.str_horizontal_scrollbar) + str_horizontal_scrollbar: str_or(b.strings, 'horizontal_scrollbar', + d.str_horizontal_scrollbar) str_vertical_scrollbar: str_or(b.strings, 'vertical_scrollbar', d.str_vertical_scrollbar) // Data grid str_columns: str_or(b.strings, 'columns', d.str_columns) diff --git a/locale_registry.v b/locale_registry.v index 5424d0f..35c5c9c 100644 --- a/locale_registry.v +++ b/locale_registry.v @@ -26,6 +26,14 @@ pub fn locale_get(id string) !Locale { return gui_locale_registry[id] or { return error('locale not found: ${id}') } } +fn locale_registry_snapshot() map[string]Locale { + return gui_locale_registry.clone() +} + +fn locale_registry_restore(snapshot map[string]Locale) { + gui_locale_registry = snapshot.clone() +} + // locale_load_dir loads all *.json files from a directory // and registers each as a locale. pub fn locale_load_dir(dir string) ! { diff --git a/theme_bundle.v b/theme_bundle.v index b105612..161352f 100644 --- a/theme_bundle.v +++ b/theme_bundle.v @@ -31,10 +31,10 @@ struct SizesBundle { } struct SpacingBundle { - small f32 = -1 - medium f32 = -1 - large f32 = -1 - text f32 = -1 + spacing_small f32 = -1 @[json: 'small'] + medium f32 = -1 + large f32 = -1 + text f32 = -1 } struct ScrollBundle { @@ -85,7 +85,11 @@ struct ThemeBundle { // theme_parse decodes a JSON string into a Theme. // Missing keys fall back to ThemeCfg{} defaults (dark theme). pub fn theme_parse(content string) !Theme { - bundle := json.decode(ThemeBundle, content) or { return error('invalid JSON: ${err}') } + trimmed := content.trim_space() + if trimmed.len == 0 || !trimmed.starts_with('{') || !trimmed.ends_with('}') { + return error('invalid JSON: expected object') + } + bundle := json.decode(ThemeBundle, trimmed) or { return error('invalid JSON: ${err}') } cfg := bundle.to_theme_cfg() return theme_maker(cfg) } @@ -247,11 +251,14 @@ fn (b ThemeBundle) to_theme_cfg() ThemeCfg { size_switch_height: widgets_f32_or(b.widgets, 'switch_height', d.size_switch_height) size_radio: widgets_f32_or(b.widgets, 'radio', d.size_radio) size_scrollbar: widgets_f32_or(b.widgets, 'scrollbar', d.size_scrollbar) - size_scrollbar_min_thumb: widgets_f32_or(b.widgets, 'scrollbar_min_thumb', d.size_scrollbar_min_thumb) + size_scrollbar_min_thumb: widgets_f32_or(b.widgets, 'scrollbar_min_thumb', + d.size_scrollbar_min_thumb) size_progress_bar: widgets_f32_or(b.widgets, 'progress_bar', d.size_progress_bar) size_range_slider: widgets_f32_or(b.widgets, 'range_slider', d.size_range_slider) - size_range_slider_thumb: widgets_f32_or(b.widgets, 'range_slider_thumb', d.size_range_slider_thumb) - size_splitter_handle: widgets_f32_or(b.widgets, 'splitter_handle', d.size_splitter_handle) + size_range_slider_thumb: widgets_f32_or(b.widgets, 'range_slider_thumb', + d.size_range_slider_thumb) + size_splitter_handle: widgets_f32_or(b.widgets, 'splitter_handle', + d.size_splitter_handle) width_submenu_min: widgets_f32_or(b.widgets, 'submenu_min', d.width_submenu_min) width_submenu_max: widgets_f32_or(b.widgets, 'submenu_max', d.width_submenu_max) } @@ -299,12 +306,13 @@ fn text_or(t ?TextBundle, fallback TextStyle) TextStyle { fn spacing_f32_or(sp ?SpacingBundle, field string, fallback f32) f32 { sb := sp or { return fallback } v := match field { - 'small' { sb.small } + 'small' { sb.spacing_small } 'medium' { sb.medium } 'large' { sb.large } 'text' { sb.text } else { f32(-1) } } + return if v >= 0 { v } else { fallback } } @@ -319,6 +327,7 @@ fn sizes_f32_or(sz ?SizesBundle, field string, fallback f32) f32 { 'text_x_large' { sb.text_x_large } else { f32(-1) } } + return if v >= 0 { v } else { fallback } } @@ -332,6 +341,7 @@ fn scroll_f32_or(sc ?ScrollBundle, field string, fallback f32) f32 { 'gap_end' { sb.gap_end } else { f32(-1) } } + return if v >= 0 { v } else { fallback } } @@ -351,5 +361,6 @@ fn widgets_f32_or(w ?WidgetsBundle, field string, fallback f32) f32 { 'submenu_max' { wb.submenu_max } else { f32(-1) } } + return if v >= 0 { v } else { fallback } } diff --git a/theme_registry.v b/theme_registry.v index 8669424..ad373a1 100644 --- a/theme_registry.v +++ b/theme_registry.v @@ -14,6 +14,14 @@ pub fn theme_get(name string) !Theme { return gui_theme_registry[name] or { return error('theme not found: ${name}') } } +fn theme_registry_snapshot() map[string]Theme { + return gui_theme_registry.clone() +} + +fn theme_registry_restore(snapshot map[string]Theme) { + gui_theme_registry = snapshot.clone() +} + // theme_load_dir loads all *.json files from a directory // and registers each as a theme. pub fn theme_load_dir(dir string) ! { From 5e6cf49f72eadb1970a8a1eb3a1ce257af835938 Mon Sep 17 00:00:00 2001 From: GGRei Date: Sat, 13 Jun 2026 20:44:03 +0200 Subject: [PATCH 6/6] ci: handle sqlite-conditioned examples --- .github/workflows/ci.yml | 15 +++----- examples/_build.vsh | 74 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 73 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c64a8ed..f946f98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -307,7 +307,7 @@ jobs: - name: Check compilation of examples env: VFLAGS: -no-parallel -cc msvc - run: v should-compile-all gui/examples/ + run: v gui/examples/_build.vsh --no-warnings --skip-missing-sqlite - name: Check compilation of changed examples with -W if: github.event_name == 'pull_request' env: @@ -328,17 +328,12 @@ jobs: Write-Host 'No changed example .v files to compile with -W.' exit 0 } - New-Item -ItemType Directory -Force -Path 'examples/bin' | Out-Null - foreach ($file in $files) { - $name = [System.IO.Path]::GetFileNameWithoutExtension($file) - $outputFile = "examples/bin/$name" - v -no-parallel -W -o $outputFile $file - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } + v examples/_build.vsh --skip-missing-sqlite @files + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE } - name: Check compilation of examples with -W if: github.event_name != 'pull_request' env: VFLAGS: -no-parallel -cc msvc - run: v gui/examples/_build.vsh + run: v gui/examples/_build.vsh --skip-missing-sqlite diff --git a/examples/_build.vsh b/examples/_build.vsh index ff11f52..41d8015 100755 --- a/examples/_build.vsh +++ b/examples/_build.vsh @@ -2,7 +2,58 @@ import os +const sqlite_marker = 'vtest build: present_sqlite3?' + +fn sqlite_available() bool { + mut dir := os.dir(@VEXE) + for dir.len > 0 { + sqlite_c := os.join_path(dir, 'thirdparty', 'sqlite', 'sqlite3.c') + sqlite_cpp := os.join_path(dir, 'thirdparty', 'sqlite', 'sqlite3.cpp') + if os.exists(sqlite_c) || os.exists(sqlite_cpp) { + return true + } + parent := os.dir(dir) + if parent == dir { + break + } + dir = parent + } + return false +} + +fn sqlite_conditioned(file string) bool { + content := os.read_file(file) or { return false } + return content.contains(sqlite_marker) +} + +fn normalize_path(base_dir string, file string) string { + if os.is_abs_path(file) { + return file + } + return os.join_path(base_dir, file) +} + unbuffer_stdout() + +mut warn := true +mut skip_missing_sqlite := false +mut input_files := []string{} +base_dir := os.getwd() +for arg in os.args[1..] { + if arg == '--' { + continue + } + if arg == '--no-warnings' { + warn = false + continue + } + if arg == '--skip-missing-sqlite' { + skip_missing_sqlite = true + continue + } + input_files << normalize_path(base_dir, arg) +} + chdir(@DIR)! output_dir := 'bin' @@ -28,20 +79,31 @@ if exists(output_dir) { } } -dir_files := ls('.') or { [] }.map(join_path_single(@DIR, it)) -files := dir_files.filter(file_ext(it) == '.v').sorted() +mut files := if input_files.len > 0 { + input_files +} else { + ls('.') or { [] }.map(join_path_single(@DIR, it)) +} +files = files.filter(file_ext(it) == '.v').sorted() if files.len == 0 { println('no .v files found') return } +skip_sqlite_examples := skip_missing_sqlite && !sqlite_available() mut errors := []string{} -for file in files { +for i, file in files { + progress := '(${i + 1:02}/${files.len:02})' + if skip_sqlite_examples && sqlite_conditioned(file) { + println('${progress} skipped sqlite-conditioned example: ${os.file_name(file)}') + continue + } _, name, _ := split_path(file) output_file := join_path(output_dir, name) - cmd := 'v -no-parallel -W -o ${output_file:-22s} ${file}' - dsp := 'v -no-parallel -W -o ${output_file:-22s} ${os.file_name(file):-26s}' - print(dsp) + warn_flag := if warn { '-W ' } else { '' } + cmd := 'v -no-parallel ${warn_flag}-o ${output_file:-22s} ${file}' + dsp := 'v -no-parallel ${warn_flag}-o ${output_file:-22s} ${os.file_name(file):-26s}' + print('${progress} ${dsp}') result := execute(cmd) if result.exit_code == 0 { println('✅')