diff --git a/.github/lsan.supp b/.github/lsan.supp index 38d32122..94b5b717 100644 --- a/.github/lsan.supp +++ b/.github/lsan.supp @@ -5,6 +5,8 @@ leak:drawing::defaultFont leak:imgutils::retainFontRefId leak:typefaces::registerTypeface +# The HarfBuzz backend also retains parsed faces in an ARC/ORC threadvar table. +leak:harfbuzzy::initHarfbuzzTypeface leak:libfontconfig.so leak:outputs::memoryOutput leak:times::newTimezone diff --git a/.github/scripts/install-msvc-nim.sh b/.github/scripts/install-msvc-nim.sh new file mode 100644 index 00000000..b531e4af --- /dev/null +++ b/.github/scripts/install-msvc-nim.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Start with the complete official distribution, verify both pinned fixes, then +# compile the corrected compiler once. No Git clone or csources build is needed. +nimMsvcRevision=705392dc03a3f8233f5f0f048acaa9b5267576f6 +nimMsvcAtomicsChecksum=2d9c6b381ad09b09b64d29f2af5a6da693c7835d574075e238a8d8c4370ef05a +nimMsvcCompilerChecksum=22e3011e65906288373fc991d2abc9a06e6ebd348c7682f162a14f21b047451b +projectRoot="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$projectRoot" +nimMsvcDir="$projectRoot/deps/nim-msvc-2.2.10-705392dc" +atomicsPath=lib/pure/concurrency/atomics.nim +compilerPath=compiler/ccgliterals.nim +stageDir= +probeDir= +nimMsvcCandidate="$nimMsvcDir" + +cleanup() { + if [[ -n "$stageDir" ]]; then rm -rf "$stageDir"; fi + if [[ -n "$probeDir" ]]; then rm -rf "$probeDir"; fi +} +trap cleanup EXIT + +verifyFile() { + [[ -f "$1" ]] && [[ "$(sha256sum "$1" | cut -d ' ' -f 1)" == "$2" ]] +} + +fetchPatch() { + local relativePath="$1" expectedChecksum="$2" + curl --fail --location --retry 3 \ + "https://raw.githubusercontent.com/elcritch/Nim/$nimMsvcRevision/$relativePath" \ + --output "$stageDir/$relativePath" + if ! verifyFile "$stageDir/$relativePath" "$expectedChecksum"; then + echo "The pinned $relativePath checksum does not match." >&2 + exit 1 + fi +} + +if [[ ! -e "$nimMsvcDir" ]]; then + atlas env 2.2.10 --binary + stageDir="$(mktemp -d "$projectRoot/deps/.nim-msvc-XXXXXX")" + cp -R "$projectRoot/deps/nim-2.2.10/." "$stageDir/" + fetchPatch "$atomicsPath" "$nimMsvcAtomicsChecksum" + fetchPatch "$compilerPath" "$nimMsvcCompilerChecksum" + # Binary distributions omit the prebuilt MSVC resource used by compiler/nim.nim. + ( + cd "$stageDir/icons" + MSYS2_ARG_CONV_EXCL='*' rc.exe /nologo /fo nim-amd64-windows-vcc.res nim.rc + ) + stageWindows="$(cygpath -m "$stageDir")" + # Keep compiler/nim.cfg (booting/nimcore), but skip Merenda's parent config. + "$projectRoot/deps/nim-2.2.10/bin/nim.exe" c --cc:vcc -d:release --mm:orc \ + --vcc.exe:cl.exe --vcc.linkerexe:cl.exe \ + --skipUserCfg:on --skipParentCfg:on --lib:"$stageWindows/lib" \ + --out:"$stageWindows/bin/nim.exe" "$stageDir/compiler/nim.nim" + # Atlas activation scripts contain the original installation's absolute path. + # Use the private compiler path printed below instead. + rm -f "$stageDir/activate.bat" "$stageDir/activate.ps1" "$stageDir/activate.sh" + nimMsvcCandidate="$stageDir" +fi + +if [[ ! -f "$nimMsvcCandidate/bin/nim.exe" ]] || + ! verifyFile "$nimMsvcCandidate/$atomicsPath" "$nimMsvcAtomicsChecksum" || + ! verifyFile "$nimMsvcCandidate/$compilerPath" "$nimMsvcCompilerChecksum"; then + echo "Incomplete MSVC Nim environment: remove $nimMsvcDir and retry." >&2 + exit 1 +fi + +# Use the compiler's default library lookup, including on cached installations. +probeDir="$(mktemp -d "$projectRoot/deps/.msvc-atomics-probe-XXXXXX")" +probeFile="$probeDir/msvc_atomics_probe.nim" +cat > "$probeFile" <<'NIM' +import std/atomics +static: + doAssert declared(nimHasCorrectMsvcAtomics) +const binary = block: + var data = newString(20_000) + for i in 0 ..< data.len: + data[i] = char(i mod 256) + data +var value = binary +let original = value +for i, c in value: + doAssert c == char(i mod 256) +value[0] = 'x' +doAssert original[0] == '\0' +NIM +"$nimMsvcCandidate/bin/nim.exe" c -r --cc:vcc --mm:arc \ + --vcc.exe:cl.exe --vcc.linkerexe:cl.exe \ + --skipUserCfg:on --skipParentCfg:on --skipProjCfg:on \ + --out:"$(cygpath -m "$probeDir")/msvc_probe.exe" "$probeFile" + +if [[ -n "$stageDir" ]]; then + # Windows may briefly retain a file lock after the compiler/probe exits. + for moveAttempt in {1..20}; do + # Do not nest the staged compiler if the destination unexpectedly exists. + if mv -T "$stageDir" "$nimMsvcDir"; then + stageDir= + break + fi + if ((moveAttempt == 20)); then + echo "Cannot install the MSVC Nim environment at $nimMsvcDir." >&2 + exit 1 + fi + sleep 0.5 + done +fi + +if [[ -n "${GITHUB_PATH:-}" ]]; then + cygpath -w "$nimMsvcDir/bin" >> "$GITHUB_PATH" +fi + +nimMsvcCompilerWindows="$(cygpath -w "$nimMsvcDir/bin/nim.exe")" +echo "Patched MSVC compiler: $nimMsvcCompilerWindows" diff --git a/.github/workflows/build-full.yml b/.github/workflows/build-full.yml index 85975ad6..259df8a9 100644 --- a/.github/workflows/build-full.yml +++ b/.github/workflows/build-full.yml @@ -10,7 +10,8 @@ jobs: tests: name: tests (${{ matrix.os }}, ${{ matrix.nimversion }}, ${{ matrix.display }}) runs-on: ${{ matrix.os }} - timeout-minutes: 40 + # Full GUI and integration coverage can take nearly 40 minutes on macOS. + timeout-minutes: 60 env: ATLAS_TEST_NIM_ARGS: ${{ matrix.nimargs }} defaults: @@ -58,6 +59,10 @@ jobs: - name: Check shared test runner coverage run: nim e --skipParentCfg:on --skipProjCfg:on .github/scripts/check_test_imports.nims + - name: Install macOS text rendering libraries + if: runner.os == 'macOS' + run: brew install harfbuzz fribidi + - name: Setup software OpenGL (Mesa) if: runner.os == 'Windows' uses: ssciwr/setup-mesa-dist-win@v3.0.0 @@ -87,6 +92,8 @@ jobs: libgl1-mesa-dev \ libgles2-mesa-dev \ libegl1-mesa-dev \ + libharfbuzz-dev \ + libfribidi-dev \ mesa-utils \ vulkan-tools \ mesa-vulkan-drivers \ @@ -260,6 +267,8 @@ jobs: libgl1-mesa-dev \ libgles2-mesa-dev \ libegl1-mesa-dev \ + libharfbuzz-dev \ + libfribidi-dev \ mesa-utils \ vulkan-tools \ mesa-vulkan-drivers \ @@ -308,6 +317,30 @@ jobs: steps: - uses: actions/checkout@v5 + - name: Configure MSVC for x64 + if: runner.os == 'Windows' + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + + - name: Install macOS text rendering libraries + if: runner.os == 'macOS' + run: brew install harfbuzz fribidi + + - name: Install Linux text rendering libraries + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends libharfbuzz-dev libfribidi-dev + + - name: Install Windows text rendering headers + if: runner.os == 'Windows' + shell: pwsh + run: | + & "$env:VCPKG_INSTALLATION_ROOT/vcpkg.exe" install harfbuzz:x64-windows + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + "INCLUDE=$env:VCPKG_INSTALLATION_ROOT/installed/x64-windows/include/harfbuzz;$env:INCLUDE" >> $env:GITHUB_ENV + - name: Cache packages uses: actions/cache@v4 with: @@ -317,9 +350,18 @@ jobs: - name: Install latest Atlas and set up Nim run: | mkdir -p "$HOME/.local/bin" - echo "$HOME/.local/bin" >> "$GITHUB_PATH" + if [[ "$RUNNER_OS" == "Windows" ]]; then + cygpath -w "$HOME/.local/bin" >> "$GITHUB_PATH" + else + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + fi + export PATH="$HOME/.local/bin:$PATH" bash .github/scripts/install-atlas.sh - "$HOME/.local/bin/atlas" env "${{ matrix.nimversion }}" --binary --github-path + if [[ "$RUNNER_OS" == "Windows" ]]; then + bash .github/scripts/install-msvc-nim.sh + else + "$HOME/.local/bin/atlas" env "${{ matrix.nimversion }}" --binary --github-path + fi - name: Verify Nim run: | @@ -340,5 +382,13 @@ jobs: run: bash .github/scripts/clean_generated_examples.sh - name: Compile Examples + if: runner.os != 'Windows' run: | atlas-run tests --jobs:2 --compile-only --only-errors "examples/all_compile.nim" + + - name: Compile Windows Examples with MSVC + if: runner.os == 'Windows' + shell: pwsh + run: | + atlas-run tests --jobs:2 --compile-only --only-errors "examples/all_compile.nim" -- --cc:vcc --passC:/MT + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/.github/workflows/release-kosmo.yml b/.github/workflows/release-kosmo.yml index ed64c885..26167fc7 100644 --- a/.github/workflows/release-kosmo.yml +++ b/.github/workflows/release-kosmo.yml @@ -81,6 +81,8 @@ jobs: "libegl1-mesa-dev:$APT_ARCH" \ "libgl1-mesa-dev:$APT_ARCH" \ "libgles2-mesa-dev:$APT_ARCH" \ + "libharfbuzz-dev:$APT_ARCH" \ + "libfribidi-dev:$APT_ARCH" \ "libvulkan-dev:$APT_ARCH" \ "libx11-xcb-dev:$APT_ARCH" \ "libxcb1-dev:$APT_ARCH" \ @@ -176,6 +178,8 @@ jobs: libegl1-mesa-dev \ libgl1-mesa-dev \ libgles2-mesa-dev \ + libharfbuzz-dev \ + libfribidi-dev \ libvulkan-dev \ libx11-xcb-dev \ libxcb1-dev \ @@ -341,6 +345,8 @@ jobs: # pkg-config inject their shared-only -l flags into this static link. staticPkgConfigDir="$RUNNER_TEMP/kosmo-empty-pkgconfig" mkdir -p "$staticPkgConfigDir" + # Keep text shaping headers available after hiding graphics .pc files. + echo "HARFBUZZ_CFLAGS=$(pkg-config --cflags harfbuzz)" >> "$GITHUB_ENV" echo "PKG_CONFIG_LIBDIR=$staticPkgConfigDir" >> "$GITHUB_ENV" - name: Build Kosmo @@ -352,6 +358,7 @@ jobs: --cc:gcc \ --gcc.exe:"$MUSL_CC" \ --gcc.linkerexe:"$MUSL_CC" \ + --passC:"$HARFBUZZ_CFLAGS" \ --passL:-static \ --opt:size \ --debugger:native \ @@ -500,6 +507,9 @@ jobs: steps: - uses: actions/checkout@v5 + - name: Install text rendering libraries + run: brew install meson ninja pkg-config + - name: Cache packages uses: actions/cache@v4 with: @@ -621,6 +631,9 @@ jobs: iconutil -c icns "$iconset_path" \ -o "$app_path/Contents/Resources/Kosmo.icns" + text_prefix="$RUNNER_TEMP/kosmo-text-libraries" + bash packaging/macos/build-text-libraries.sh "$text_prefix" + export PKG_CONFIG_PATH="$text_prefix/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" nim c \ --opt:size \ --debugger:native \ @@ -628,10 +641,13 @@ jobs: --stackTrace:off \ -d:nimStackTraceOverride \ -d:moe.embedded \ + -d:harfbuzzyDynlib=@executable_path/../Frameworks/libharfbuzz.dylib \ + -d:harfbuzzyFribidiDynlib=@executable_path/../Frameworks/libfribidi.dylib \ -d:KosmoVersion="$version" \ -d:KosmoGitHashOverride="$revision" \ --out:"$app_path/Contents/MacOS/kosmo" \ src/merenda/kosmo/kosmo.nim + bash packaging/macos/bundle-text-libraries.sh "$app_path" "$text_prefix" xcrun dsymutil \ "$app_path/Contents/MacOS/kosmo" \ -o "$dsym_path" @@ -687,6 +703,9 @@ jobs: exit 1 fi + - name: Verify signed Kosmo startup + run: build/Kosmo.app/Contents/MacOS/kosmo --help + - name: Package Kosmo app run: | ditto \ @@ -734,10 +753,32 @@ jobs: build-windows: runs-on: windows-2022 - timeout-minutes: 45 + # Optimizing Kosmo's large C translation units can exceed 45 minutes. + timeout-minutes: 75 steps: - uses: actions/checkout@v5 + - name: Configure MSVC for x64 + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + + - name: Set up software OpenGL for the startup check + uses: ssciwr/setup-mesa-dist-win@v3.0.0 + with: + version: '26.0.3' + build-type: 'release-msvc' + deployment-choice: '1' + + - name: Install text rendering libraries + shell: pwsh + run: | + & "$env:VCPKG_INSTALLATION_ROOT/vcpkg.exe" install ` + harfbuzz:x64-windows-staticcrt fribidi:x64-windows-staticcrt ` + --overlay-triplets=packaging/windows + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + "INCLUDE=$env:VCPKG_INSTALLATION_ROOT/installed/x64-windows-staticcrt/include/harfbuzz;$env:INCLUDE" >> $env:GITHUB_ENV + - name: Install Nim uses: jiro4989/setup-nim-action@v2 with: @@ -747,7 +788,8 @@ jobs: shell: bash run: | mkdir -p "$HOME/.local/bin" - echo "$HOME/.local/bin" >> "$GITHUB_PATH" + cygpath -w "$HOME/.local/bin" >> "$GITHUB_PATH" + export PATH="$HOME/.local/bin:$PATH" bash .github/scripts/install-atlas.sh atlas -v @@ -761,44 +803,71 @@ jobs: shell: bash run: atlas install -tuk --features:kosmo + - name: Install Nim with corrected MSVC atomics + shell: bash + run: bash .github/scripts/install-msvc-nim.sh + + - name: Verify MSVC linker response files + shell: pwsh + timeout-minutes: 5 + run: | + New-Item -ItemType Directory -Force -Path build | Out-Null + nim c -r --cc:vcc --passC:/MT --out:build/windowsresponsefiles.exe ` + tests/integrations/fixtures/windowsresponsefiles.nim + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Build Kosmo shell: pwsh run: | $version = $env:KOSMO_RELEASE_VERSION.TrimStart('v') $revision = $env:GITHUB_SHA.Substring(0, 12) + $textBin = "$env:VCPKG_INSTALLATION_ROOT/installed/x64-windows-staticcrt/bin" + $harfbuzzDll = Get-ChildItem "$textBin/*.dll" | Where-Object Name -Match '^(lib)?harfbuzz(-[0-9]+)?\.dll$' | Select-Object -First 1 + $fribidiDll = Get-ChildItem "$textBin/*.dll" | Where-Object Name -Match '^(lib)?fribidi(-[0-9]+)?\.dll$' | Select-Object -First 1 + if (-not $harfbuzzDll -or -not $fribidiDll) { + throw 'The text dependency installation is missing HarfBuzz or FriBidi DLLs.' + } New-Item -ItemType Directory -Force -Path build, dist | Out-Null Push-Location data - windres kosmo.rc -O coff -o ../build/kosmo.res + rc.exe /nologo /fo ../build/kosmo.res kosmo.rc + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } Pop-Location - # The setup action uses MinGW. Link its exception runtime into the - # executable so the release ZIP does not require libgcc_s_seh-1.dll - # beside kosmo.exe. nim c ` + --cc:vcc ` --opt:size ` --debugger:native ` - --lineTrace:off ` - --stackTrace:off ` - --passL:-static-libgcc ` - -d:nimStackTraceOverride ` + --lineTrace:on ` + --stackTrace:on ` + --passC:/MT ` --app:gui ` -d:moe.embedded ` + "-d:harfbuzzyDynlib=$($harfbuzzDll.Name)" ` + "-d:harfbuzzyFribidiDynlib=$($fribidiDll.Name)" ` "-d:KosmoVersion=$version" ` "-d:KosmoGitHashOverride=$revision" ` --passL:build/kosmo.res ` + "--passL:/link /DEBUG:FULL /PDB:dist/kosmo.pdb" ` --out:dist/kosmo.exe ` src/merenda/kosmo/kosmo.nim if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - if (-not (& objdump -h dist/kosmo.exe | Select-String -Quiet '\.debug_info')) { - throw 'Kosmo executable is missing native debug information.' + if (-not (Test-Path dist/kosmo.pdb)) { + throw 'Kosmo executable is missing its PDB.' } - if (-not (& nm dist/kosmo.exe | Select-String -Quiet 'backtrace_create_state')) { - throw 'Kosmo executable is missing libbacktrace.' + if (-not (& dumpbin /headers dist/kosmo.exe | Select-String -Quiet 'Debug Directories')) { + throw 'Kosmo executable is missing its native debug directory.' } - $imports = & objdump -p dist/kosmo.exe - if ($imports -match '(?im)^\s*DLL Name:\s+libgcc_s.*\.dll\s*$') { - throw 'Kosmo executable must not depend on a MinGW GCC runtime DLL.' + $imports = & dumpbin /dependents dist/kosmo.exe + if ($imports -match '(?i)libgcc|libstdc\+\+|vcruntime[0-9]+|msvcp[0-9]+') { + throw 'Kosmo executable must not depend on a compiler runtime DLL.' + } + Copy-Item "$textBin/*.dll" dist/ + Get-ChildItem dist/*.dll | ForEach-Object { + $dependencies = & dumpbin /dependents $_.FullName + if ($dependencies -match '(?i)vcruntime[0-9]+|msvcp[0-9]+') { + throw "Text DLL $($_.Name) requires a Visual C++ redistributable." + } } $process = Start-Process ` -FilePath "$PWD/dist/kosmo.exe" ` @@ -818,13 +887,34 @@ jobs: } } + - name: Verify MSVC stack traces + shell: pwsh + run: | + nim c -r --cc:vcc --passC:/MT --out:build/windowsstacktrace.exe ` + tests/integrations/fixtures/windowsstacktrace.nim + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Verify MSVC atomic ownership and worker shutdown + shell: pwsh + timeout-minutes: 5 + run: | + nim c -r --cc:vcc --passC:/MT --out:build/windowsatomics.exe ` + tests/integrations/fixtures/windowsatomics.nim + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Package Kosmo shell: pwsh run: | Copy-Item data/FONT-LICENSES.md dist/FONT-LICENSES.md Copy-Item -Recurse data/font-licenses dist/font-licenses + New-Item -ItemType Directory -Force dist/text-library-licenses | Out-Null + Get-ChildItem "$env:VCPKG_INSTALLATION_ROOT/installed/x64-windows-staticcrt/share" -Directory | ForEach-Object { + if (Test-Path "$($_.FullName)/copyright") { + Copy-Item "$($_.FullName)/copyright" "dist/text-library-licenses/$($_.Name).txt" + } + } Compress-Archive ` - -Path dist/kosmo.exe, dist/FONT-LICENSES.md, dist/font-licenses ` + -Path dist/kosmo.exe, dist/kosmo.pdb, dist/*.dll, dist/FONT-LICENSES.md, dist/font-licenses, dist/text-library-licenses ` -DestinationPath dist/kosmo-windows-amd64.zip ` -Force diff --git a/AGENTS.md b/AGENTS.md index cce84d93..7ce75eb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ - Prefer `nim ic` for local development builds and testing to reuse incremental compilation results. Use `-r` to run the compiled program, and use `--backend:ic` with `atlas-run build` or `atlas-run tests`. Leave CI's compiler backend unchanged. - Run multiple or all tests with `atlas-run tests --backend:ic [test-selections]`. Omit selections to run the full suite, or pass one or more test selectors to run focused tests. Selectors match as `foo` -> `tests/tfoo*.nim`, `foo.nim` -> `tests/tfoo.nim`, and `examples/foo*.nim` -> `examples/foo*.nim`. Do not adjust the `--jobs` count or the `--nimcache`. - Compile the example bundle with `atlas-run tests --backend:ic --compile-only examples/all_compile.nim`; do not run `examples/all_compile.nim` as a test. +- Development control (`-d:merenda.devControl`) provides live renderer screenshots, window inspection, input automation, and CSS reloads. Use `MERENDA_BACKGROUND=1` to start apps in the background without taking focus on macOS and Windows. Read [docs/dev-control.md](docs/dev-control.md) before capturing live screenshots from a running app. - Execute a single test locally using `nim ic`: - `nim ic -r tests/ttransfer.nim` - `nim ic -r -d:debug tests/ttransfer.nim` diff --git a/CHANGES.md b/CHANGES.md index be574bcf..c91c2397 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,5 @@ +# Changes + ## Unreleased - Route development control through Sigils JSON-RPC selectors and dispatcher over @@ -14,9 +16,32 @@ - Add `MERENDA_BACKGROUND=1` nonactivating development presentation on macOS and Windows through a pinned Siwin update. -# Changes - -## Unreleased +## 0.28.0 + +- Let locally signed macOS apps load their bundled text libraries. Check startup + after signing and through the installed command to catch loader failures. + Statically link HarfBuzz and FriBidi when installing with `nim install_kosmo`. + +- Build Windows Kosmo releases and examples with MSVC. Use the Windows SDK + resource compiler, ship the matching PDB, and keep Nim stack traces with + function names and source lines in place of GNU libbacktrace on MSVC. + Use Sigils 0.32.0 and a private Nim 2.2.10 compiler built with pinned, + checksum-verified fixes for MSVC atomic exchange, compare-and-swap, + arithmetic operations, and large ARC/ORC string literals in embedded assets. + Invoke MSVC directly from its Developer shell so large linker response files + remain within Windows' command-line limit. + +- Enable FigDraw's `textBackendHarfbuzzy` Atlas feature in `merenda.nimble` so + this checkout and downstream Merenda apps use HarfBuzz text rendering and + automatic system-font fallback. Require released FigDraw 0.45.0 and Harfbuzzy + 0.3.0 for dynamic and static builds. Previously HarfBuzz was installed as a dependency, + but FigDraw still selected Pixie, leaving automatic fallback disabled. Missing + glyphs in the editor, terminal, and other text views can now use system fonts, + including checkbox symbols absent from the bundled Nerd Font. Initialize fallback + on background text-layout threads as well as the main thread. + +- Render Markdown task lists with ☐ for `[ ]`, ☑ for `[x]` or `[X]`, and ☒ for + `[-]`, preserving nested lists, numbering, and inline formatting. - Match split divider fills, edges, and grips to each bundled theme's palette, including the dark Nebula, Peachy, and Synthwave83 themes. diff --git a/README.md b/README.md index ccec21a9..b18cdc76 100644 --- a/README.md +++ b/README.md @@ -600,6 +600,10 @@ to focus an existing terminal. If none is open, Kosmo opens one in the nearest pane to the right, creating a right split when needed. **New Terminal** in the File menu still opens an additional terminal in the active pane. +Markdown task lists show ☐ for `- [ ]`, ☑ for `- [x]` (also `[X]`), and ☒ for +`- [-]`. Text rendering uses system-font fallback for characters missing from +the selected font, including symbols absent from the bundled Nerd Font. + In Moe's normal mode, `:e path` opens a Kosmo document tab or selects the file's existing tab. `:help` and `:config` open reusable tabs; `:config` keeps Moe's interactive @@ -788,6 +792,10 @@ for supported builds, custom install locations, and the static Linux build. ### Build from source +Install HarfBuzz and FriBidi development libraries for your platform first +(`brew install harfbuzz fribidi pkg-config` on macOS, or the `libharfbuzz-dev` +and `libfribidi-dev` packages on Debian/Ubuntu). + From your Merenda checkout, install the extra Kosmo dependencies, then build and launch it: @@ -797,18 +805,31 @@ nim c -o:kosmo src/merenda/kosmo/kosmo.nim ./kosmo . ``` -On Windows, run `./kosmo.exe .` after compiling. +On Windows, open a Visual Studio x64 Developer PowerShell and run +`bash .github/scripts/install-msvc-nim.sh` using Git for Windows Bash. +Use the compiler path it prints to build with +`& "" c --cc:vcc -o:kosmo.exe src/merenda/kosmo/kosmo.nim`, +then run `./kosmo.exe .`. +The script makes a private copy of the official Nim 2.2.10 distribution, +installs checksum-verified atomic and large-string fixes, and builds the corrected +compiler once. Worker threads need the atomic fix; an unpatched MSVC build is +rejected at compile time. The string fix lets ARC/ORC embed the bundled assets. +Keep the Developer shell open when building: the project invokes `cl.exe` +directly so large applications can link using response files. On macOS, install the current checkout as a complete `Kosmo.app` with its icon, bundled notices, debug symbols, and local code signature: ```sh +brew install cmake meson ninja nim install_kosmo ``` This uses Atlas to resolve dependencies, replaces `~/Applications/Kosmo.app`, and updates the `~/.local/bin/kosmo` link. Restart Kosmo if it was already running. Set `KOSMO_INSTALL_DIR` or `KOSMO_BIN_DIR` to choose other locations. +This build links HarfBuzz and FriBidi statically with `-d:harfbuzzyStatic`, +so the installed app does not require Homebrew at runtime. ## Explore more diff --git a/config.nims b/config.nims index 234a5a0b..0976db0d 100644 --- a/config.nims +++ b/config.nims @@ -14,13 +14,21 @@ goto --stacktrace: off ---define: - nimStackTraceOverride --path: "src/" --nimcache: "deps/.nimcache" -switch("import", "libbacktrace") +when defined(windows): + # Use the configured Developer shell directly; vccexe expands response files + # into command lines that exceed Windows' limit for large applications. + switch("vcc.exe", "cl.exe") + switch("vcc.linkerexe", "cl.exe") + # MSVC emits PDBs; GNU libbacktrace expects DWARF and a GNU unwinder. + switch("stackTrace", "on") + switch("lineTrace", "on") +else: + switch("define", "nimStackTraceOverride") + switch("import", "libbacktrace") when defined(freebsd): --define: diff --git a/docs/releasing-kosmo.md b/docs/releasing-kosmo.md index d242d913..d1eb96cf 100644 --- a/docs/releasing-kosmo.md +++ b/docs/releasing-kosmo.md @@ -14,20 +14,40 @@ Each executable archive also includes the notices for Kosmo's bundled IBM Plex Sans and JetBrains Mono Nerd Font Mono resources. On macOS the notices live in the app's `Contents/Resources` directory. +macOS bundles minimal HarfBuzz and FriBidi libraries built for macOS 13 in +`Contents/Frameworks`, with their notices in `Contents/Resources/text-library-licenses`. +Windows archives place the text DLLs beside `kosmo.exe`; the installer copies +them together and installs their notices with the font notices. Linux and +FreeBSD use system HarfBuzz and FriBidi libraries, including the musl build. + The five bundled Moe themes are embedded in the executable and installed into Kosmo's platform asset cache for Moe's file-based theme loader. Release archives do not need a separate `data/moe/themes` directory, and theme discovery does not depend on the working directory used to launch Kosmo. -Release executables retain native debug information and link the `libbacktrace` -stack-trace override. Nim's instrumented stack tracing remains disabled because +Release executables retain native debug information. Windows builds use MSVC, +ship `kosmo.pdb` beside `kosmo.exe`, and use Nim's stack tracing with function names +and source lines. The installer keeps the PDB beside the installed executable. +Windows CI runs `.github/scripts/install-msvc-nim.sh` to copy the official Nim +2.2.10 distribution into a private environment and install atomic and ARC/ORC +large-string fixes from an immutable fork revision. The script verifies both +files' SHA-256 and builds the compiler once with the official binary. It retains +the compiler's own configuration while skipping Merenda's parent configuration. +A compiled probe verifies default library lookup, binary string contents, and +copy-on-write behavior before adding the compiler to CI's path. The Atlas-managed +official installation stays untouched. Local MSVC builds must use that script's +compiler too. The regression tests run +natively with MSVC in the fork, and the release job additionally checks shared +ownership, concurrent increments, and worker shutdown before packaging. +The other platforms link the `libbacktrace` stack-trace override. Nim's +instrumented stack tracing remains disabled on those platforms because `libbacktrace` uses the compiler's native debug information without its runtime overhead. `libbacktrace` is an unconditional package dependency, and the project -configuration imports it globally; release builds also define -`nimStackTraceOverride` explicitly. Each platform job verifies both the native -debug data and linked libbacktrace backend before packaging. +configuration imports it except on Windows/MSVC; the other release builds also +define `nimStackTraceOverride` explicitly. Each platform job verifies its native +debug data and stack-trace support before packaging. -Linux and Windows keep the debug information in the executable. The macOS app +Linux keeps the debug information in the executable. The macOS app bundles its matching symbols at `Contents/MacOS/kosmo.dSYM`. This is the location libbacktrace checks beside the running executable, so release stack traces retain file and line information. diff --git a/install.sh b/install.sh index 2cde2062..38003c89 100755 --- a/install.sh +++ b/install.sh @@ -158,6 +158,10 @@ install_notices() { rm -rf "$KOSMO_DOC_DIR/font-licenses" cp -R "$extract_dir/font-licenses" "$KOSMO_DOC_DIR/font-licenses" fi + if [ -d "$extract_dir/text-library-licenses" ]; then + rm -rf "$KOSMO_DOC_DIR/text-library-licenses" + cp -R "$extract_dir/text-library-licenses" "$KOSMO_DOC_DIR/text-library-licenses" + fi echo "install.sh: installed bundled-font notices to $KOSMO_DOC_DIR" >&2 } @@ -289,6 +293,14 @@ case "$platform" in echo "install.sh: release archive did not contain kosmo.exe" >&2 exit 1 fi + for library in "$extract_dir/"*.dll; do + if [ -f "$library" ]; then + install_command "$library" "$(basename "$library")" + fi + done + if [ -f "$extract_dir/kosmo.pdb" ]; then + install_command "$extract_dir/kosmo.pdb" kosmo.pdb + fi install_command "$extract_dir/kosmo.exe" kosmo.exe install_notices "$extract_dir" ;; diff --git a/merenda.nimble b/merenda.nimble index dbf90fc6..e4204c56 100644 --- a/merenda.nimble +++ b/merenda.nimble @@ -1,4 +1,4 @@ -version = "0.27.6" +version = "0.28.0" author = "Jaremy Creechley" description = "Nim-native UI toolkit" license = "BSD-3-Clause" @@ -12,8 +12,9 @@ requires "chroniclers >= 0.6" requires "crunchy >= 0.1.11" # Includes merged ABI/X11 fixes and opt-in nonactivating window presentation. requires "gh:elcritch/siwin#50a96efcd5a3feb5fcfdf07a46baa7da50360c0b" -requires "gh:elcritch/figdraw >= 0.44.0 [siwin, harfbuzz]" -requires "sigils >= 0.31.0 [sigNameAsString, closures, siwin, chronos]" +requires "gh:elcritch/figdraw >= 0.45.0 [siwin, harfbuzz, textBackendHarfbuzzy]" +requires "gh:elcritch/harfbuzzy >= 0.3.0" +requires "gh:elcritch/sigils >= 0.32.0 [sigNameAsString, closures, siwin, chronos]" requires "gh:elcritch/variant#fix/ic-type-ids" requires "gh:elcritch/kiwiberry#4d273da93f67d8e5d6317898851772b32fa635ec" requires "cborious" diff --git a/packaging/macos/build-text-libraries.sh b/packaging/macos/build-text-libraries.sh new file mode 100644 index 00000000..a03f75d9 --- /dev/null +++ b/packaging/macos/build-text-libraries.sh @@ -0,0 +1,59 @@ +#!/bin/bash +set -euo pipefail + +prefix="${1:?usage: build-text-libraries.sh /absolute/install/prefix}" +work_dir="$(mktemp -d "${TMPDIR:-/tmp}/kosmo-text.XXXXXX")" +cleanup() { + local build_status=$? + if [[ "$build_status" -ne 0 ]]; then + for log in "$work_dir/"*-build/meson-logs/meson-log.txt; do + if [[ -f "$log" ]]; then + tail -80 "$log" >&2 + fi + done + fi + rm -rf "$work_dir" + exit "$build_status" +} +trap cleanup EXIT + +# Homebrew verifies source checksums. Build minimal libraries rather than using +# bottles whose deployment targets can exceed Kosmo's macOS 13 minimum. +export MACOSX_DEPLOYMENT_TARGET=13.0 +export SDKROOT="$(xcrun --sdk macosx --show-sdk-path)" +export CC="$(xcrun --find clang)" +export CXX="$(xcrun --find clang++)" +export HOMEBREW_NO_AUTO_UPDATE=1 +brew fetch --build-from-source harfbuzz fribidi +for formula in harfbuzz fribidi; do + mkdir -p "$work_dir/$formula" + tar -xf "$(brew --cache --build-from-source "$formula")" \ + -C "$work_dir/$formula" --strip-components 1 +done + +meson setup "$work_dir/harfbuzz-build" "$work_dir/harfbuzz" \ + --prefix="$prefix" --libdir=lib --buildtype=release --default-library=shared \ + --wrap-mode=nofallback --auto-features=disabled \ + -Dtests=disabled -Dutilities=disabled -Dsubset=disabled \ + -Draster=disabled -Dvector=disabled -Dgpu=disabled +meson compile -C "$work_dir/harfbuzz-build" +meson install -C "$work_dir/harfbuzz-build" + +meson setup "$work_dir/fribidi-build" "$work_dir/fribidi" \ + --prefix="$prefix" --libdir=lib --buildtype=release --default-library=shared \ + --wrap-mode=nofallback -Ddocs=false -Dbin=false -Dtests=false +meson compile -C "$work_dir/fribidi-build" +meson install -C "$work_dir/fribidi-build" + +for library in "$prefix/lib/libharfbuzz.dylib" "$prefix/lib/libfribidi.dylib"; do + otool -l "$library" | awk ' + $1 == "cmd" { legacy = ($2 == "LC_VERSION_MIN_MACOSX") } + ($1 == "minos" || (legacy && $1 == "version")) && $2 + 0 > 13.0 { + print "Text library exceeds the macOS 13 deployment target: " $2 > "/dev/stderr" + exit 1 + }' +done + +mkdir -p "$prefix/share/licenses" +cp "$work_dir/harfbuzz/COPYING" "$prefix/share/licenses/harfbuzz.txt" +cp "$work_dir/fribidi/COPYING" "$prefix/share/licenses/fribidi.txt" diff --git a/packaging/macos/bundle-text-libraries.sh b/packaging/macos/bundle-text-libraries.sh new file mode 100644 index 00000000..91624aaf --- /dev/null +++ b/packaging/macos/bundle-text-libraries.sh @@ -0,0 +1,46 @@ +#!/bin/bash +set -euo pipefail + +app_path="${1:?usage: bundle-text-libraries.sh /path/to/Kosmo.app /text/library/prefix}" +text_prefix="${2:?text library prefix required}" +library_dir="$app_path/Contents/Frameworks" +mkdir -p "$library_dir" + +bundle_library() { + local source="$1" name destination dependency + name="$(basename "$source")" + destination="$library_dir/$name" + if [[ -f "$destination" ]]; then + return + fi + cp -L "$source" "$destination" + chmod u+w "$destination" + install_name_tool -id "@loader_path/$name" "$destination" + while IFS= read -r dependency; do + case "$dependency" in + /usr/lib/*|/System/Library/*) continue ;; + esac + # The first entry is the source library's own install name. + if [[ "$dependency" -ef "$source" ]]; then + continue + fi + if [[ ! -f "$dependency" ]]; then + echo "Cannot bundle text-library dependency: $dependency" >&2 + exit 1 + fi + bundle_library "$dependency" + install_name_tool -change "$dependency" \ + "@loader_path/$(basename "$dependency")" "$destination" + done < <(otool -L "$source" | sed -E '1d; s/^[[:space:]]+//; s/ \(compatibility version.*$//') + if [[ -n "${KOSMO_SIGNING_IDENTITY:-}" ]]; then + codesign --force --options runtime --timestamp \ + --sign "$KOSMO_SIGNING_IDENTITY" "$destination" + else + codesign --force --sign - "$destination" + fi +} + +bundle_library "$text_prefix/lib/libharfbuzz.dylib" +bundle_library "$text_prefix/lib/libfribidi.dylib" +mkdir -p "$app_path/Contents/Resources/text-library-licenses" +cp "$text_prefix/share/licenses/"*.txt "$app_path/Contents/Resources/text-library-licenses/" diff --git a/packaging/macos/debug.entitlements b/packaging/macos/debug.entitlements index 5a8979bd..9f9dd10f 100644 --- a/packaging/macos/debug.entitlements +++ b/packaging/macos/debug.entitlements @@ -4,5 +4,7 @@ com.apple.security.get-task-allow + com.apple.security.cs.disable-library-validation + diff --git a/packaging/macos/install-local.sh b/packaging/macos/install-local.sh index 99d07822..44eca2e5 100755 --- a/packaging/macos/install-local.sh +++ b/packaging/macos/install-local.sh @@ -10,7 +10,7 @@ fi repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$repo_root" -for required_command in atlas nim git awk sips iconutil xcrun nm plutil codesign ditto; do +for required_command in atlas nim git awk sips iconutil xcrun nm plutil codesign ditto meson ninja cmake; do if ! command -v "$required_command" >/dev/null 2>&1; then echo "install-local.sh: missing required command: $required_command" >&2 exit 1 @@ -84,13 +84,24 @@ done iconutil -c icns "$iconset_path" -o "$app_path/Contents/Resources/Kosmo.icns" echo "install-local.sh: building Kosmo.app $version ($revision)" +export MACOSX_DEPLOYMENT_TARGET=13.0 +export SDKROOT="$(xcrun --sdk macosx --show-sdk-path)" +export CC="$(xcrun --find clang)" +export CXX="$(xcrun --find clang++)" +KOSMO_TEXT_LICENSE_DIR="$app_path/Contents/Resources/text-library-licenses" \ + nim e packaging/macos/staticTextLicenses.nims +# Cached external C objects can retain an older deployment target. nim c \ + --forceBuild:on \ + --passC:-mmacosx-version-min=13.0 \ + --passL:-mmacosx-version-min=13.0 \ --opt:size \ --debugger:native \ --lineTrace:off \ --stackTrace:off \ -d:nimStackTraceOverride \ -d:moe.embedded \ + -d:harfbuzzyStatic \ -d:KosmoVersion="$version" \ -d:KosmoGitHashOverride="$revision" \ --out:"$app_path/Contents/MacOS/kosmo" \ @@ -110,12 +121,14 @@ plutil -lint "$app_path/Contents/Info.plist" codesign --force --options runtime --sign - \ --entitlements packaging/macos/debug.entitlements "$app_path" codesign --verify --deep --strict "$app_path" +"$app_path/Contents/MacOS/kosmo" --help >/dev/null mkdir -p "$install_dir" "$bin_dir" staged_app="$install_dir/.Kosmo.app.install.$$" staged_command="$bin_dir/.kosmo.install.$$" ditto "$app_path" "$staged_app" codesign --verify --deep --strict "$staged_app" +"$staged_app/Contents/MacOS/kosmo" --help >/dev/null if [[ -e "$installed_app" || -L "$installed_app" ]]; then backup_app="$install_dir/.Kosmo.app.backup.$$" @@ -125,6 +138,7 @@ restore_install=1 mv "$staged_app" "$installed_app" staged_app="" ln -s "$installed_app/Contents/MacOS/kosmo" "$staged_command" +"$staged_command" --help >/dev/null mv -f "$staged_command" "$installed_command" staged_command="" restore_install=0 diff --git a/packaging/macos/staticTextLicenses.nims b/packaging/macos/staticTextLicenses.nims new file mode 100644 index 00000000..111218c2 --- /dev/null +++ b/packaging/macos/staticTextLicenses.nims @@ -0,0 +1,10 @@ +## Copy notices from the pinned native sources used by the static build. +import std/os +import harfbuzzy/private/staticHarfbuzz + +let destination = getEnv("KOSMO_TEXT_LICENSE_DIR") +if destination.len == 0: + raise newException(ValueError, "KOSMO_TEXT_LICENSE_DIR is required") +mkDir(destination) +cpFile(harfbuzzSourceDir.parentDir / "COPYING", destination / "harfbuzz.txt") +cpFile(fribidiSourceDir / "COPYING", destination / "fribidi.txt") diff --git a/packaging/windows/x64-windows-staticcrt.cmake b/packaging/windows/x64-windows-staticcrt.cmake new file mode 100644 index 00000000..2dc8fd60 --- /dev/null +++ b/packaging/windows/x64-windows-staticcrt.cmake @@ -0,0 +1,4 @@ +# Keep the text DLLs independent of an installed Visual C++ redistributable. +set(VCPKG_TARGET_ARCHITECTURE x64) +set(VCPKG_CRT_LINKAGE static) +set(VCPKG_LIBRARY_LINKAGE dynamic) diff --git a/src/merenda/nimkit/drawing/drawing.nim b/src/merenda/nimkit/drawing/drawing.nim index a4af12e2..fa045085 100644 --- a/src/merenda/nimkit/drawing/drawing.nim +++ b/src/merenda/nimkit/drawing/drawing.nim @@ -244,6 +244,8 @@ proc defaultFont( tag: variation.tag, value: variation.value ) when AutomaticFontFallbackEnabled: + # Each text-layout worker needs its own thread-local fallback resolver. + installAutomaticFontFallbackResolver() font.language = $resolvedLanguage fontRef(font) diff --git a/src/merenda/nimkit/text/markdownviews.nim b/src/merenda/nimkit/text/markdownviews.nim index 5ab73e4c..c4b5e8f6 100644 --- a/src/merenda/nimkit/text/markdownviews.nim +++ b/src/merenda/nimkit/text/markdownviews.nim @@ -747,8 +747,25 @@ proc renderInlineChildren( builder: var MarkdownBuilder, token: markdownParser.Token, attributes: TextAttributes, + skipPrefixBytes = 0, ) = + let prefixEnd = token.pos - token.doc.len + skipPrefixBytes + var skippingPrefix = skipPrefixBytes > 0 for child in token.children: + if skippingPrefix: + # Inline positions are source byte endpoints. Consume the task marker + # without changing the AST, including markers parsed as reference links. + if child.pos <= prefixEnd: + skippingPrefix = child.pos < prefixEnd + continue + skippingPrefix = false + if child of markdownParser.Text: + let skipped = max(prefixEnd - (child.pos - child.doc.len), 0) + if skipped > 0: + builder.add( + child.doc[skipped ..< child.doc.len].replace("\n", " "), attributes + ) + continue builder.renderInline(child, attributes) proc renderImage( @@ -885,6 +902,20 @@ proc renderInline( else: builder.add(token.doc, attributes) +proc renderParagraph( + builder: var MarkdownBuilder, + token: markdownParser.Token, + attributes: TextAttributes, + skipPrefixBytes = 0, +) = + let start = builder.runeLength + builder.renderInlineChildren(token, attributes, skipPrefixBytes) + if builder.runeLength > start: + builder.navigationItems.add MarkdownNavigationItem( + range: initTextRange(start, builder.runeLength - start), + sectionIdentifier: builder.sectionIdentifier, + ) + proc renderBlock( builder: var MarkdownBuilder, token: markdownParser.Token, attributes: TextAttributes ) @@ -900,6 +931,29 @@ func endsWithCodeBlock(builder: MarkdownBuilder): bool = builder.codeBlocks.len > 0 and builder.codeBlocks[^1].range.maxIndex == builder.runeLength +func taskListMarker(item: markdownParser.Li): tuple[symbol: string, prefixBytes: int] = + if item.children.head.isNil: + return + let paragraph = item.children.head.value + if not (paragraph of markdownParser.Paragraph): + return + let source = paragraph.doc + if source.len < 3 or source[0] != '[' or source[2] != ']' or + (source.len > 3 and source[3] notin Whitespace): + return + case source[1] + of ' ': + result.symbol = "☐" + of 'x', 'X': + result.symbol = "☑" + of '-': + result.symbol = "☒" + else: + return + result.prefixBytes = 3 + while result.prefixBytes < source.len and source[result.prefixBytes] in Whitespace: + inc result.prefixBytes + proc renderListItem( builder: var MarkdownBuilder, item: markdownParser.Li, @@ -908,11 +962,19 @@ proc renderListItem( attributes: TextAttributes, ) = let + task = item.taskListMarker() + itemMarker = + if task.symbol.len == 0: + marker + elif marker == "•": + task.symbol + else: + marker & " " & task.symbol indentation = " ".repeat(max(depth * builder.style.listIndent, 0)) - continuation = indentation & " ".repeat(marker.runeLen + 1) + continuation = indentation & " ".repeat(itemMarker.runeLen + 1) var markerAttributes = attributes markerAttributes.foregroundColor = builder.style.mutedColor - builder.add(indentation & marker & " ", markerAttributes) + builder.add(indentation & itemMarker & " ", markerAttributes) var first = true for child in item.children: @@ -928,7 +990,10 @@ proc renderListItem( builder.add(continuation, attributes) elif not first: builder.add("\n" & continuation, attributes) - builder.renderBlock(child, attributes) + if first and task.prefixBytes > 0: + builder.renderParagraph(child, attributes, task.prefixBytes) + else: + builder.renderBlock(child, attributes) first = false proc renderList( @@ -1538,13 +1603,7 @@ proc renderBlock( if token.isNil: return if token of markdownParser.Paragraph: - let start = builder.runeLength - builder.renderInlineChildren(token, attributes) - if builder.runeLength > start: - builder.navigationItems.add MarkdownNavigationItem( - range: initTextRange(start, builder.runeLength - start), - sectionIdentifier: builder.sectionIdentifier, - ) + builder.renderParagraph(token, attributes) elif token of markdownParser.Heading: let heading = markdownParser.Heading(token) var headingAttributes = attributes diff --git a/src/merenda/nimkit/text/textlayout.nim b/src/merenda/nimkit/text/textlayout.nim index 2a2e44d6..0c293bcc 100644 --- a/src/merenda/nimkit/text/textlayout.nim +++ b/src/merenda/nimkit/text/textlayout.nim @@ -826,7 +826,8 @@ proc textRangeForGlyphLine(layout: GlyphArrangement, line: Slice[int]): TextRang if found: initTextRange(start, stop - start) else: - initTextRange(0, 0) + # Shaping backends can represent an empty line with an insertion glyph. + layout.sourceRangeForGlyph(line.a) proc containsHardBreak[T](runes: T, range: TextRange): bool = if runes.len == 0: @@ -1265,11 +1266,12 @@ proc lineFragment[T]( lineCount: int, sourceRunes: T, ): TextLineFragment = - let - containers = manager.effectiveContainers() - glyphRange = initGlyphRange(line.a, line.b - line.a + 1) + let containers = manager.effectiveContainers() + var glyphRange = initGlyphRange(line.a, line.b - line.a + 1) # Read the retained arrangement directly; a local value copies every glyph. var textRange = manager.xLayout.textRangeForGlyphLine(line) + if textRange.isEmpty: + glyphRange.length = 0 if textRange.maxIndex < sourceRunes.len and sourceRunes[textRange.maxIndex] in [Rune('\n'), Rune('\r')]: inc textRange.length @@ -1302,7 +1304,10 @@ proc lineFragment[T]( let lineTop = - if hasUsedRect: virtualUsedRect.origin.y else: manager.xLayoutRect.origin.y + if hasUsedRect: + virtualUsedRect.origin.y + else: + manager.xLayout.glyphRect(line.a).y + manager.xLayoutRect.origin.y containerIndex = containers.containerIndexForVirtualY(lineTop) container = containers[containerIndex.toInt] virtualContainer = containers.virtualContainerRect(containerIndex.toInt) diff --git a/src/merenda/nimkit/themes/themecore.nim b/src/merenda/nimkit/themes/themecore.nim index cc77152a..4d6b8ba9 100644 --- a/src/merenda/nimkit/themes/themecore.nim +++ b/src/merenda/nimkit/themes/themecore.nim @@ -1,5 +1,12 @@ import std/[atomics, sets, strutils, tables] +when defined(windows) and defined(vcc): + when not declared(nimHasCorrectMsvcAtomics): + {. + error: + "MSVC needs the corrected Nim atomics library. Run bash .github/scripts/install-msvc-nim.sh and use the printed compiler path." + .} + import figdraw except CornerRadii from sigils/selectors import DynamicAgent diff --git a/tests/integrations/fixtures/moefrontendexec.nim b/tests/integrations/fixtures/moefrontendexec.nim index 5941ff3b..b7ad5398 100644 --- a/tests/integrations/fixtures/moefrontendexec.nim +++ b/tests/integrations/fixtures/moefrontendexec.nim @@ -2,7 +2,10 @@ import std/[monotimes, os, times] if paramCount() >= 3 and paramStr(1) == "--moe-hook-fixture": - writeFile(paramStr(3), $getCurrentProcessId()) + let ready = paramStr(3) + # File existence must mean the parent can read a complete PID. + writeFile(ready & ".tmp", $getCurrentProcessId()) + moveFile(ready & ".tmp", ready) echo "hook output: " & paramStr(2) if paramCount() == 4 and paramStr(4) == "wait": let deadline = getMonoTime() + initDuration(seconds = 60) diff --git a/tests/integrations/fixtures/windowsatomics.nim b/tests/integrations/fixtures/windowsatomics.nim new file mode 100644 index 00000000..b73fdfd2 --- /dev/null +++ b/tests/integrations/fixtures/windowsatomics.nim @@ -0,0 +1,63 @@ +## Standalone native MSVC coverage for shared ownership and contended atomics. + +when isMainModule: + import std/[atomics, monotimes, os, times] + import threading/smartptrs + import sigils/[threadChronos, threadPool] + + when not (defined(windows) and defined(vcc)): + {.error: "Compile this fixture for Windows with --cc:vcc".} + when not declared(nimHasCorrectMsvcAtomics): + {.error: "Use the pinned MSVC compiler with corrected atomics".} + + var destructions: Atomic[int] + + type Tracked = object + value: int + + proc `=destroy`(value: var Tracked) = + if value.value != 0: + discard destructions.fetchAdd(1) + + var before: int + block: + let owner = newSharedPtr(Tracked(value: 42)) + before = destructions.load() + block: + let copy = owner + doAssert copy[].value == 42 + doAssert owner[].value == 42 + doAssert destructions.load() == before + doAssert destructions.load() == before + 1 + + const + workerCount = 4 + iterations = 20_000 + var completed: Atomic[int] + + proc increment(counter: ptr Atomic[int]) {.thread.} = + for index in 0 ..< iterations: + let previous = counter[].fetchAdd(1, moRelaxed) + doAssert previous >= 0 and previous < workerCount * iterations + discard completed.fetchAdd(1, moRelease) + + var + counter: Atomic[int] + workers: array[workerCount, Thread[ptr Atomic[int]]] + for worker in workers.mitems: + createThread(worker, increment, addr counter) + let deadline = getMonoTime() + initDuration(seconds = 60) + while completed.load(moAcquire) != workerCount and getMonoTime() < deadline: + sleep(1) + doAssert completed.load(moAcquire) == workerCount + joinThreads(workers) + doAssert counter.load() == workerCount * iterations + let pool = newSigilThreadPool(workers = 2) + pool.start() + pool.stop() + pool.join() + let dispatcher = newSigilChronosThread() + dispatcher.start() + dispatcher.stop() + dispatcher.join() + echo "MSVC shared ownership and contended atomic increments passed" diff --git a/tests/integrations/fixtures/windowsresponsefiles.nim b/tests/integrations/fixtures/windowsresponsefiles.nim new file mode 100644 index 00000000..b257bcee --- /dev/null +++ b/tests/integrations/fixtures/windowsresponsefiles.nim @@ -0,0 +1,16 @@ +## Standalone native MSVC regression for linker commands over Windows' limit. + +when isMainModule: + when not (defined(windows) and defined(vcc)): + {.error: "Compile this fixture for Windows with --cc:vcc".} + + const linkerOptions = block: + var options = "/link" + # Repeating a harmless warning option forces a response file. vccexe used + # to expand it again and exceed CreateProcess's 32,767-character limit. + for i in 0 ..< 3000: + options.add " /IGNORE:4099" + options + + {.passL: linkerOptions.} + echo "MSVC linked successfully with a response file over 32K" diff --git a/tests/integrations/fixtures/windowsstacktrace.nim b/tests/integrations/fixtures/windowsstacktrace.nim new file mode 100644 index 00000000..12b8ee21 --- /dev/null +++ b/tests/integrations/fixtures/windowsstacktrace.nim @@ -0,0 +1,19 @@ +## Standalone MSVC smoke test for the Windows stack-trace compiler settings. +## The release job compiles this separately so its PDB stays beside the process. + +when isMainModule: + import std/strutils + + when not (defined(windows) and defined(vcc)): + {.error: "Compile this fixture for Windows with --cc:vcc".} + + proc captureFailure() {.noinline.} = + raise newException(ValueError, "MSVC stack trace probe") + + try: + captureFailure() + except ValueError as error: + let trace = getStackTrace(error) + doAssert trace.contains("captureFailure"), trace + doAssert trace.contains("windowsstacktrace.nim"), trace + echo "MSVC stack trace includes the function and Nim source location" diff --git a/tests/nimkit/font_layout.nim b/tests/nimkit/font_layout.nim index e2031173..8ed8486e 100644 --- a/tests/nimkit/font_layout.nim +++ b/tests/nimkit/font_layout.nim @@ -1,4 +1,4 @@ -import std/[options, os, strutils, tempfiles, unittest] +import std/[options, os, strutils, tempfiles, unicode, unittest] import figdraw import pkg/bumpy @@ -82,6 +82,37 @@ proc selectionBounds(layout: GlyphArrangement): tuple[x, y, w, h: float32] = (minX, minY, maxX - minX, maxY - minY) suite "nimkit font layout": + test "checkbox symbols fall back from the bundled Nerd Font and rasterize": + require "font-fallback" in textBackendFeatures() + require fontFallbackResolver() != nil + let + font = TextStyle( + fontName: "JetBrainsMonoNerdFontMono-Regular.ttf.zip", fontSize: 16.0 + ).textFont(frMonospace).font + shaped = typeset( + bumpy.rect(0, 0, 120, 40), font, "☐☑☒", minContent = false, wrap = false + ) + placed = placeGlyphs( + font, + [ + (Rune(0x2610), vec2(0, 0)), + (Rune(0x2611), vec2(20, 0)), + (Rune(0x2612), vec2(40, 0)), + ], + ) + for layout in [shaped, placed]: + require layout.arrangedGlyphs.len == 3 + for glyph in layout.arrangedGlyphs: + check glyph.glyphId != FontGlyphId(0) + check getFigFont(glyph.fontId).typefaceId != font.typefaceId + for glyph in layout.glyphs(): + let image = glyph.generateGlyph(force = true, upload = false) + require not image.isNil + check image.opaqueBounds().w > 0 + check image.opaqueBounds().h > 0 + for index, glyph in placed.arrangedGlyphs: + check glyph.pos.x == float32(index * 20) + when defined(posix): test "platform default fonts load shape and rasterize without figDataDir": let previousDataDir = figDataDir() diff --git a/tests/nimkit/markdownviews.nim b/tests/nimkit/markdownviews.nim index bf1d9d17..75f2fc10 100644 --- a/tests/nimkit/markdownviews.nim +++ b/tests/nimkit/markdownviews.nim @@ -882,6 +882,89 @@ sibling body check rendered.contains("3. third\n4. fourth") check rendered.contains("• paragraph one\n paragraph two") + test "task lists render Unicode checkboxes and preserve inline styling": + let + style = initMarkdownStyle() + storage = markdownTextStorage( + "- [ ] Todo é\n- [x] **Done** and [link](https://example.com)\n" & + "- [X] Checked\n- [-] Cancelled\n- ordinary" + ) + check storage.stringValue() == + "☐ Todo é\n☑ Done and link\n☑ Checked\n☒ Cancelled\n• ordinary" + check storage.attributesFor("Done").fontName == style.strongFontName + check storage.attributesFor("link").link == "https://example.com" + check storage.attributesFor("☐").foregroundColor == style.mutedColor + check storage.attributesFor("Todo").foregroundColor == style.textColor + + test "nested and ordered tasks retain numbering and continuation indentation": + let storage = markdownTextStorage( + "- [ ] Parent\n - [x] Child\n\n More details\n\n" & + "3. [-] Cancelled\n\n Follow up\n4. [ ] Next" + ) + check storage.stringValue().contains("☐ Parent\n ☑ Child\n More details") + check storage.stringValue().contains( + "3. ☒ Cancelled\n Follow up\n4. ☐ Next" + ) + + test "background task-list layout resolves missing checkbox glyphs": + var style = initMarkdownStyle() + style.bodyFontName = "JetBrainsMonoNerdFontMono-Regular.ttf.zip" + let view = newMarkdownView( + "- [ ] Pending\n- [x] Done\n- [-] Cancelled\n\n" & + "Background content. ".repeat(256), + style = style, + frame = rect(0, 0, 360, 180), + ) + require view.waitForMarkdownParsing() + require view.waitForMarkdownLayout() + let manager = view.textView().layoutManager() + check manager.snapshotBuildThreadId() != getThreadId() + let layout = manager.glyphArrangementResource()[] + var checkboxCount = 0 + for glyph in layout.arrangedGlyphs: + if glyph.rune in [Rune(0x2610), Rune(0x2611), Rune(0x2612)]: + inc checkboxCount + check glyph.glyphId != FontGlyphId(0) + check not getTypefaceSource(getFigFont(glyph.fontId).typefaceId).name.contains( + "JetBrainsMonoNerdFontMono" + ) + check checkboxCount == 3 + + test "task markers only replace a list item's initial literal marker": + let storage = markdownTextStorage( + "[ ] paragraph\n\n- \\[ ] escaped\n- [x]word\n- [z] unknown\n" & + "- `[x]` code\n- [x](https://example.com) link\n- body [x] later\n\n" & + " [ ] later paragraph\n\n```\n- [x] fenced\n```" + ) + let rendered = storage.stringValue() + check rendered.contains("[ ] paragraph") + check rendered.contains("• [ ] escaped") + check rendered.contains("• [x]word") + check rendered.contains("• [z] unknown") + check rendered.contains("• [x] code") + check rendered.contains("• x link") + check rendered.contains("• body [x] later\n [ ] later paragraph") + check rendered.contains("- [x] fenced") + check not rendered.contains("☐") + check not rendered.contains("☑") + check not rendered.contains("☒") + + test "task rendering keeps the parsed tree reusable for style changes": + let view = newMarkdownView( + "- [x] **Done**\n- [ ] [pending](https://example.com)\n- [-] Cancelled\n\n" & + "[x]: https://example.com/reference" + ) + require view.waitForMarkdownParsing() + let expected = "☑ Done\n☐ pending\n☒ Cancelled" + check view.textStorage().stringValue() == expected + var style = view.markdownStyle + style.mutedColor = color(0.7, 0.2, 0.1) + view.markdownStyle = style + require view.waitForMarkdownRendering() + check view.textStorage().stringValue() == expected + check view.textStorage().attributesFor("☑").foregroundColor == style.mutedColor + check view.textStorage().attributesFor("pending").link == "https://example.com" + test "block quotes prefix every line and thematic breaks use the configured rule": let style = initMarkdownStyle() diff --git a/tests/nimkit/monotextviews.nim b/tests/nimkit/monotextviews.nim index 640e24ee..c23ac583 100644 --- a/tests/nimkit/monotextviews.nim +++ b/tests/nimkit/monotextviews.nim @@ -516,6 +516,27 @@ suite "nimkit mono text views": check foundText + test "monospace checkbox cells use symbol fallback and keep their grid positions": + let view = newMonoTextViewer("☐☑☒", frame = rect(0, 0, 220, 80)) + view.fontName = "JetBrainsMonoNerdFontMono-Regular.ttf.zip" + let list = buildRenders(view)[DefaultDrawLevel] + var foundText = false + for node in list.nodes: + if node.kind == nkText and node.renderedText() == "☐☑☒": + foundText = true + require node.textLayout.arrangedGlyphs.len == 3 + let glyphs = node.textLayout.arrangedGlyphs + for glyph in glyphs: + check glyph.glyphId != FontGlyphId(0) + check getTypefaceSource(getFigFont(glyph.fontId).typefaceId).name.find( + "JetBrainsMonoNerdFontMono" + ) < 0 + check glyphs[1].pos.x - glyphs[0].pos.x > 0 + check abs( + (glyphs[2].pos.x - glyphs[1].pos.x) - (glyphs[1].pos.x - glyphs[0].pos.x) + ) < 0.001 + check foundText + test "row glyph batches preserve cell positions across colors and blank backgrounds": let view = newMonoTextViewer(frame = rect(0, 0, 240, 80)) diff --git a/tests/nimkit/renderfragments.nim b/tests/nimkit/renderfragments.nim index e9675b98..554d07d9 100644 --- a/tests/nimkit/renderfragments.nim +++ b/tests/nimkit/renderfragments.nim @@ -1,4 +1,4 @@ -import std/[hashes, tables, unicode, unittest] +import std/[hashes, strutils, tables, unicode, unittest] import figdraw import pkg/pixie except draw @@ -951,8 +951,9 @@ suite "NimKit render fragments": var renderedLine: string for glyphIndex in 0 ..< node.textLayout.glyphCount(): renderedLine.add node.textLayout.displayRune(glyphIndex) - renderedLines.add renderedLine - check renderedLines == @["alpha\n", "Beta\n", "gamma"] + # Shaping backends can omit newline glyphs while retaining their source. + renderedLines.add renderedLine.strip(chars = {'\n', '\r'}) + check renderedLines == @["alpha", "Beta", "gamma"] let replica = retainedScenes.newRenderSceneReplica() var fullUpdate = retainedScenes.newRenderSceneUpdate(scene, 0, 0) diff --git a/tests/nimkit/textlayout.nim b/tests/nimkit/textlayout.nim index b61fb0cc..08f17752 100644 --- a/tests/nimkit/textlayout.nim +++ b/tests/nimkit/textlayout.nim @@ -1121,13 +1121,18 @@ suite "nimkit text layout": ) let snapshot = manager.layoutSnapshot() - check snapshot.lineFragments.len >= 4 + require snapshot.lineFragments.len == 4 check snapshot.hasLineStart(0) check snapshot.hasLineStart(2) check snapshot.hasLineStart(3) check snapshot.hasLineStart(5) check snapshot.lineFragments[^1].textRange.location == 5 check snapshot.lineFragments[^1].glyphRange.isEmpty + for index, start in [0, 2, 3, 5]: + check snapshot.lineFragments[index].textRange.location == start + if index > 0: + check snapshot.lineFragments[index].fragmentRect.minY >= + snapshot.lineFragments[index - 1].fragmentRect.maxY - TextLayoutEpsilon test "layout lifecycle and scalar queries expose current cached layout": let manager = newTextLayoutManager( diff --git a/tests/nimkit/tooltips.nim b/tests/nimkit/tooltips.nim index cd3bcf36..cbed57ed 100644 --- a/tests/nimkit/tooltips.nim +++ b/tests/nimkit/tooltips.nim @@ -1,4 +1,4 @@ -import std/[times, unicode, unittest] +import std/[times, unittest] import figdraw @@ -11,8 +11,8 @@ func center(bounds: Rect): Point = ) proc renderedText(node: Fig): string = - for glyphIndex in 0 ..< node.textLayout.glyphCount(): - result.add node.textLayout.displayRune(glyphIndex) + # A shaped glyph can cover multiple characters, such as the `fi` ligature. + node.textLayout.sourceRunes.bytes() proc layerContainsText(renders: Renders, level: ZLevel, text: string): bool = if level notin renders.layers: