diff --git a/.github/workflows/build-native.yml b/.github/workflows/build-native.yml index 65be88f7..ced50cb6 100644 --- a/.github/workflows/build-native.yml +++ b/.github/workflows/build-native.yml @@ -1,7 +1,8 @@ name: Build native # Produces the diffengine_viewer binaries committed under -# src/DiffEngineViewer/runtimes/{rid}/native. +# src/DiffEngineViewer.{Linux,Mac}/runtimes/{rid}/native, in the head that loads them. There is no +# Windows equivalent: that head renders with WinForms. # # They are committed rather than built during a normal build so that a plain # `dotnet build src --configuration Release` produces a shippable package on any machine, and @@ -34,12 +35,7 @@ jobs: fail-fast: false matrix: include: - - rid: win-x64 - os: windows-latest - generator: -A x64 - - rid: win-arm64 - os: windows-latest - generator: -A ARM64 + # No Windows entries. That head renders with WinForms and loads no native library. - rid: linux-x64 os: ubuntu-24.04 - rid: linux-arm64 @@ -61,40 +57,83 @@ jobs: libgl1-mesa-dev libglu1-mesa-dev libwayland-dev libxkbcommon-dev - name: Configure - shell: bash - run: | - if [ "${{ matrix.rid }}" = "osx" ]; then - cmake -S native -B build -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" - elif [ "${{ runner.os }}" = "Windows" ]; then - cmake -S native -B build ${{ matrix.generator }} - else - cmake -S native -B build -G Ninja -DCMAKE_BUILD_TYPE=Release - fi + if: matrix.rid != 'osx' + run: cmake -S native -B build -G Ninja -DCMAKE_BUILD_TYPE=Release - name: Build + if: matrix.rid != 'osx' run: cmake --build build --config Release + # macOS draws with AppKit and Core Text rather than raylib and ImGui, so it is a Swift + # package rather than a CMake project. Both --arch flags in one invocation produce a + # universal binary, so there is no separate lipo step. + # + # Nothing of the Swift runtime is shipped: it has been part of macOS since 10.14.4, which is + # why this dylib is a fraction of the size of the one it replaced. + - name: Build + if: matrix.rid == 'osx' + run: swift build -c release --arch arm64 --arch x86_64 --package-path native/swift + - name: Collect shell: bash run: | + # Laid out as src/{head}/runtimes/{rid}/native, so the propose job below can merge every + # artifact straight into src and the binaries land in the head that loads them. collect() { - mkdir -p "artifacts/$1/native" - cp "$2" "artifacts/$1/native/" + mkdir -p "artifacts/$1/runtimes/$2/native" + cp "$3" "artifacts/$1/runtimes/$2/native/" } case "${{ matrix.rid }}" in - win-*) - collect "${{ matrix.rid }}" build/Release/diffengine_viewer.dll - ;; linux-*) strip build/libdiffengine_viewer.so - collect "${{ matrix.rid }}" build/libdiffengine_viewer.so + collect DiffEngineViewer.Linux "${{ matrix.rid }}" build/libdiffengine_viewer.so ;; osx) - strip -x build/libdiffengine_viewer.dylib + # swift build leaves a per architecture dylib in more than one place, so taking the + # first one found gives a single slice binary that loads on half the Macs in the + # world. Every candidate is checked, and if none is already universal they are + # merged, so the only thing that can be collected is a fat binary. + # The dSYM contains a DWARF file of the same name which is also universal, so the + # arch check below would happily accept it and ship debug symbols as the library. + candidates=$(find native/swift/.build -name libdiffengine_viewer.dylib -not -path '*.dSYM/*') + if [ -z "$candidates" ]; then + echo "::error::swift build produced no libdiffengine_viewer.dylib" + exit 1 + fi + + echo "$candidates" | while read -r candidate; do + echo "$candidate: $(lipo -archs "$candidate" 2>/dev/null)" + done + + dylib="" + for candidate in $candidates; do + archs=$(lipo -archs "$candidate" 2>/dev/null || echo "") + if [[ "$archs" == *x86_64* && "$archs" == *arm64* ]]; then + dylib="$candidate" + break + fi + done + + if [ -z "$dylib" ]; then + dylib=universal/libdiffengine_viewer.dylib + mkdir -p universal + # shellcheck disable=SC2086 + lipo -create $candidates -output "$dylib" + fi + + archs=$(lipo -archs "$dylib") + echo "collecting $dylib: $archs" + for arch in x86_64 arm64; do + case " $archs " in + *" $arch "*) ;; + *) echo "::error::$dylib is missing the $arch slice"; exit 1 ;; + esac + done + + strip -x "$dylib" # The dylib is universal, so both macOS RIDs get the same file. - collect osx-x64 build/libdiffengine_viewer.dylib - collect osx-arm64 build/libdiffengine_viewer.dylib + collect DiffEngineViewer.Mac osx-x64 "$dylib" + collect DiffEngineViewer.Mac osx-arm64 "$dylib" ;; esac ls -lhR artifacts @@ -133,11 +172,11 @@ jobs: with: pattern: native-* merge-multiple: true - path: src/DiffEngineViewer/runtimes + path: src - name: Show what changed run: | - ls -lhR src/DiffEngineViewer/runtimes + ls -lhR src/DiffEngineViewer.Linux/runtimes src/DiffEngineViewer.Mac/runtimes git status --short # A PR rather than a direct push: these are binaries, so the diff is not reviewable and the @@ -151,7 +190,10 @@ jobs: title: 'Rebuild native renderer binaries' commit-message: 'Rebuild native renderer binaries' body: | - Rebuilt `diffengine_viewer` from `native/` for all six RIDs. + Rebuilt `diffengine_viewer` from `native/` for the four RIDs that load one. + Windows is not among them: that head renders with WinForms. Produced by the `build-native` workflow from ${{ github.sha }}. - add-paths: src/DiffEngineViewer/runtimes + add-paths: | + src/DiffEngineViewer.Linux/runtimes + src/DiffEngineViewer.Mac/runtimes diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index 7473ae31..ea17edb2 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -59,13 +59,18 @@ jobs: shell: bash run: | missing=0 - for rid in win-x64 win-arm64 linux-x64 linux-arm64 osx-x64 osx-arm64; do - directory="src/DiffEngineViewer/runtimes/$rid/native" + check() { + directory="src/$1/runtimes/$2/native" if [ -z "$(ls -A "$directory" 2>/dev/null)" ]; then - echo "::error::No native renderer for $rid. Run the build-native workflow." + echo "::error::No native renderer for $2. Run the build-native workflow." missing=1 fi - done + } + # No Windows RIDs: that head renders with WinForms and loads no native library. + check DiffEngineViewer.Linux linux-x64 + check DiffEngineViewer.Linux linux-arm64 + check DiffEngineViewer.Mac osx-x64 + check DiffEngineViewer.Mac osx-arm64 exit $missing # Enumerated rather than passed as a glob. This job runs on Windows, where the shell is pwsh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 79c5e7fb..244fc3cf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -66,7 +66,9 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + # macOS is pinned rather than latest, because it carries committed pixel baselines and + # Core Text rasterisation moves between OS versions. Same image build-native uses. + os: [ubuntu-latest, macos-14] steps: - name: Checkout uses: actions/checkout@v4 @@ -104,8 +106,8 @@ jobs: run: | cmake -S native -B native/build/linux-x64 -G Ninja -DCMAKE_BUILD_TYPE=Release cmake --build native/build/linux-x64 - mkdir -p src/DiffEngineViewer/runtimes/linux-x64/native - cp native/build/linux-x64/libdiffengine_viewer.so src/DiffEngineViewer/runtimes/linux-x64/native/ + mkdir -p src/DiffEngineViewer.Linux/runtimes/linux-x64/native + cp native/build/linux-x64/libdiffengine_viewer.so src/DiffEngineViewer.Linux/runtimes/linux-x64/native/ # Release-NotWindows drops the WinForms tray and its tests from the solution. - name: Build @@ -130,6 +132,17 @@ jobs: dotnet test src/DiffEngineViewer.Tests/DiffEngineViewer.Tests.csproj --configuration Release --no-build --no-restore + # No xvfb and no GL flags: deview_capture on this platform draws into a bitmap context of its + # own making, so it needs neither a window nor a window server. Determinism is pinned inside + # that call rather than by the environment. + - name: Pixel snapshots + if: runner.os == 'macOS' + env: + DIFFENGINE_VIEWER_PIXEL_TESTS: 'true' + run: > + dotnet test src/DiffEngineViewer.Tests/DiffEngineViewer.Tests.csproj + --configuration Release --no-build --no-restore + - name: Upload received on failure if: failure() uses: actions/upload-artifact@v4 @@ -139,10 +152,10 @@ jobs: if-no-files-found: ignore retention-days: 14 - # The jobs above only ever load the x64 natives: the Linux one rebuilds its own from source, and - # Windows never P/Invokes because the pixel tests are Linux gated. This job exists so the other - # four committed binaries are actually loaded somewhere, which is what catches a wrong - # architecture, a file corrupted by a text mode checkout, or an unsatisfied runtime dependency. + # The jobs above only ever load the x64 natives, and the Linux one rebuilds its own from source. + # This job exists so the arm64 binaries are actually loaded somewhere, which is what catches a + # wrong architecture, a file corrupted by a text mode checkout, or an unsatisfied runtime + # dependency. # # It runs DiffEngineViewer.Tests rather than the whole suite: that is where the native smoke test # lives, it takes seconds, and it gives the screen and IPC tests some cross architecture coverage @@ -159,12 +172,12 @@ jobs: # targeting one now sits queued indefinitely. Both macOS RIDs ship the same universal # dylib, so loading it here covers the arm64 slice and the step below checks that the # x86_64 slice is present. + # + # No Windows entry either, since that head renders with WinForms and loads nothing. - rid: osx-arm64 os: macos-14 - rid: linux-arm64 os: ubuntu-24.04-arm - - rid: win-arm64 - os: windows-11-arm steps: - name: Checkout uses: actions/checkout@v4 @@ -189,7 +202,7 @@ jobs: if: matrix.rid == 'osx-arm64' run: | for rid in osx-arm64 osx-x64; do - dylib="src/DiffEngineViewer/runtimes/$rid/native/libdiffengine_viewer.dylib" + dylib="src/DiffEngineViewer.Mac/runtimes/$rid/native/libdiffengine_viewer.dylib" archs=$(lipo -archs "$dylib") echo "$rid: $archs" for arch in x86_64 arm64; do diff --git a/claude.md b/claude.md index c07dcb6d..dc57c813 100644 --- a/claude.md +++ b/claude.md @@ -46,30 +46,40 @@ DiffEngine is a library that manages launching and cleanup of diff tools for sna - `ResolvedTool` - A diff tool that was found on the system with its resolved executable path. - `BuildServerDetector` - Detects CI/build server environments to disable diff tool launching. -**DiffEngineViewer (`src/DiffEngineViewer/`):** -- Cross platform GUI diff tool: Dear ImGui rendered through raylib. Reviews inline snapshots and - plain two-file diffs. +**DiffEngineViewer (`src/DiffEngineViewer/` plus three heads):** +- Cross platform GUI diff tool. Reviews inline snapshots and plain two-file diffs. +- `src/DiffEngineViewer/` is a **library** (`DiffEngineViewer.Core.dll`) holding everything that is + not a renderer. `src/DiffEngineViewer.{Windows,Mac,Linux}/` are thin `Exe` heads, one package + each, all named `DiffEngineViewer` so the launcher can resolve the executable by name. +- One package per OS rather than one portable one, because WinForms must be named as a framework + dependency and such a package cannot start on macOS or Linux. - Bundled inside DiffEngine.nupkg under `tools/viewer/{rid}/`, so inline snapshots work with no - extra install. Also shipped standalone as the `DiffEngineViewer` dotnet tool. + extra install. `DiffEngine.csproj` maps each RID to the head that renders on it. - `ViewerSession` is a pure state machine over an immutable `SessionState`. `ScreenBuilder` projects that into a `Screen` (already sliced to the visible rows), which `AsciiRenderer` draws - as text and the native shim draws as pixels. Both renderers consume the identical structure, - which is what makes the text snapshots meaningful. + as text and each `IViewerWindow` draws as pixels. Every renderer consumes the identical + structure, which is what makes the text snapshots meaningful and keeps three renderers honest. +- `ViewerProgram.Run(args, OpenWindow)` owns the loop for all heads. A head is a `Main` that + chooses a renderer; nothing else about the app is per platform. +- Windows renders with **WinForms** and loads no native library. It is pumped through + `Application.DoEvents` rather than `Application.Run`, so the shared loop stays shared. +- macOS renders with **AppKit and Core Text** (`native/swift/`), Linux with **raylib and Dear + ImGui** (`native/`). Both implement the same C ABI, so the managed interop layer is identical. - Does **not** reference DiffEngine. It links `Inline/*.cs` and `Tray/TrayDetector.cs` as source, - because DiffEngine publishes and embeds the viewer and a reference back would be a cycle. + because DiffEngine publishes and embeds the heads and a reference back would be a cycle. - Single instance by socket bind on 3493 (`DiffEngine_ViewerPort`): whoever binds owns the window, and a process that fails to bind forwards its patch and exits. -**Native shim (`native/`):** +**Native shim (`native/`), used by the Mac and Linux heads only:** - `raylib` and `imgui` are fetched by CMake (`FetchContent`), pinned by tag in `native/CMakeLists.txt`. Deliberately not submodules: nothing in a normal `dotnet build` touches this folder, so a recursive clone on every checkout would serve a path almost nobody takes. - Building it needs CMake 3.24+, a C++17 compiler and network access. Contributors do not need any of that, because the binaries are committed. -- `native/src/deview.cpp` is a renderer for the `Screen` model, not an ImGui binding: ~12 exports +- `native/src/deview.cpp` is a renderer for the `Screen` model, not an ImGui binding: eight exports taking one flat blittable frame description. The ABI is `native/include/deview.h`; bump - `DEVIEW_VERSION` whenever the structs change. -- Built binaries are **committed** to `src/DiffEngineViewer/runtimes/{rid}/native/`, so a plain + `DEVIEW_VERSION` whenever the structs change **or a field changes meaning**. +- Built binaries are **committed** to `src/DiffEngineViewer.{Linux,Mac}/runtimes/{rid}/native/`, so a plain `dotnet build` produces a shippable package and contributors never need CMake. Regenerate them with the `build-native` GitHub workflow, which opens a PR. @@ -82,6 +92,16 @@ DiffEngine is a library that manages launching and cleanup of diff tools for sna every platform rather than a Windows-only copy that can drift. - Allows accepting/discarding diffs from system tray +**Packaging.Tests (`src/Packaging.Tests/`):** +- Opens each `.nupkg` a Release build drops in `nugets` and snapshots its entry list, plus a few + invariants a snapshot states poorly: an apphost with no assembly beside it, a viewer file in the + tray package, an incomplete bundled head. +- Exists because package content is assembled by several unrelated MSBuild mechanisms and nothing + else asserts the result. The failure mode it was written for is stale build output: `PackAsTool` + packages the publish directory wholesale, and MSBuild never removes a file that stopped being + produced, so anything a discarded experiment left in `bin` keeps shipping. +- Windows only, and skipped entirely when no packages were produced, which is every Debug build. + ### Adding a New Diff Tool 1. Add enum value to `DiffTool.cs` diff --git a/docs/diff-tool.md b/docs/diff-tool.md index 125b26e0..c6e34696 100644 --- a/docs/diff-tool.md +++ b/docs/diff-tool.md @@ -284,8 +284,8 @@ DiffTools.UseOrder(DiffTool.DiffEngineViewer); #### Notes: * Bundled inside the DiffEngine package, so it needs no install - * Also available standalone via `dotnet tool install -g DiffEngineViewer` - * Cross platform: Windows, macOS and Linux + * Also available standalone as `DiffEngineViewer.Windows`, `.Mac` or `.Linux` + * Cross platform: WinForms on Windows, Dear ImGui through raylib elsewhere #### Windows settings: diff --git a/docs/mdsource/viewer.source.md b/docs/mdsource/viewer.source.md index 49a536a7..8bdf9ccd 100644 --- a/docs/mdsource/viewer.source.md +++ b/docs/mdsource/viewer.source.md @@ -8,18 +8,34 @@ source file. Unlike every other entry in the [tool list](/docs/diff-tool.md), it does not need to be installed. A copy ships inside the DiffEngine package, so it is always present. -The UI is [Dear ImGui](https://github.com/ocornut/imgui) rendered through -[raylib](https://github.com/raysan5/raylib). +The renderer is native to each platform: + +| Platform | Renderer | +| --- | --- | +| Windows | WinForms | +| macOS | AppKit and Core Text | +| Linux | [Dear ImGui](https://github.com/ocornut/imgui) through [raylib](https://github.com/raysan5/raylib) | + +All three draw the same screen model, and the layout, scrolling and keyboard handling are shared, +so the only difference is how the pixels get there. ## NuGet - * https://www.nuget.org/packages/DiffEngineViewer + * https://www.nuget.org/packages/DiffEngineViewer.Windows + * https://www.nuget.org/packages/DiffEngineViewer.Mac + * https://www.nuget.org/packages/DiffEngineViewer.Linux Only needed to use the viewer outside a project that references DiffEngine, since DiffEngine already bundles it. -`dotnet tool install -g DiffEngineViewer` +``` +dotnet tool install -g DiffEngineViewer.Windows +``` + +One package per operating system rather than one for all of them, because WinForms has to be named +as a framework dependency and a package that names it cannot start anywhere else. The copy bundled +in DiffEngine is unaffected: it is published per RID and resolved by directory. ## Usage @@ -80,5 +96,5 @@ continuous testing and AI CLIs. ## Platforms Ships for `win-x64`, `win-arm64`, `linux-x64`, `linux-arm64`, `osx-x64` and `osx-arm64`. On a -platform with no matching binary, resolution falls through to a globally installed +platform with no matching build, resolution falls through to a globally installed DiffEngineViewer tool, and then to whatever other diff tool is available. diff --git a/docs/viewer.md b/docs/viewer.md index c28ce097..cd5159f7 100644 --- a/docs/viewer.md +++ b/docs/viewer.md @@ -15,18 +15,34 @@ source file. Unlike every other entry in the [tool list](/docs/diff-tool.md), it does not need to be installed. A copy ships inside the DiffEngine package, so it is always present. -The UI is [Dear ImGui](https://github.com/ocornut/imgui) rendered through -[raylib](https://github.com/raysan5/raylib). +The renderer is native to each platform: + +| Platform | Renderer | +| --- | --- | +| Windows | WinForms | +| macOS | AppKit and Core Text | +| Linux | [Dear ImGui](https://github.com/ocornut/imgui) through [raylib](https://github.com/raysan5/raylib) | + +All three draw the same screen model, and the layout, scrolling and keyboard handling are shared, +so the only difference is how the pixels get there. ## NuGet - * https://www.nuget.org/packages/DiffEngineViewer + * https://www.nuget.org/packages/DiffEngineViewer.Windows + * https://www.nuget.org/packages/DiffEngineViewer.Mac + * https://www.nuget.org/packages/DiffEngineViewer.Linux Only needed to use the viewer outside a project that references DiffEngine, since DiffEngine already bundles it. -`dotnet tool install -g DiffEngineViewer` +``` +dotnet tool install -g DiffEngineViewer.Windows +``` + +One package per operating system rather than one for all of them, because WinForms has to be named +as a framework dependency and a package that names it cannot start anywhere else. The copy bundled +in DiffEngine is unaffected: it is published per RID and resolved by directory. ## Usage @@ -87,5 +103,5 @@ continuous testing and AI CLIs. ## Platforms Ships for `win-x64`, `win-arm64`, `linux-x64`, `linux-arm64`, `osx-x64` and `osx-arm64`. On a -platform with no matching binary, resolution falls through to a globally installed +platform with no matching build, resolution falls through to a globally installed DiffEngineViewer tool, and then to whatever other diff tool is available. diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index c72c28de..7542c3b2 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -17,8 +17,9 @@ endif() # Sources are fetched rather than vendored as submodules. # # Nothing in a normal `dotnet build` touches this directory: the built binaries are committed to -# src/DiffEngineViewer/runtimes, so only this file's own build needs raylib and imgui. Submodules -# would have imposed a recursive clone on every checkout to serve a path almost nobody takes. +# the runtimes folder of the Linux and Mac heads, so only this file's own build needs raylib and +# imgui. Submodules would have imposed a recursive clone on every checkout to serve a path almost +# nobody takes. # # The versions are pinned by tag here, in the same file as the build configuration. # @@ -73,6 +74,8 @@ else() target_compile_options(diffengine_viewer PRIVATE -Wall -Wextra -Wno-unused-parameter) endif() +# Kept buildable on macOS for comparison against the Swift renderer that actually ships there, +# but nothing consumes the result: build-native builds native/swift for the osx RIDs. if(APPLE) set_target_properties(diffengine_viewer PROPERTIES SUFFIX ".dylib") endif() diff --git a/native/include/deview.h b/native/include/deview.h index 3d57b18a..5cfc0ede 100644 --- a/native/include/deview.h +++ b/native/include/deview.h @@ -124,13 +124,37 @@ typedef struct DeviewInput { int32_t scrollDelta; /* Set when the user asked to close the window; the managed side decides hide versus exit. */ int32_t closeRequested; + /* + * The window size in character cells, not pixels. Measured here from the font that was + * actually loaded, because this side is the only one that knows it. Reporting pixels and + * having the managed side divide by a constant is what left the viewer with no DPI handling. + */ int32_t columns; int32_t rows; } DeviewInput; /* - * Returns 1 on success. fontTtf may be NULL, in which case ImGui's built in font is used. - * hidden starts the window offscreen, which the pixel snapshot tests rely on. + * Bumped whenever the structs above change, or what a field means changes, so a stale native + * library is detected not crashed. + * + * 2: DeviewInput.columns and rows carry character cells rather than pixels. + */ +#define DEVIEW_VERSION 2 + +/* + * The Swift implementation imports this header for the struct layouts, because Swift does not + * guarantee its own, and then defines the entry points itself with @_cdecl. It defines + * DEVIEW_TYPES_ONLY so it does not also import prototypes for symbols it is about to provide. + */ +#ifndef DEVIEW_TYPES_ONLY + +/* + * Returns 1 on success. fontTtf may be NULL, in which case a built in font is used. + * + * hidden starts without a visible window, which the pixel snapshot tests rely on. An + * implementation may defer creating the window entirely until deview_set_hidden asks for one: + * capture does not need it, and on macOS a window may only be created on the main thread, which a + * test host does not promise. */ DEVIEW_API int32_t deview_init( int32_t width, @@ -159,10 +183,10 @@ DEVIEW_API void deview_focus(void); DEVIEW_API void deview_shutdown(void); -/* Bumped whenever the structs above change, so a stale native library is detected not crashed. */ -#define DEVIEW_VERSION 1 DEVIEW_API int32_t deview_version(void); +#endif /* DEVIEW_TYPES_ONLY */ + #ifdef __cplusplus } #endif diff --git a/native/src/deview.cpp b/native/src/deview.cpp index 4553d462..70d684e2 100644 --- a/native/src/deview.cpp +++ b/native/src/deview.cpp @@ -307,6 +307,27 @@ int ReadKey() return DEVIEW_KEY_NONE; } +/* + * The window size in character cells. Measured from the font that was actually loaded, because + * this side is the only one that knows it: the managed side used to divide pixels by a hardcoded + * 9 by 18, which is why the viewer had no DPI handling at all. + * + * A row is one text line plus the spacing between rows, which is what the table the panes are + * drawn in lays out on. + */ +void MeasureGrid() +{ + ImGui::SetCurrentContext(state.context); + const float width = ImGui::CalcTextSize("M").x; + const float height = ImGui::GetTextLineHeightWithSpacing(); + state.input.columns = width > 0.0f + ? static_cast(static_cast(GetScreenWidth()) / width) + : 0; + state.input.rows = height > 0.0f + ? static_cast(static_cast(GetScreenHeight()) / height) + : 0; +} + /* ---- the frame ---- */ void DrawRow(const DeviewScreen* screen, const DeviewPane& pane, int index, int column) @@ -589,8 +610,7 @@ int32_t deview_present(const DeviewScreen* screen) RenderDrawData(ImGui::GetDrawData()); EndDrawing(); - state.input.columns = GetScreenWidth(); - state.input.rows = GetScreenHeight(); + MeasureGrid(); return 1; } @@ -606,8 +626,7 @@ void deview_poll_input(DeviewInput* input) state.input.key = ReadKey(); const Vector2 wheel = GetMouseWheelMoveV(); state.input.scrollDelta = static_cast(wheel.y); - state.input.columns = GetScreenWidth(); - state.input.rows = GetScreenHeight(); + MeasureGrid(); } *input = state.input; diff --git a/native/swift/Package.swift b/native/swift/Package.swift new file mode 100644 index 00000000..b882eff0 --- /dev/null +++ b/native/swift/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version: 5.9 +import PackageDescription + +// The product name decides the file name: SwiftPM emits lib.dylib, which is what +// NativeResolver probes for. Nothing else here may rename it. +let package = Package( + name: "diffengine_viewer", + platforms: [.macOS(.v12)], + products: [ + .library(name: "diffengine_viewer", type: .dynamic, targets: ["Deview"]) + ], + targets: [ + // Exists only to import the ABI. Swift does not guarantee struct layout, so the structs + // have to come from the C header rather than being redeclared here. + .target(name: "CDeview"), + .target( + name: "Deview", + dependencies: ["CDeview"], + linkerSettings: [ + .linkedFramework("AppKit"), + .linkedFramework("CoreText"), + .linkedFramework("CoreGraphics"), + .linkedFramework("ImageIO") + ]) + ]) diff --git a/native/swift/Sources/CDeview/bridge.c b/native/swift/Sources/CDeview/bridge.c new file mode 100644 index 00000000..586e8f90 --- /dev/null +++ b/native/swift/Sources/CDeview/bridge.c @@ -0,0 +1,3 @@ +/* SwiftPM needs a C target to have at least one source file. There is nothing to compile: this + * target exists only to publish deview_bridge.h to Swift. */ +#include "include/deview_bridge.h" diff --git a/native/swift/Sources/CDeview/include/deview_bridge.h b/native/swift/Sources/CDeview/include/deview_bridge.h new file mode 100644 index 00000000..8d290c58 --- /dev/null +++ b/native/swift/Sources/CDeview/include/deview_bridge.h @@ -0,0 +1,14 @@ +/* + * Brings the ABI structs into Swift without their prototypes: this library provides those symbols + * itself, with @_cdecl, so importing declarations for them as well would only invite a clash. + * + * The canonical header is reached by a relative include rather than copied, because two copies of + * a struct layout is exactly the bug DEVIEW_VERSION exists to catch. + */ +#ifndef DEVIEW_BRIDGE_H +#define DEVIEW_BRIDGE_H + +#define DEVIEW_TYPES_ONLY +#include "../../../../include/deview.h" + +#endif diff --git a/native/swift/Sources/Deview/Exports.swift b/native/swift/Sources/Deview/Exports.swift new file mode 100644 index 00000000..6267c3fc --- /dev/null +++ b/native/swift/Sources/Deview/Exports.swift @@ -0,0 +1,138 @@ +import AppKit +import CDeview +import CoreGraphics +import Foundation +import ImageIO + +/// The eight entry points of native/include/deview.h, implemented over AppKit and Core Text. +/// +/// The header is imported for its struct layouts only, with DEVIEW_TYPES_ONLY, so these are the +/// definitions of those symbols rather than a second declaration of them. + +@_cdecl("deview_version") +public func deviewVersion() -> Int32 { + Int32(DEVIEW_VERSION) +} + +@_cdecl("deview_init") +public func deviewInit( + _ width: Int32, + _ height: Int32, + _ title: UnsafePointer?, + _ fontTtf: UnsafePointer?, + _ fontLength: Int32, + _ fontSize: Float, + _ hidden: Int32) -> Int32 { + var font: Data? + if let fontTtf, fontLength > 0 { + font = Data(bytes: fontTtf, count: Int(fontLength)) + } + + let opened = Runtime.shared.open( + width: width, + height: height, + title: title.map { String(cString: $0) } ?? "DiffEngineViewer", + font: font, + fontSize: CGFloat(fontSize), + hidden: hidden != 0) + return opened ? 1 : 0 +} + +@_cdecl("deview_present") +public func deviewPresent(_ screen: UnsafePointer?) -> Int32 { + let runtime = Runtime.shared + guard runtime.initialised, let screen else { + return 0 + } + + runtime.present(Frame.decode(screen)) + return 1 +} + +@_cdecl("deview_poll_input") +public func deviewPollInput(_ input: UnsafeMutablePointer?) { + guard let input else { + return + } + + let runtime = Runtime.shared + if runtime.initialised { + runtime.measureGrid() + } + + input.pointee = runtime.input + runtime.resetInput() +} + +@_cdecl("deview_set_hidden") +public func deviewSetHidden(_ hidden: Int32) { + if hidden == 0 { + Runtime.shared.show() + } else { + Runtime.shared.hide() + } +} + +@_cdecl("deview_focus") +public func deviewFocus() { + Runtime.shared.show() +} + +@_cdecl("deview_shutdown") +public func deviewShutdown() { + Runtime.shared.shutdown() +} + +/// Renders into a bitmap of this side's own making rather than asking the view for one. +/// +/// `bitmapImageRepForCachingDisplay` would inherit the window's backing scale, which is 2 on a +/// Retina machine and 1 elsewhere, so a committed baseline would only ever match on the kind of +/// display that produced it. Everything that varies is pinned here instead: scale, colour space, +/// and the six font smoothing and subpixel switches. No window is needed, which also means the +/// snapshot tests do not need a window server. +@_cdecl("deview_capture") +public func deviewCapture( + _ screen: UnsafePointer?, + _ width: Int32, + _ height: Int32, + _ pngPath: UnsafePointer?) -> Int32 { + guard let screen, + let pngPath, + let renderer = Runtime.shared.renderer, + let space = CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext( + data: nil, + width: Int(width), + height: Int(height), + bitsPerComponent: 8, + bytesPerRow: 0, + space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue) + else { + return 0 + } + + context.setAllowsFontSmoothing(false) + context.setShouldSmoothFonts(false) + context.setAllowsFontSubpixelPositioning(false) + context.setShouldSubpixelPositionFonts(false) + context.setAllowsFontSubpixelQuantization(false) + context.setShouldSubpixelQuantizeFonts(false) + + renderer.draw( + Frame.decode(screen), + in: context, + size: CGSize(width: CGFloat(width), height: CGFloat(height))) + + guard let image = context.makeImage() else { + return 0 + } + + let url = URL(fileURLWithPath: String(cString: pngPath)) as CFURL + guard let destination = CGImageDestinationCreateWithURL(url, "public.png" as CFString, 1, nil) else { + return 0 + } + + CGImageDestinationAddImage(destination, image, nil) + return CGImageDestinationFinalize(destination) ? 1 : 0 +} diff --git a/native/swift/Sources/Deview/Frame.swift b/native/swift/Sources/Deview/Frame.swift new file mode 100644 index 00000000..822147eb --- /dev/null +++ b/native/swift/Sources/Deview/Frame.swift @@ -0,0 +1,113 @@ +import CDeview +import Foundation + +/// One frame, decoded out of the flat description the managed side hands over. +/// +/// Copied rather than read in place, because the pointers in `DeviewScreen` are only valid for the +/// duration of the call that carried them, and the view redraws whenever AppKit says so. +struct Frame { + var title = "" + var subtitle = "" + var status = "" + var queue: [QueueItem] = [] + var buttons: [Button] = [] + var left = Pane() + var right = Pane() + + struct Row { + var kind: Int32 = 0 + var lineNumber: Int32 = -1 + var text = "" + } + + struct Pane { + var header = "" + var rows: [Row] = [] + } + + struct QueueItem { + var label = "" + var selected = false + var failed = false + } + + struct Button { + var label = "" + var enabled = false + } + + static func decode(_ pointer: UnsafePointer) -> Frame { + let screen = pointer.pointee + var frame = Frame() + frame.title = string(screen, screen.titleOffset, screen.titleLength) + frame.subtitle = string(screen, screen.subtitleOffset, screen.subtitleLength) + frame.status = string(screen, screen.statusOffset, screen.statusLength) + + if let items = screen.queue { + for index in 0 ..< Int(screen.queueCount) { + let item = items[index] + frame.queue.append( + QueueItem( + label: string(screen, item.labelOffset, item.labelLength), + selected: item.flags & DEVIEW_QUEUE_SELECTED.value != 0, + failed: item.flags & DEVIEW_QUEUE_FAILED.value != 0)) + } + } + + if let buttons = screen.buttons { + for index in 0 ..< Int(screen.buttonCount) { + let button = buttons[index] + frame.buttons.append( + Button( + label: string(screen, button.labelOffset, button.labelLength), + enabled: button.flags & DEVIEW_BUTTON_ENABLED.value != 0)) + } + } + + if let panes = screen.panes, screen.paneCount >= 2 { + frame.left = pane(screen, panes[0]) + frame.right = pane(screen, panes[1]) + } + + return frame + } + + private static func pane(_ screen: DeviewScreen, _ source: DeviewPane) -> Pane { + var pane = Pane() + pane.header = string(screen, source.headerOffset, source.headerLength) + guard let rows = screen.rows else { + return pane + } + + for index in 0 ..< Int(source.rowCount) { + let offset = Int(source.rowOffset) + index + guard offset >= 0, offset < Int(screen.rowCount) else { + continue + } + + let row = rows[offset] + pane.rows.append( + Row( + kind: row.kind, + lineNumber: row.lineNumber, + text: string(screen, row.textOffset, row.textLength))) + } + + return pane + } + + /// Every offset is validated against the blob rather than trusted. This is the boundary the + /// managed side reaches across, and a bad length here would be a read past the end of it. + private static func string(_ screen: DeviewScreen, _ offset: Int32, _ length: Int32) -> String { + guard length > 0, + offset >= 0, + let base = screen.strings, + Int(offset) + Int(length) <= Int(screen.stringsLength) + else { + return "" + } + + let buffer = UnsafeBufferPointer(start: base + Int(offset), count: Int(length)) + return String(decoding: buffer, as: UTF8.self) + } +} diff --git a/native/swift/Sources/Deview/Palette.swift b/native/swift/Sources/Deview/Palette.swift new file mode 100644 index 00000000..0dae5db8 --- /dev/null +++ b/native/swift/Sources/Deview/Palette.swift @@ -0,0 +1,96 @@ +import CDeview +import CoreGraphics + +/// A plain C enum arrives in Swift as a struct whose rawValue is unsigned, while every field that +/// holds one across the ABI is `int32_t`. These do the conversion once rather than at every +/// comparison. +extension DeviewRowKind { + var value: Int32 { Int32(rawValue) } +} + +extension DeviewKey { + var value: Int32 { Int32(rawValue) } +} + +extension DeviewQueueFlags { + var value: Int32 { Int32(rawValue) } +} + +extension DeviewButtonFlags { + var value: Int32 { Int32(rawValue) } +} + +/// Transcribed from `RowColour` and `RowBackground` in deview.cpp, so a change looks the same +/// whichever renderer drew it. The screen model carries a row kind and never a colour, so this +/// mapping belongs to each renderer rather than to the model. +enum Palette { + static let background = grey(24) + static let filler = grey(28) + static let text = grey(212) + + /// The gutter, and the subtitle and status that the other heads draw dimmed. + static let dim = grey(130) + + static let rule = grey(70) + + /// ImGui draws a selected item as its accent at 31% over the window background. This is that + /// composite, so the queue highlight matches without carrying an alpha channel around. + static let selected = rgb(38, 64, 90) + + static let buttonFace = grey(52) + static let buttonDisabled = grey(34) + + static func foreground(_ kind: Int32) -> CGColor { + switch kind { + case DEVIEW_ROW_ADDED.value: + return rgb(126, 214, 139) + case DEVIEW_ROW_REMOVED.value: + return rgb(233, 129, 129) + case DEVIEW_ROW_MODIFIED.value: + return rgb(231, 197, 113) + default: + return text + } + } + + /// Nil where the row takes the window background, which is every unchanged row. + static func rowBackground(_ kind: Int32) -> CGColor? { + switch kind { + case DEVIEW_ROW_ADDED.value: + return rgb(38, 74, 44) + case DEVIEW_ROW_REMOVED.value: + return rgb(84, 40, 40) + case DEVIEW_ROW_MODIFIED.value: + return rgb(74, 64, 32) + case DEVIEW_ROW_FILLER.value: + return filler + default: + return nil + } + } + + static func marker(_ kind: Int32) -> String { + switch kind { + case DEVIEW_ROW_ADDED.value: + return "+" + case DEVIEW_ROW_REMOVED.value: + return "-" + case DEVIEW_ROW_MODIFIED.value: + return "~" + default: + return " " + } + } + + private static func rgb(_ red: Int, _ green: Int, _ blue: Int) -> CGColor { + CGColor( + srgbRed: CGFloat(red) / 255, + green: CGFloat(green) / 255, + blue: CGFloat(blue) / 255, + alpha: 1) + } + + private static func grey(_ level: Int) -> CGColor { + rgb(level, level, level) + } +} diff --git a/native/swift/Sources/Deview/Renderer.swift b/native/swift/Sources/Deview/Renderer.swift new file mode 100644 index 00000000..4034b5a5 --- /dev/null +++ b/native/swift/Sources/Deview/Renderer.swift @@ -0,0 +1,256 @@ +// AppKit for NSAttributedString.Key.font and .foregroundColor, which are declared there rather +// than in Foundation. +import AppKit +import CDeview +import CoreGraphics +import CoreText +import Foundation + +/// Draws a `Frame` with Core Text. Used both for the window and for the offscreen capture, so the +/// baselines describe what a user sees rather than a second code path. +/// +/// Nothing is flipped. Core Graphics puts the origin bottom left, and layout here is expressed top +/// down and converted once in `rect`, which avoids having to fight the text matrix. +final class Renderer { + private static let queueWidth: CGFloat = 220 + private static let padding: CGFloat = 6 + private static let gap: CGFloat = 4 + + /// Marker, space, four digit line number, two spaces. Matches AsciiRenderer's gutter, so a + /// line lands in the same column in both. + private static let gutterCells: CGFloat = 8 + + private let font: CTFont + private let ascent: CGFloat + private let descent: CGFloat + + /// One character cell. Measured from the font that was actually loaded, which is what the ABI + /// reports back so the managed side can slice a pane to rows that fit. + let cell: CGSize + + /// Where the clickable things ended up, for the view's hit testing. Returned from `draw` + /// rather than stored, so an offscreen capture cannot overwrite the window's copy. + struct Layout { + var buttons: [CGRect] = [] + var queueItems: [CGRect] = [] + } + + init(fontData: Data?, size: CGFloat) { + font = Renderer.load(fontData, size) + ascent = CTFontGetAscent(font) + descent = CTFontGetDescent(font) + + var character: UniChar = 0x4D // 'M' + var glyph = CGGlyph() + var advance = CGSize.zero + if CTFontGetGlyphsForCharacters(font, &character, &glyph, 1) { + _ = CTFontGetAdvancesForGlyphs(font, .horizontal, &glyph, &advance, 1) + } + + cell = CGSize( + width: max(1, advance.width.rounded()), + height: max(1, (ascent + descent + CTFontGetLeading(font)).rounded(.up))) + } + + private static func load(_ data: Data?, _ size: CGFloat) -> CTFont { + guard let data, + !data.isEmpty, + let provider = CGDataProvider(data: data as CFData), + let cgFont = CGFont(provider) + else { + // Nothing embedded, so take the system monospaced face. + return CTFontCreateWithName("Menlo" as CFString, size, nil) + } + + // Registered process wide so Core Text can resolve it by name later if it needs to. A + // duplicate registration is not an error worth failing over, hence the ignored result. + var error: Unmanaged? + _ = CTFontManagerRegisterGraphicsFont(cgFont, &error) + error?.release() + return CTFontCreateWithGraphicsFont(cgFont, size, nil, nil) + } + + /// The window size in character cells, which is what version 2 of the ABI reports. + func grid(for size: CGSize) -> (columns: Int32, rows: Int32) { + (Int32(size.width / cell.width), Int32(size.height / cell.height)) + } + + @discardableResult + func draw(_ frame: Frame, in context: CGContext, size: CGSize) -> Layout { + var layout = Layout() + context.setFillColor(Palette.background) + context.fill(CGRect(origin: .zero, size: size)) + + let line = cell.height + let hasQueue = !frame.queue.isEmpty + let panesLeft = hasQueue ? Renderer.padding + Renderer.queueWidth + Renderer.gap : Renderer.padding + let panesWidth = max(cell.width * 2, size.width - Renderer.padding - panesLeft) + let half = (panesWidth / 2).rounded(.down) + + text(frame.title, in: rect(top: Renderer.padding, left: Renderer.padding, width: size.width - Renderer.padding * 2, height: line, size), Palette.text, context) + if !frame.subtitle.isEmpty { + let width = CGFloat(frame.subtitle.count) * cell.width + text(frame.subtitle, in: rect(top: Renderer.padding, left: size.width - Renderer.padding - width, width: width, height: line, size), Palette.dim, context) + } + + let firstRule = Renderer.padding + line + Renderer.gap + rule(top: firstRule, width: size.width, in: context, size) + + let headerTop = firstRule + Renderer.gap + if hasQueue { + text("Pending (\(frame.queue.count))", in: rect(top: headerTop, left: Renderer.padding, width: Renderer.queueWidth, height: line, size), Palette.text, context) + } + + text(frame.left.header, in: rect(top: headerTop, left: panesLeft, width: half, height: line, size), Palette.text, context) + text(frame.right.header, in: rect(top: headerTop, left: panesLeft + half, width: half, height: line, size), Palette.text, context) + rule(top: headerTop + line + Renderer.gap, width: size.width, in: context, size) + + let bodyTop = Renderer.padding + (line + Renderer.gap) * 2 + Renderer.gap * 2 + let footerHeight = line + Renderer.gap * 2 + let capacity = max(1, Int((size.height - bodyTop - footerHeight - Renderer.padding) / line)) + let rows = min(capacity, max(frame.queue.count, max(frame.left.rows.count, frame.right.rows.count))) + + for index in 0 ..< rows { + let top = bodyTop + CGFloat(index) * line + if hasQueue { + let bounds = rect(top: top, left: Renderer.padding, width: Renderer.queueWidth, height: line, size) + layout.queueItems.append(bounds) + queueItem(frame, index, bounds, context) + } + + row(frame.left, index, rect(top: top, left: panesLeft, width: half, height: line, size), context) + row(frame.right, index, rect(top: top, left: panesLeft + half, width: panesWidth - half, height: line, size), context) + } + + let bodyBottom = bodyTop + CGFloat(capacity) * line + if hasQueue { + columnRule(left: panesLeft - Renderer.gap / 2, top: bodyTop, bottom: bodyBottom, in: context, size) + } + + columnRule(left: panesLeft + half - Renderer.gap / 2, top: bodyTop, bottom: bodyBottom, in: context, size) + + layout.buttons = footer(frame, size: size, height: footerHeight, line: line, in: context) + return layout + } + + private func footer(_ frame: Frame, size: CGSize, height: CGFloat, line: CGFloat, in context: CGContext) -> [CGRect] { + let top = size.height - height - Renderer.padding + rule(top: top - Renderer.gap, width: size.width, in: context, size) + + var rects: [CGRect] = [] + var left = Renderer.padding + for button in frame.buttons { + let width = CGFloat(button.label.count + 4) * cell.width + let bounds = rect(top: top, left: left, width: width, height: height, size) + rects.append(bounds) + + context.setFillColor(button.enabled ? Palette.buttonFace : Palette.buttonDisabled) + context.fill(bounds) + let label = bounds.insetBy(dx: cell.width * 2, dy: (height - line) / 2) + text(button.label, in: label, button.enabled ? Palette.text : Palette.dim, context) + left += width + Renderer.gap + } + + if !frame.status.isEmpty { + let width = CGFloat(frame.status.count) * cell.width + let bounds = rect(top: top + (height - line) / 2, left: size.width - Renderer.padding - width, width: width, height: line, size) + text(frame.status, in: bounds, Palette.dim, context) + } + + return rects + } + + private func queueItem(_ frame: Frame, _ index: Int, _ bounds: CGRect, _ context: CGContext) { + guard index < frame.queue.count else { + return + } + + let item = frame.queue[index] + if item.selected { + context.setFillColor(Palette.selected) + context.fill(bounds) + } + + let label = item.failed ? "\(item.label) !" : item.label + let colour = item.failed ? Palette.foreground(DEVIEW_ROW_REMOVED.value) : Palette.text + text(label, in: bounds.offsetBy(dx: cell.width, dy: 0), colour, context) + } + + private func row(_ pane: Frame.Pane, _ index: Int, _ bounds: CGRect, _ context: CGContext) { + guard index < pane.rows.count else { + return + } + + let row = pane.rows[index] + if let background = Palette.rowBackground(row.kind) { + context.setFillColor(background) + context.fill(bounds) + } + + if row.kind == DEVIEW_ROW_FILLER.value { + return + } + + let number = String(row.lineNumber) + let gutter = "\(Palette.marker(row.kind)) \(String(repeating: " ", count: max(0, 4 - number.count)))\(number)" + let width = Renderer.gutterCells * cell.width + text(gutter, in: CGRect(x: bounds.minX, y: bounds.minY, width: width, height: bounds.height), Palette.dim, context) + text( + row.text, + in: CGRect(x: bounds.minX + width, y: bounds.minY, width: bounds.width - width, height: bounds.height), + Palette.foreground(row.kind), + context) + } + + /// Clipped to its own rect, so a long line stops at its column instead of running into the + /// next one. + private func text(_ string: String, in bounds: CGRect, _ colour: CGColor, _ context: CGContext) { + guard !string.isEmpty, bounds.width > 0 else { + return + } + + let attributed = NSAttributedString( + string: RowText.flatten(string), + attributes: [ + .font: font, + .foregroundColor: colour + ]) + + context.saveGState() + context.clip(to: bounds) + context.textPosition = CGPoint(x: bounds.minX, y: bounds.minY + descent) + CTLineDraw(CTLineCreateWithAttributedString(attributed), context) + context.restoreGState() + } + + private func rule(top: CGFloat, width: CGFloat, in context: CGContext, _ size: CGSize) { + context.setFillColor(Palette.rule) + context.fill(rect(top: top, left: Renderer.padding, width: width - Renderer.padding * 2, height: 1, size)) + } + + private func columnRule(left: CGFloat, top: CGFloat, bottom: CGFloat, in context: CGContext, _ size: CGSize) { + context.setFillColor(Palette.rule) + context.fill(rect(top: top, left: left, width: 1, height: bottom - top, size)) + } + + /// Top down layout into Core Graphics' bottom left origin, in one place. + private func rect(top: CGFloat, left: CGFloat, width: CGFloat, height: CGFloat, _ size: CGSize) -> CGRect { + CGRect(x: left, y: size.height - top - height, width: max(0, width), height: height) + } +} + +/// A tab or a stray newline would break a character grid. Every renderer has to resolve them the +/// same way or the text snapshots stop describing what the pixel ones show, so this matches +/// RowText.Flatten on the managed side. +enum RowText { + static func flatten(_ text: String) -> String { + guard text.contains(where: { $0 == "\t" || $0 == "\r" || $0 == "\n" }) else { + return text + } + + return text + .replacingOccurrences(of: "\t", with: " ") + .replacingOccurrences(of: "\r", with: "") + .replacingOccurrences(of: "\n", with: " ") + } +} diff --git a/native/swift/Sources/Deview/Runtime.swift b/native/swift/Sources/Deview/Runtime.swift new file mode 100644 index 00000000..aab5328e --- /dev/null +++ b/native/swift/Sources/Deview/Runtime.swift @@ -0,0 +1,149 @@ +import AppKit +import CDeview +import Foundation + +/// Process wide, because the ABI is: one window, addressed by free functions. +/// +/// Everything here runs on the thread that calls in, which is the managed side's main thread and +/// therefore the process main thread. AppKit requires that, and it is also why the loop stays in +/// C# rather than being inverted into `NSApplication.run`. +final class Runtime { + static let shared = Runtime() + + private var delegate: WindowDelegate? + private var size = CGSize(width: 1100, height: 700) + private var title = "DiffEngineViewer" + + var window: NSWindow? + var view: ViewerView? + var renderer: Renderer? + var input = DeviewInput() + var initialised = false + + private init() { + resetInput() + } + + func open(width: Int32, height: Int32, title: String, font: Data?, fontSize: CGFloat, hidden: Bool) -> Bool { + if initialised { + return true + } + + renderer = Renderer(fontData: font, size: fontSize) + size = CGSize(width: CGFloat(width), height: CGFloat(height)) + self.title = title + initialised = true + + // A hidden start is capture only, and capture draws into a bitmap of its own making. Not + // touching AppKit at all in that case is what lets the pixel tests run: NSWindow may only + // be instantiated on the main thread, and a test host runs them on whatever thread it + // likes. The app itself always starts visible, from Main, which is the main thread. + if !hidden { + makeWindow() + } + + measureGrid() + return true + } + + private func makeWindow() { + guard window == nil, let renderer else { + return + } + + let application = NSApplication.shared + // Regular rather than accessory, so the window can take focus and appear in the dock + // without this being an app bundle. finishLaunching is the part of run() that has to + // happen before events are pumped by hand. + application.setActivationPolicy(.regular) + application.finishLaunching() + + let bounds = NSRect(origin: .zero, size: size) + let view = ViewerView(renderer: renderer, frame: bounds) + let window = NSWindow( + contentRect: bounds, + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false) + let delegate = WindowDelegate() + + window.title = title + window.contentView = view + window.delegate = delegate + window.isReleasedWhenClosed = false + window.center() + + self.view = view + self.window = window + self.delegate = delegate + } + + func present(_ frame: Frame) { + // Nothing to present when this runtime never took a window. Capture goes straight to the + // renderer, so a headless one is still useful. + guard let view else { + return + } + + view.model = frame + view.needsDisplay = true + view.displayIfNeeded() + pump() + measureGrid() + } + + /// Drains what is queued and then blocks until the deadline, which is both the pump and the + /// frame throttle. Without the second part this would spin a core, since the managed loop + /// calls straight back in. + private func pump() { + let deadline = Date(timeIntervalSinceNow: 1.0 / 60.0) + while let event = NSApp.nextEvent(matching: .any, until: deadline, inMode: .default, dequeue: true) { + NSApp.sendEvent(event) + } + } + + func measureGrid() { + guard let renderer else { + return + } + + // The requested size when there is no view to ask, which is the headless capture case. + let grid = renderer.grid(for: view?.bounds.size ?? size) + input.columns = grid.columns + input.rows = grid.rows + } + + /// Builds the window if this runtime started headless, so a hidden start is still only a + /// deferral rather than a different contract from the other heads. Only reachable from the + /// managed loop's thread, which is the main one. + func show() { + makeWindow() + window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + func hide() { + window?.orderOut(nil) + } + + func shutdown() { + window?.delegate = nil + window?.orderOut(nil) + window?.close() + window = nil + view = nil + renderer = nil + delegate = nil + initialised = false + } + + /// Each event is delivered exactly once, so the poll that read them clears them. The grid is + /// left alone: it is a state, not an event. + func resetInput() { + input.key = DEVIEW_KEY_NONE.value + input.clickedButton = -1 + input.clickedQueueItem = -1 + input.scrollDelta = 0 + input.closeRequested = 0 + } +} diff --git a/native/swift/Sources/Deview/ViewerView.swift b/native/swift/Sources/Deview/ViewerView.swift new file mode 100644 index 00000000..3d284b0f --- /dev/null +++ b/native/swift/Sources/Deview/ViewerView.swift @@ -0,0 +1,118 @@ +import AppKit +import CDeview + +/// The window's content. Drawing goes through the same `Renderer` the capture uses; this only adds +/// input, which it records into `Runtime` for the next `deview_poll_input` to drain. +final class ViewerView: NSView { + private let renderer: Renderer + private var layout = Renderer.Layout() + + var model = Frame() + + init(renderer: Renderer, frame: NSRect) { + self.renderer = renderer + super.init(frame: frame) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("Not loaded from a nib.") + } + + /// Left as false so the view's context matches the offscreen one, which lets both go through + /// the same drawing code. + override var isFlipped: Bool { false } + + override var acceptsFirstResponder: Bool { true } + + override func draw(_ dirtyRect: NSRect) { + guard let context = NSGraphicsContext.current?.cgContext else { + return + } + + layout = renderer.draw(model, in: context, size: bounds.size) + } + + override func mouseDown(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + if let index = layout.buttons.firstIndex(where: { $0.contains(point) }) { + Runtime.shared.input.clickedButton = Int32(index) + return + } + + if let index = layout.queueItems.firstIndex(where: { $0.contains(point) }), + index < model.queue.count { + Runtime.shared.input.clickedQueueItem = Int32(index) + } + } + + /// Accumulated, because a trackpad delivers many small deltas between two polls and the + /// managed side amplifies whatever it is given. + override func scrollWheel(with event: NSEvent) { + let notches = Int32(event.scrollingDeltaY.rounded()) + if notches != 0 { + Runtime.shared.input.scrollDelta += notches + } + } + + override func keyDown(with event: NSEvent) { + let key = ViewerView.map(event) + if key == DEVIEW_KEY_NONE.value { + super.keyDown(with: event) + return + } + + Runtime.shared.input.key = key + } + + /// Matches ReadKey in deview.cpp and the WinForms head's Map, which is the keymap the docs + /// publish. + private static func map(_ event: NSEvent) -> Int32 { + let shift = event.modifierFlags.contains(.shift) + switch Int(event.keyCode) { + case 126: + return DEVIEW_KEY_SCROLL_UP.value + case 125: + return DEVIEW_KEY_SCROLL_DOWN.value + case 116: + return DEVIEW_KEY_PAGE_UP.value + case 121: + return DEVIEW_KEY_PAGE_DOWN.value + case 115: + return DEVIEW_KEY_HOME.value + case 119: + return DEVIEW_KEY_END.value + case 48: + return shift ? DEVIEW_KEY_PREVIOUS_ITEM.value : DEVIEW_KEY_NEXT_ITEM.value + case 53: + return DEVIEW_KEY_QUIT.value + default: + break + } + + switch event.charactersIgnoringModifiers?.lowercased() { + case "n": + return DEVIEW_KEY_NEXT_CHANGE.value + case "p": + return DEVIEW_KEY_PREVIOUS_CHANGE.value + case "a": + return shift ? DEVIEW_KEY_ACCEPT_ALL.value : DEVIEW_KEY_ACCEPT.value + case "d": + return DEVIEW_KEY_DISCARD.value + case "q": + return DEVIEW_KEY_QUIT.value + default: + return DEVIEW_KEY_NONE.value + } + } +} + +/// Closing is the managed side's decision: with a tray to reopen from it hides, without one it +/// exits. So the request is recorded and the close refused, and the answer comes back as either +/// `deview_set_hidden` or `deview_shutdown`. +final class WindowDelegate: NSObject, NSWindowDelegate { + func windowShouldClose(_ sender: NSWindow) -> Bool { + Runtime.shared.input.closeRequested = 1 + return false + } +} diff --git a/native/swift/readme.md b/native/swift/readme.md new file mode 100644 index 00000000..48715709 --- /dev/null +++ b/native/swift/readme.md @@ -0,0 +1,47 @@ +# macOS renderer + +The macOS half of `libdiffengine_viewer`, drawn with AppKit and Core Text. It implements the same +ABI as `native/` does for Linux — `native/include/deview.h`, eight exports over one flat frame +description — so the managed side is identical on both and `DiffEngineViewer.Core` has no idea +which one it loaded. + +``` +swift build -c release --arch arm64 --arch x86_64 +``` + +Both `--arch` flags in one invocation give a universal binary, so there is no `lipo` step. Nothing +of the Swift runtime is shipped: it has been part of macOS since 10.14.4. + +Built binaries are committed to `src/DiffEngineViewer.Mac/runtimes/{rid}/native/`, so a plain +`dotnet build` produces a shippable package and contributors never need Xcode. Regenerate them with +the `build-native` workflow, which opens a PR. + +## Notes + +`Sources/CDeview` exists only to import `deview.h`. Swift does not guarantee struct layout, so the +ABI structs have to come from the C header rather than being redeclared here. The header hides its +prototypes behind `DEVIEW_TYPES_ONLY`, because this library defines those symbols itself with +`@_cdecl`. + +**C# owns the loop.** `deview_present` drains `NSApp.nextEvent` up to a deadline and returns, +rather than handing control to `NSApplication.run`. That is what keeps the scroll amplification, +the button lookup and the close-means-hide rule in `ViewerProgram` for every platform. It also +means the deadline is the frame throttle: without it the managed loop would spin a core. + +**No app bundle.** `setActivationPolicy(.regular)` plus `finishLaunching()` is enough to get a +window that takes focus and appears in the dock, which is the same thing GLFW does for the Linux +build. + +**Nothing is flipped.** Core Graphics has a bottom left origin; layout is written top down and +converted once, which avoids having to fight the text matrix to keep glyphs upright. + +**A hidden start creates no window.** `NSWindow` may only be instantiated on the main thread, and a +test host runs `[Before(Class)]` on whatever thread it likes, so `deview_init(hidden: 1)` builds +only the renderer and defers the window until `deview_set_hidden(0)` asks for one. The app always +starts visible, from `Main`, which is the main thread. + +`deview_capture` draws into a bitmap context of its own making rather than asking the view for one. +`bitmapImageRepForCachingDisplay` would inherit the window's backing scale, so a committed baseline +would only match on the kind of display that produced it. Scale, colour space and the six font +smoothing and subpixel switches are all pinned there instead — which also means capture needs no +window server, so the snapshot tests are sturdier than the Linux ones. diff --git a/src/DiffEngine.Tests/diffTools.include.md b/src/DiffEngine.Tests/diffTools.include.md index bfffe936..8b0bb194 100644 --- a/src/DiffEngine.Tests/diffTools.include.md +++ b/src/DiffEngine.Tests/diffTools.include.md @@ -149,8 +149,8 @@ DiffTools.UseOrder(DiffTool.DiffEngineViewer); #### Notes: * Bundled inside the DiffEngine package, so it needs no install - * Also available standalone via `dotnet tool install -g DiffEngineViewer` - * Cross platform: Windows, macOS and Linux + * Also available standalone as `DiffEngineViewer.Windows`, `.Mac` or `.Linux` + * Cross platform: WinForms on Windows, Dear ImGui through raylib elsewhere #### Windows settings: diff --git a/src/DiffEngine.slnx b/src/DiffEngine.slnx index 03d0ce2e..aadeb161 100644 --- a/src/DiffEngine.slnx +++ b/src/DiffEngine.slnx @@ -23,11 +23,11 @@ - @@ -36,9 +36,34 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/DiffEngine/DiffEngine.csproj b/src/DiffEngine/DiffEngine.csproj index 82fa027c..77f0320f 100644 --- a/src/DiffEngine/DiffEngine.csproj +++ b/src/DiffEngine/DiffEngine.csproj @@ -23,6 +23,32 @@ + + + + + + + - - - - - - + + + + + + - - diff --git a/src/DiffEngine/Implementation/DiffEngineViewer.cs b/src/DiffEngine/Implementation/DiffEngineViewer.cs index 6ffc12e1..59559783 100644 --- a/src/DiffEngine/Implementation/DiffEngineViewer.cs +++ b/src/DiffEngine/Implementation/DiffEngineViewer.cs @@ -33,8 +33,8 @@ public static Definition DiffEngineViewer() CreateNoWindow: true, Notes: """ * Bundled inside the DiffEngine package, so it needs no install - * Also available standalone via `dotnet tool install -g DiffEngineViewer` - * Cross platform: Windows, macOS and Linux + * Also available standalone as `DiffEngineViewer.Windows`, `.Mac` or `.Linux` + * Cross platform: WinForms on Windows, Dear ImGui through raylib elsewhere """); } diff --git a/src/DiffEngineViewer.Linux/DiffEngineViewer.Linux.csproj b/src/DiffEngineViewer.Linux/DiffEngineViewer.Linux.csproj new file mode 100644 index 00000000..6f32fe9f --- /dev/null +++ b/src/DiffEngineViewer.Linux/DiffEngineViewer.Linux.csproj @@ -0,0 +1,53 @@ + + + + Exe + net10.0 + + DiffEngineViewer + DiffEngineViewer + DiffEngineViewer.Linux + true + A diff tool for text files and inline snapshots, for Linux. + true + false + LatestMajor + + false + + + + + + + + + + + + + + + + + diff --git a/src/DiffEngineViewer.Linux/Program.cs b/src/DiffEngineViewer.Linux/Program.cs new file mode 100644 index 00000000..f322f391 --- /dev/null +++ b/src/DiffEngineViewer.Linux/Program.cs @@ -0,0 +1,10 @@ +static class Program +{ + static int Main(string[] args) + { + // Registered before anything can trigger a load. Only the heads that P/Invoke need this, + // so it lives with the renderer choice rather than in ViewerProgram. + NativeResolver.Register(); + return ViewerProgram.Run(args, NativeViewerWindow.Open); + } +} diff --git a/src/DiffEngineViewer/runtimes/linux-arm64/native/libdiffengine_viewer.so b/src/DiffEngineViewer.Linux/runtimes/linux-arm64/native/libdiffengine_viewer.so similarity index 73% rename from src/DiffEngineViewer/runtimes/linux-arm64/native/libdiffengine_viewer.so rename to src/DiffEngineViewer.Linux/runtimes/linux-arm64/native/libdiffengine_viewer.so index 901c0943..e4587cfa 100644 Binary files a/src/DiffEngineViewer/runtimes/linux-arm64/native/libdiffengine_viewer.so and b/src/DiffEngineViewer.Linux/runtimes/linux-arm64/native/libdiffengine_viewer.so differ diff --git a/src/DiffEngineViewer/runtimes/linux-x64/native/libdiffengine_viewer.so b/src/DiffEngineViewer.Linux/runtimes/linux-x64/native/libdiffengine_viewer.so similarity index 62% rename from src/DiffEngineViewer/runtimes/linux-x64/native/libdiffengine_viewer.so rename to src/DiffEngineViewer.Linux/runtimes/linux-x64/native/libdiffengine_viewer.so index 8eb315fb..a1093459 100644 Binary files a/src/DiffEngineViewer/runtimes/linux-x64/native/libdiffengine_viewer.so and b/src/DiffEngineViewer.Linux/runtimes/linux-x64/native/libdiffengine_viewer.so differ diff --git a/src/DiffEngineViewer.Mac/DiffEngineViewer.Mac.csproj b/src/DiffEngineViewer.Mac/DiffEngineViewer.Mac.csproj new file mode 100644 index 00000000..61258a27 --- /dev/null +++ b/src/DiffEngineViewer.Mac/DiffEngineViewer.Mac.csproj @@ -0,0 +1,50 @@ + + + + Exe + net10.0 + + DiffEngineViewer + DiffEngineViewer + DiffEngineViewer.Mac + true + A diff tool for text files and inline snapshots, for macOS. + true + false + LatestMajor + + false + + + + + + + + + + + + + + + + + diff --git a/src/DiffEngineViewer.Mac/Program.cs b/src/DiffEngineViewer.Mac/Program.cs new file mode 100644 index 00000000..f322f391 --- /dev/null +++ b/src/DiffEngineViewer.Mac/Program.cs @@ -0,0 +1,10 @@ +static class Program +{ + static int Main(string[] args) + { + // Registered before anything can trigger a load. Only the heads that P/Invoke need this, + // so it lives with the renderer choice rather than in ViewerProgram. + NativeResolver.Register(); + return ViewerProgram.Run(args, NativeViewerWindow.Open); + } +} diff --git a/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib b/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib new file mode 100644 index 00000000..d5d2fd1e Binary files /dev/null and b/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib differ diff --git a/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib b/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib new file mode 100644 index 00000000..d5d2fd1e Binary files /dev/null and b/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib differ diff --git a/src/DiffEngineViewer.Tests/DiffEngineViewer.Tests.csproj b/src/DiffEngineViewer.Tests/DiffEngineViewer.Tests.csproj index f1e98cda..8a4541fd 100644 --- a/src/DiffEngineViewer.Tests/DiffEngineViewer.Tests.csproj +++ b/src/DiffEngineViewer.Tests/DiffEngineViewer.Tests.csproj @@ -22,4 +22,15 @@ + + + + + diff --git a/src/DiffEngineViewer.Tests/NativeTests.cs b/src/DiffEngineViewer.Tests/NativeTests.cs index b5ded376..15b10f5f 100644 --- a/src/DiffEngineViewer.Tests/NativeTests.cs +++ b/src/DiffEngineViewer.Tests/NativeTests.cs @@ -1,17 +1,26 @@ /// /// Loads the committed native renderer for whatever RID this is running on and checks its ABI. /// -/// Headless and cheap, which is the point: it is the only coverage four of the six RIDs get. The -/// pixel tests only run on Linux, so without this a binary built for the wrong architecture, -/// corrupted by a text mode checkout, or missing a runtime dependency would ship undetected. +/// Headless and cheap, which is the point: it is the only coverage most of the RIDs get. The pixel +/// tests only run on Linux, so without this a binary built for the wrong architecture, corrupted +/// by a text mode checkout, or missing a runtime dependency would ship undetected. +/// +/// +/// Windows is excluded because it has no native renderer to load. That head draws with WinForms, +/// which is covered by DiffEngineViewer.Windows.Tests instead. /// /// public class NativeTests { + static bool HasNativeRenderer => + OperatingSystem.IsLinux() || + OperatingSystem.IsMacOS(); + [Test] public async Task LoadsAndReportsItsAbiVersion() { - if (!NativeResolver.TryFind(out var path)) + if (!HasNativeRenderer || + !NativeResolver.TryFind(out var path)) { // No binary is shipped for this RID, for example linux-musl. Resolution falls through // to a globally installed tool, so there is nothing to check here. @@ -28,9 +37,7 @@ public async Task LoadsAndReportsItsAbiVersion() [Test] public async Task ShipsABinaryForThisPlatform() { - if (!OperatingSystem.IsWindows() && - !OperatingSystem.IsLinux() && - !OperatingSystem.IsMacOS()) + if (!HasNativeRenderer) { return; } diff --git a/src/DiffEngineViewer.Tests/PixelTestAttribute.cs b/src/DiffEngineViewer.Tests/PixelTestAttribute.cs index d4a95d76..886e28db 100644 --- a/src/DiffEngineViewer.Tests/PixelTestAttribute.cs +++ b/src/DiffEngineViewer.Tests/PixelTestAttribute.cs @@ -1,7 +1,11 @@ /// -/// Pixel snapshots need a GL context, so they are opt in. CI runs them on Linux under Xvfb with -/// Mesa llvmpipe, a pure software rasteriser and therefore more reproducible than any GPU driver. -/// Windows and macOS developers are never blocked by a missing context. +/// Pixel snapshots are opt in because their baselines are pinned to the CI images that produced +/// them: Linux under Xvfb with Mesa llvmpipe, and the pinned macos-14 runner. A developer machine +/// renders correctly but will not match them, and on Linux there may be no GL context at all. +/// +/// The WinForms head does not use this. Its baselines reproduce off CI, the same way +/// DiffEngineTray's do. +/// /// public sealed class PixelTestAttribute() : SkipAttribute($"Set {Variable}=true to run pixel snapshots.") { diff --git a/src/DiffEngineViewer.Tests/PixelTests.FileDiff.verified.png b/src/DiffEngineViewer.Tests/PixelTests.FileDiff.Linux.verified.png similarity index 100% rename from src/DiffEngineViewer.Tests/PixelTests.FileDiff.verified.png rename to src/DiffEngineViewer.Tests/PixelTests.FileDiff.Linux.verified.png diff --git a/src/DiffEngineViewer.Tests/PixelTests.FileDiff.OSX.verified.png b/src/DiffEngineViewer.Tests/PixelTests.FileDiff.OSX.verified.png new file mode 100644 index 00000000..a32921a9 Binary files /dev/null and b/src/DiffEngineViewer.Tests/PixelTests.FileDiff.OSX.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.InlineAccepted.verified.png b/src/DiffEngineViewer.Tests/PixelTests.InlineAccepted.Linux.verified.png similarity index 100% rename from src/DiffEngineViewer.Tests/PixelTests.InlineAccepted.verified.png rename to src/DiffEngineViewer.Tests/PixelTests.InlineAccepted.Linux.verified.png diff --git a/src/DiffEngineViewer.Tests/PixelTests.InlineAccepted.OSX.verified.png b/src/DiffEngineViewer.Tests/PixelTests.InlineAccepted.OSX.verified.png new file mode 100644 index 00000000..330c1b23 Binary files /dev/null and b/src/DiffEngineViewer.Tests/PixelTests.InlineAccepted.OSX.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.InlineQueue.verified.png b/src/DiffEngineViewer.Tests/PixelTests.InlineQueue.Linux.verified.png similarity index 100% rename from src/DiffEngineViewer.Tests/PixelTests.InlineQueue.verified.png rename to src/DiffEngineViewer.Tests/PixelTests.InlineQueue.Linux.verified.png diff --git a/src/DiffEngineViewer.Tests/PixelTests.InlineQueue.OSX.verified.png b/src/DiffEngineViewer.Tests/PixelTests.InlineQueue.OSX.verified.png new file mode 100644 index 00000000..d5055ae9 Binary files /dev/null and b/src/DiffEngineViewer.Tests/PixelTests.InlineQueue.OSX.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.InlineSingle.verified.png b/src/DiffEngineViewer.Tests/PixelTests.InlineSingle.Linux.verified.png similarity index 100% rename from src/DiffEngineViewer.Tests/PixelTests.InlineSingle.verified.png rename to src/DiffEngineViewer.Tests/PixelTests.InlineSingle.Linux.verified.png diff --git a/src/DiffEngineViewer.Tests/PixelTests.InlineSingle.OSX.verified.png b/src/DiffEngineViewer.Tests/PixelTests.InlineSingle.OSX.verified.png new file mode 100644 index 00000000..31ed52d0 Binary files /dev/null and b/src/DiffEngineViewer.Tests/PixelTests.InlineSingle.OSX.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.cs b/src/DiffEngineViewer.Tests/PixelTests.cs index e92230d5..82a1f702 100644 --- a/src/DiffEngineViewer.Tests/PixelTests.cs +++ b/src/DiffEngineViewer.Tests/PixelTests.cs @@ -5,11 +5,25 @@ /// rather than opening one per test. /// /// -/// The verified images are the ones produced by the Linux CI job under Xvfb with Mesa llvmpipe. -/// Determinism comes from pinning the rasteriser rather than the platform: llvmpipe is pure -/// software and therefore more reproducible than any GPU driver, and ImGui rasterises glyphs with -/// its own stb_truetype so text is identical everywhere. Opting in on another machine will render -/// correctly but may not match those baselines pixel for pixel. +/// Two sets of baselines, because the two platforms no longer share a renderer: Linux is raylib +/// and ImGui, macOS is AppKit and Core Text. +/// +/// +/// The Linux images come from the CI job under Xvfb with Mesa llvmpipe. Determinism there comes +/// from pinning the rasteriser rather than the platform: llvmpipe is pure software and therefore +/// more reproducible than any GPU driver, and ImGui rasterises glyphs with its own stb_truetype so +/// text is identical everywhere. +/// +/// +/// The macOS images come from the pinned macos-14 runner, and are the weaker guarantee of the two. +/// Core Text is the system text stack, so the capture pins everything it can reach — scale, colour +/// space, and the six font smoothing and subpixel switches — but Apple can still change glyph +/// rasterisation within a runner image. That shows up as one legible diff to re-accept, not as +/// flakiness. +/// +/// +/// Opting in on a developer machine will render correctly but may not match either set pixel for +/// pixel. /// /// [NotInParallel] @@ -19,13 +33,15 @@ public class PixelTests const int height = 700; /// - /// Matches ViewerWindow's cell metrics, so the captured grid is the one ScreenBuilder sized. + /// The grid JetBrains Mono at 15px gives at this window size. Fixed here rather than taken + /// from the shim's own measurement, so the baselines stay pinned to one layout: these are the + /// numbers they were captured at. /// const int columns = width / 9; const int rows = height / 18; - static ViewerWindow? window; + static IViewerWindow? window; [Before(Class)] public static void Open() @@ -35,9 +51,10 @@ public static void Open() return; } - if (!ViewerWindow.TryOpen("DiffEngineViewer", width, height, true, out window, out var error)) + window = NativeViewerWindow.Open("DiffEngineViewer", width, height, true, out var error); + if (window is null) { - throw new(error); + throw new(error!); } } @@ -84,7 +101,10 @@ static async Task Capture(SessionState state) try { await Assert.That(window!.Capture(screen, width, height, path)).IsTrue(); - await VerifyFile(path); + // Linux and macOS run the same tests against different renderers, so the baselines + // have to be told apart. + await VerifyFile(path) + .UniqueForOSPlatform(); } finally { diff --git a/src/DiffEngineViewer.Windows.Tests/DiffEngineViewer.Windows.Tests.csproj b/src/DiffEngineViewer.Windows.Tests/DiffEngineViewer.Windows.Tests.csproj new file mode 100644 index 00000000..ab3e70a8 --- /dev/null +++ b/src/DiffEngineViewer.Windows.Tests/DiffEngineViewer.Windows.Tests.csproj @@ -0,0 +1,29 @@ + + + net10.0-windows + true + Exe + + + + + + + + + + + + + + + + + diff --git a/src/DiffEngineViewer.Windows.Tests/ModuleInitializer.cs b/src/DiffEngineViewer.Windows.Tests/ModuleInitializer.cs new file mode 100644 index 00000000..dbf18bb9 --- /dev/null +++ b/src/DiffEngineViewer.Windows.Tests/ModuleInitializer.cs @@ -0,0 +1,14 @@ +public static class ModuleInitializer +{ + [ModuleInitializer] + public static void Initialize() + { + VerifyWinForms.Initialize(); + VerifierSettings.UseSsimForPng(); + // Program.Main does this for the app, and a test host never runs Main. Without visual + // styles the buttons render as the classic control, which would be a baseline that does + // not describe what a user sees. + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + } +} diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.FileDiff.verified.png b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.FileDiff.verified.png new file mode 100644 index 00000000..e414f89c Binary files /dev/null and b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.FileDiff.verified.png differ diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineAccepted.verified.png b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineAccepted.verified.png new file mode 100644 index 00000000..eee8afd1 Binary files /dev/null and b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineAccepted.verified.png differ diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineQueue.verified.png b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineQueue.verified.png new file mode 100644 index 00000000..fb6da255 Binary files /dev/null and b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineQueue.verified.png differ diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineSingle.verified.png b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineSingle.verified.png new file mode 100644 index 00000000..cf38231b Binary files /dev/null and b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineSingle.verified.png differ diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.cs b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.cs new file mode 100644 index 00000000..4bbdef44 --- /dev/null +++ b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.cs @@ -0,0 +1,77 @@ +/// +/// Renders real frames through the WinForms head and verifies the pixels, over the same fixtures +/// the ASCII snapshots use, so the two describe the same screens. +/// +/// Goes through rather than rendering a control directly, +/// because that is the method the other heads' baselines come from and it is worth exercising. +/// +/// +/// One window for the class, reused, and serial. Creating and destroying a top level window per +/// test is slow and leaves activation racing between them. +/// +/// +[NotInParallel] +public class WindowsPixelTests +{ + const int width = 1100; + const int height = 700; + + static IViewerWindow? window; + + [Before(Class)] + public static void Open() + { + window = FormsViewerWindow.Open("DiffEngineViewer", width, height, hidden: true, out var error); + if (window is null) + { + throw new(error!); + } + } + + [After(Class)] + public static void Close() + { + window?.Dispose(); + window = null; + } + + [Test] + public Task FileDiff() => + Capture(Fixtures.File()); + + [Test] + public Task InlineSingle() => + Capture(Fixtures.Inline(Fixtures.Patch())); + + [Test] + public Task InlineQueue() => + Capture( + Fixtures.Inline( + Fixtures.Patch(), + Fixtures.Patch("SampleTests.cs", 88, "\"one\"", "two"), + Fixtures.Patch("OtherTests.cs", 12, null, "brand new"))); + + [Test] + public Task InlineAccepted() + { + var state = Fixtures.Inline( + Fixtures.Patch(), + Fixtures.Patch("OtherTests.cs", 12, null, "brand new")); + return Capture(ViewerSession.Apply(state, CommandKind.Accept, Fixtures.Applied)); + } + + static async Task Capture(SessionState state) + { + var screen = ScreenBuilder.Build(state); + var path = Path.Combine(Path.GetTempPath(), $"deview-{Guid.NewGuid():N}.png"); + try + { + await Assert.That(window!.Capture(screen, width, height, path)).IsTrue(); + await VerifyFile(path); + } + finally + { + File.Delete(path); + } + } +} diff --git a/src/DiffEngineViewer.Windows/DiffEngineViewer.Windows.csproj b/src/DiffEngineViewer.Windows/DiffEngineViewer.Windows.csproj new file mode 100644 index 00000000..5cc73e52 --- /dev/null +++ b/src/DiffEngineViewer.Windows/DiffEngineViewer.Windows.csproj @@ -0,0 +1,61 @@ + + + + Exe + net10.0 + true + + $(NoWarn);NETSDK1137 + + true + + DiffEngineViewer + DiffEngineViewer + DiffEngineViewer.Windows + true + A diff tool for text files and inline snapshots, for Windows. + true + false + LatestMajor + false + + + + + Windows + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DiffEngineViewer.Windows/FormsViewerWindow.cs b/src/DiffEngineViewer.Windows/FormsViewerWindow.cs new file mode 100644 index 00000000..8671a6b9 --- /dev/null +++ b/src/DiffEngineViewer.Windows/FormsViewerWindow.cs @@ -0,0 +1,152 @@ +/// +/// The WinForms renderer. Consumes directly, so this head marshals nothing +/// and ships no native code. +/// +/// Pumped rather than inverted onto Application.Run. ViewerProgram owns the loop for all +/// three heads, and keeping it that way means the scroll amplification, the button lookup and the +/// close-means-hide rule stay in one place. DoEvents is usually a smell, but the conditions +/// that make it one are absent here: no modal dialogs, no nested message loops, and session state +/// already behind its own lock. +/// +/// +sealed class FormsViewerWindow : IViewerWindow +{ + /// + /// Roughly sixty frames a second, which is what the shim's SetTargetFPS gives the other heads. + /// Without it this loop would spin a core, since DoEvents returns immediately when idle. + /// + const int frameMilliseconds = 16; + + readonly ViewerForm form; + bool disposed; + + FormsViewerWindow(ViewerForm form) => + this.form = form; + + public static IViewerWindow? Open(string title, int width, int height, bool hidden, out string? error) + { + error = null; + try + { + var form = new ViewerForm(title, width, height); + // Forces the handle, so a hidden window can still measure text and be captured. + form.CreateControl(); + _ = form.Handle; + if (!hidden) + { + form.Show(); + } + + return new FormsViewerWindow(form); + } + catch (Exception exception) + { + // No desktop session, or a station that cannot host a window. Same shape as a missing + // native renderer: a message rather than a stack trace. + error = $"Could not open a window. {exception.Message}"; + return null; + } + } + + public bool Present(Screen screen) + { + if (disposed || form.IsDisposed) + { + return false; + } + + form.Apply(screen); + Application.DoEvents(); + if (form.IsDisposed) + { + return false; + } + + Thread.Sleep(frameMilliseconds); + return true; + } + + public ViewerInput Poll() => + form.IsDisposed ? default : form.Drain(); + + public void SetHidden(bool hidden) + { + if (form.IsDisposed) + { + return; + } + + form.Visible = !hidden; + form.ShowInTaskbar = !hidden; + } + + public void Focus() + { + if (form.IsDisposed) + { + return; + } + + form.Visible = true; + form.ShowInTaskbar = true; + form.BringToFront(); + form.Activate(); + } + + public bool Capture(Screen screen, int width, int height, string pngPath) + { + if (form.IsDisposed) + { + return false; + } + + // DrawToBitmap sends a paint message, and a window that has never been shown does not + // answer one: the result is a correctly sized image of nothing. Shown off to the side + // rather than at the default position, so a capture run does not steal focus mid screen. + var wasVisible = form.Visible; + if (!wasVisible) + { + form.StartPosition = FormStartPosition.Manual; + form.Location = new(-2000, -2000); + form.ShowInTaskbar = false; + form.Show(); + } + + try + { + form.ClientSize = new(width, height); + form.Apply(screen); + form.PerformLayout(); + // Invalidate only marks dirty; the paint has to have happened before the bitmap. + form.Surface.Refresh(); + + using var bitmap = new Bitmap(width, height); + form.Surface.DrawToBitmap(bitmap, new(0, 0, width, height)); + bitmap.Save(pngPath, ImageFormat.Png); + } + finally + { + if (!wasVisible) + { + form.Visible = false; + } + } + + return true; + } + + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + if (!form.IsDisposed) + { + form.CloseForReal(); + form.Dispose(); + } + } +} diff --git a/src/DiffEngineViewer.Windows/InternalsVisibleTo.cs b/src/DiffEngineViewer.Windows/InternalsVisibleTo.cs new file mode 100644 index 00000000..798ae406 --- /dev/null +++ b/src/DiffEngineViewer.Windows/InternalsVisibleTo.cs @@ -0,0 +1 @@ +[assembly: InternalsVisibleTo("DiffEngineViewer.Windows.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] diff --git a/src/DiffEngineViewer.Windows/MonoFont.cs b/src/DiffEngineViewer.Windows/MonoFont.cs new file mode 100644 index 00000000..736d9692 --- /dev/null +++ b/src/DiffEngineViewer.Windows/MonoFont.cs @@ -0,0 +1,47 @@ +/// +/// Registers the embedded JetBrains Mono with GDI+ and measures one character cell from it. +/// +/// The collection and the pinned bytes are process wide and never released: GDI+ reads the memory +/// behind an AddMemoryFont registration for as long as any font from it is alive, and every +/// font here lives until the process ends. +/// +/// +static class MonoFont +{ + const float pointSize = 11f; + + static readonly PrivateFontCollection collection = new(); + static readonly FontFamily family = Register(); + + public static Font Create() => + new(family, pointSize, FontStyle.Regular, GraphicsUnit.Point); + + /// + /// The advance width and line height of one cell, which is what the app's character grid is + /// counted in. Measured rather than assumed, so a scaled display reports a grid that actually + /// fits, which is the bug the hardcoded 9 by 18 in the native head still has. + /// + public static Size Cell(Graphics graphics, Font font) + { + var width = graphics.MeasureString("M", font, PointF.Empty, Painter.Format).Width; + return new( + Math.Max(1, (int) Math.Round(width)), + Math.Max(1, (int) Math.Ceiling(font.GetHeight(graphics)))); + } + + static FontFamily Register() + { + var bytes = EmbeddedFont.Bytes(); + if (bytes.Length == 0) + { + // Nothing embedded, so take whatever monospaced face the machine has. + return new(GenericFontFamilies.Monospace); + } + + // Pinned for the process lifetime rather than copied to unmanaged memory and freed: GDI+ + // keeps reading this buffer, so freeing it is what produces the classic garbled glyphs. + var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned); + collection.AddMemoryFont(handle.AddrOfPinnedObject(), bytes.Length); + return collection.Families[0]; + } +} diff --git a/src/DiffEngineViewer.Windows/Painter.cs b/src/DiffEngineViewer.Windows/Painter.cs new file mode 100644 index 00000000..ac2674ec --- /dev/null +++ b/src/DiffEngineViewer.Windows/Painter.cs @@ -0,0 +1,60 @@ +/// +/// One text drawing setup for the whole head. +/// +/// GDI+ DrawString rather than TextRenderer's GDI. GDI honours whatever ClearType +/// setting the machine has, which would make a committed pixel baseline reproducible only on the +/// machine that produced it. GDI+ can be pinned to grayscale antialiasing, so it cannot. +/// +/// +static class Painter +{ + /// + /// Typographic rather than the default, whose extra side bearings would stop cell widths + /// lining up with the measured advance. No NoClip, so a long line is clipped to its + /// column instead of bleeding into the next one. + /// + public static readonly StringFormat Format = BuildFormat(); + + static readonly Dictionary brushes = []; + + static StringFormat BuildFormat() + { + var format = (StringFormat) StringFormat.GenericTypographic.Clone(); + format.FormatFlags |= StringFormatFlags.NoWrap; + format.Trimming = StringTrimming.None; + return format; + } + + public static void Prepare(Graphics graphics) + { + graphics.TextRenderingHint = TextRenderingHint.AntiAliasGridFit; + graphics.SmoothingMode = SmoothingMode.None; + graphics.PixelOffsetMode = PixelOffsetMode.Half; + } + + /// + /// Cached because a frame draws one string per cell, and a brush per cell per frame is the + /// kind of allocation that turns an idle window into a busy one. + /// + public static SolidBrush Brush(Color colour) + { + if (brushes.TryGetValue(colour, out var brush)) + { + return brush; + } + + brush = new(colour); + brushes.Add(colour, brush); + return brush; + } + + public static void Draw(Graphics graphics, string text, Font font, Color colour, RectangleF bounds) + { + if (text.Length == 0) + { + return; + } + + graphics.DrawString(text, font, Brush(colour), bounds, Format); + } +} diff --git a/src/DiffEngineViewer.Windows/Palette.cs b/src/DiffEngineViewer.Windows/Palette.cs new file mode 100644 index 00000000..9ac84aa5 --- /dev/null +++ b/src/DiffEngineViewer.Windows/Palette.cs @@ -0,0 +1,55 @@ +/// +/// Transcribed from RowColour and RowBackground in the native shim, so a change looks +/// the same whichever renderer drew it. The model deliberately carries a +/// and never a colour, so this mapping is each renderer's own. +/// +static class Palette +{ + public static readonly Color Background = Color.FromArgb(24, 24, 24); + public static readonly Color Filler = Color.FromArgb(28, 28, 28); + public static readonly Color Text = Color.FromArgb(212, 212, 212); + + /// + /// The gutter, and the subtitle and status that ImGui draws with TextDisabled. + /// + public static readonly Color Dim = Color.FromArgb(130, 130, 130); + + public static readonly Color Rule = Color.FromArgb(70, 70, 70); + + /// + /// ImGui draws a selected Selectable as its accent at 31% over the window background. This is + /// that composite, so the queue highlight matches without carrying an alpha channel around. + /// + public static readonly Color Selected = Color.FromArgb(38, 64, 90); + + public static Color Foreground(RowKind kind) => + kind switch + { + RowKind.Added => Color.FromArgb(126, 214, 139), + RowKind.Removed => Color.FromArgb(233, 129, 129), + RowKind.Modified => Color.FromArgb(231, 197, 113), + _ => Text + }; + + /// + /// Null where the row takes the window background, which is every unchanged row. + /// + public static Color? RowBackground(RowKind kind) => + kind switch + { + RowKind.Added => Color.FromArgb(38, 74, 44), + RowKind.Removed => Color.FromArgb(84, 40, 40), + RowKind.Modified => Color.FromArgb(74, 64, 32), + RowKind.Filler => Filler, + _ => null + }; + + public static char Marker(RowKind kind) => + kind switch + { + RowKind.Added => '+', + RowKind.Removed => '-', + RowKind.Modified => '~', + _ => ' ' + }; +} diff --git a/src/DiffEngineViewer.Windows/Program.cs b/src/DiffEngineViewer.Windows/Program.cs new file mode 100644 index 00000000..1bede478 --- /dev/null +++ b/src/DiffEngineViewer.Windows/Program.cs @@ -0,0 +1,17 @@ +static class Program +{ + /// + /// STA because WinForms requires it, and the whole app runs on this thread: ViewerProgram owns + /// the loop and the socket listener marshals window changes back through a queue it drains. + /// + [STAThread] + static int Main(string[] args) + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + // The reason this head exists rather than a sixth copy of the shim: the native renderer + // has no DPI handling at all and converts pixels to cells by dividing by a constant. + Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); + return ViewerProgram.Run(args, FormsViewerWindow.Open); + } +} diff --git a/src/DiffEngineViewer.Windows/ViewerCanvas.cs b/src/DiffEngineViewer.Windows/ViewerCanvas.cs new file mode 100644 index 00000000..977b8f59 --- /dev/null +++ b/src/DiffEngineViewer.Windows/ViewerCanvas.cs @@ -0,0 +1,249 @@ +/// +/// Draws everything except the footer, which is real controls so the buttons keep native focus, +/// keyboard access and theming. +/// +/// One owner drawn surface rather than a control per pane, because +/// has already sliced each pane to the rows that fit. A scrolling control would want to own that +/// decision, and then the text snapshots would stop describing what this shows. +/// +/// +sealed class ViewerCanvas : Control +{ + const int queueWidth = 220; + + /// + /// Marker, space, four digit line number, two spaces. Matches AsciiRenderer's gutter, so a + /// line lands in the same column in both. + /// + const int gutterCells = 8; + + const int padding = 6; + const int gap = 4; + + readonly Font font = MonoFont.Create(); + Screen? screen; + Size cell; + + public ViewerCanvas() + { + SetStyle( + ControlStyles.AllPaintingInWmPaint | + ControlStyles.UserPaint | + ControlStyles.OptimizedDoubleBuffer | + ControlStyles.ResizeRedraw, + true); + BackColor = Palette.Background; + } + + public event Action? QueueItemClicked; + + /// Notches, positive for up, matching what the shim reports. + public event Action? Scrolled; + + /// + /// How many body rows fit. Reported back as part of the grid size so ScreenBuilder slices to + /// exactly what is drawable, rather than to a guess from a hardcoded cell height. + /// + public int BodyCapacity => + Math.Max(1, (Height - BodyTop - padding) / Cell.Height); + + public int ColumnCapacity => + Math.Max(40, Width / Cell.Width); + + public void Draw(Screen value) + { + screen = value; + Invalidate(); + } + + Size Cell + { + get + { + if (cell.IsEmpty) + { + using var graphics = CreateGraphics(); + cell = MonoFont.Cell(graphics, font); + } + + return cell; + } + } + + int BodyTop => + padding + (Cell.Height + gap) * 2 + gap * 2; + + protected override void OnPaint(PaintEventArgs e) + { + var graphics = e.Graphics; + graphics.Clear(Palette.Background); + if (screen is null) + { + return; + } + + Painter.Prepare(graphics); + var lineHeight = Cell.Height; + var hasQueue = screen.Queue.Count > 0; + var panesLeft = hasQueue ? padding + queueWidth + gap : padding; + var panesWidth = Math.Max(2 * Cell.Width, Width - padding - panesLeft); + var half = panesWidth / 2; + + DrawTitle(graphics, lineHeight); + + var firstRule = padding + lineHeight + gap; + DrawRule(graphics, firstRule); + + var headerTop = firstRule + gap; + if (hasQueue) + { + Painter.Draw(graphics, $"Pending ({screen.Queue.Count})", font, Palette.Text, Cellular(padding, headerTop, queueWidth, lineHeight)); + } + + Painter.Draw(graphics, screen.Left.Header, font, Palette.Text, Cellular(panesLeft, headerTop, half, lineHeight)); + Painter.Draw(graphics, screen.Right.Header, font, Palette.Text, Cellular(panesLeft + half, headerTop, half, lineHeight)); + DrawRule(graphics, headerTop + lineHeight + gap); + + var bodyTop = BodyTop; + var capacity = BodyCapacity; + var rows = Math.Min(capacity, Math.Max(screen.Queue.Count, Math.Max(screen.Left.Rows.Count, screen.Right.Rows.Count))); + for (var index = 0; index < rows; index++) + { + var top = bodyTop + index * lineHeight; + if (hasQueue) + { + DrawQueueItem(graphics, index, new(padding, top, queueWidth, lineHeight)); + } + + DrawRow(graphics, screen.Left, index, new(panesLeft, top, half, lineHeight)); + DrawRow(graphics, screen.Right, index, new(panesLeft + half, top, panesWidth - half, lineHeight)); + } + + var bodyBottom = bodyTop + capacity * lineHeight; + if (hasQueue) + { + DrawColumnRule(graphics, panesLeft - gap / 2, bodyTop, bodyBottom); + } + + DrawColumnRule(graphics, panesLeft + half - gap / 2, bodyTop, bodyBottom); + } + + void DrawTitle(Graphics graphics, int lineHeight) + { + Painter.Draw(graphics, screen!.Title, font, Palette.Text, Cellular(padding, padding, Width - padding * 2, lineHeight)); + if (screen.Subtitle.Length == 0) + { + return; + } + + var width = screen.Subtitle.Length * Cell.Width; + Painter.Draw(graphics, screen.Subtitle, font, Palette.Dim, Cellular(Width - padding - width, padding, width, lineHeight)); + } + + void DrawQueueItem(Graphics graphics, int index, Rectangle bounds) + { + if (index >= screen!.Queue.Count) + { + return; + } + + var item = screen.Queue[index]; + if (item.Selected) + { + graphics.FillRectangle(Painter.Brush(Palette.Selected), bounds); + } + + var failed = item.Status is not null; + Painter.Draw( + graphics, + failed ? $"{item.Label} !" : item.Label, + font, + failed ? Palette.Foreground(RowKind.Removed) : Palette.Text, + Cellular(bounds.X + Cell.Width, bounds.Y, bounds.Width - Cell.Width, bounds.Height)); + } + + void DrawRow(Graphics graphics, Pane pane, int index, Rectangle bounds) + { + if (index >= pane.Rows.Count) + { + return; + } + + var row = pane.Rows[index]; + if (Palette.RowBackground(row.Kind) is { } background) + { + graphics.FillRectangle(Painter.Brush(background), bounds); + } + + if (row.Kind == RowKind.Filler) + { + return; + } + + var gutter = gutterCells * Cell.Width; + Painter.Draw( + graphics, + $"{Palette.Marker(row.Kind)} {row.LineNumber,4}", + font, + Palette.Dim, + Cellular(bounds.X, bounds.Y, gutter, bounds.Height)); + Painter.Draw( + graphics, + RowText.Flatten(row.Text), + font, + Palette.Foreground(row.Kind), + Cellular(bounds.X + gutter, bounds.Y, bounds.Width - gutter, bounds.Height)); + } + + void DrawRule(Graphics graphics, int top) => + graphics.FillRectangle(Painter.Brush(Palette.Rule), padding, top, Width - padding * 2, 1); + + static void DrawColumnRule(Graphics graphics, int left, int top, int bottom) => + graphics.FillRectangle(Painter.Brush(Palette.Rule), left, top, 1, bottom - top); + + /// + /// GDI+ measures and clips in floats, and the layout is all integers, so the conversion lives + /// in one place rather than at every call. + /// + static RectangleF Cellular(int left, int top, int width, int height) => + new(left, top, Math.Max(0, width), height); + + protected override void OnMouseDown(MouseEventArgs e) + { + base.OnMouseDown(e); + if (screen is null || + screen.Queue.Count == 0 || + e.X < padding || + e.X >= padding + queueWidth) + { + return; + } + + var index = (e.Y - BodyTop) / Cell.Height; + if (index >= 0 && + index < screen.Queue.Count) + { + QueueItemClicked?.Invoke(index); + } + } + + protected override void OnMouseWheel(MouseEventArgs e) + { + base.OnMouseWheel(e); + var notches = e.Delta / SystemInformation.MouseWheelScrollDelta; + if (notches != 0) + { + Scrolled?.Invoke(notches); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + font.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/DiffEngineViewer.Windows/ViewerForm.cs b/src/DiffEngineViewer.Windows/ViewerForm.cs new file mode 100644 index 00000000..7ba6e4a1 --- /dev/null +++ b/src/DiffEngineViewer.Windows/ViewerForm.cs @@ -0,0 +1,220 @@ +/// +/// The window. Accumulates what the user did so can drain it, +/// which keeps the loop in ViewerProgram identical to the one the native heads run. +/// +sealed class ViewerForm : Form +{ + readonly ViewerCanvas canvas = new() + { + Dock = DockStyle.Fill + }; + + readonly FlowLayoutPanel buttonRow = new() + { + Dock = DockStyle.Left, + AutoSize = true, + WrapContents = false, + Margin = Padding.Empty + }; + + readonly Label status = new() + { + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.MiddleRight, + ForeColor = Palette.Dim, + AutoSize = false + }; + + readonly List pool = []; + + /// + /// The client area as one control, so it can be rendered to a bitmap without the window frame. + /// + public Panel Surface { get; } = new() + { + Dock = DockStyle.Fill, + BackColor = Palette.Background + }; + + Screen? last; + CommandKind key; + int clickedButton = -1; + int clickedQueueItem = -1; + int scrollDelta; + bool closeRequested; + bool closingForReal; + + public ViewerForm(string title, int width, int height) + { + Text = title; + BackColor = Palette.Background; + ForeColor = Palette.Text; + ClientSize = new(width, height); + StartPosition = FormStartPosition.CenterScreen; + KeyPreview = true; + + var footer = new Panel + { + Dock = DockStyle.Bottom, + Height = 40, + Padding = new(6, 4, 6, 6), + BackColor = Palette.Background + }; + footer.Controls.Add(status); + footer.Controls.Add(buttonRow); + + // Everything lives in one filling panel so a capture can take the client area alone. Going + // through the form would include the title bar, which is themed by the OS and would make a + // committed baseline a picture of the machine that produced it. + Surface.Controls.Add(canvas); + Surface.Controls.Add(footer); + Controls.Add(Surface); + + canvas.QueueItemClicked += _ => clickedQueueItem = _; + canvas.Scrolled += _ => scrollDelta += _; + } + + public void Apply(Screen screen) + { + // ScreenBuilder allocates a fresh Screen every frame, so record equality would never hit. + // Without this the window repaints sixty times a second while sitting idle. + if (Same(last, screen)) + { + return; + } + + last = screen; + status.Text = screen.Status; + ApplyButtons(screen); + canvas.Draw(screen); + } + + void ApplyButtons(Screen screen) + { + while (pool.Count < screen.Buttons.Count) + { + var index = pool.Count; + var button = new FormsButton + { + AutoSize = true, + Margin = new(0, 0, 6, 0), + FlatStyle = FlatStyle.System + }; + button.Click += (_, _) => clickedButton = index; + pool.Add(button); + buttonRow.Controls.Add(button); + } + + for (var index = 0; index < pool.Count; index++) + { + var button = pool[index]; + if (index >= screen.Buttons.Count) + { + button.Visible = false; + continue; + } + + var model = screen.Buttons[index]; + button.Text = model.Label; + button.Enabled = model.Enabled; + button.Visible = true; + } + } + + public ViewerInput Drain() + { + var input = new ViewerInput( + Key: key, + ClickedButton: clickedButton, + ClickedQueueItem: clickedQueueItem, + ScrollDelta: scrollDelta, + CloseRequested: closeRequested, + Columns: canvas.ColumnCapacity, + // ScreenBuilder subtracts Chrome to get the body, so adding it back asks for exactly + // the rows the canvas can draw rather than a guess from a fixed cell height. + Rows: canvas.BodyCapacity + ScreenBuilder.Chrome); + + key = CommandKind.None; + clickedButton = -1; + clickedQueueItem = -1; + scrollDelta = 0; + closeRequested = false; + return input; + } + + public void CloseForReal() + { + closingForReal = true; + Close(); + } + + protected override void OnFormClosing(FormClosingEventArgs e) + { + // Always cancelled, because whether closing means hide or exit is ViewerProgram's rule and + // it needs a tray check to decide. CloseForReal is how the answer comes back. + if (!closingForReal) + { + closeRequested = true; + e.Cancel = true; + } + + base.OnFormClosing(e); + } + + /// + /// ProcessCmdKey rather than OnKeyDown, because Tab and Escape are consumed by focus + /// navigation and the default button before a key handler would ever see them. + /// + protected override bool ProcessCmdKey(ref Message message, Keys keyData) + { + var command = Map(keyData); + if (command == CommandKind.None) + { + return base.ProcessCmdKey(ref message, keyData); + } + + key = command; + return true; + } + + static CommandKind Map(Keys keyData) + { + var shift = (keyData & Keys.Shift) == Keys.Shift; + return (keyData & Keys.KeyCode) switch + { + Keys.Up => CommandKind.ScrollUp, + Keys.Down => CommandKind.ScrollDown, + Keys.PageUp => CommandKind.PageUp, + Keys.PageDown => CommandKind.PageDown, + Keys.Home => CommandKind.ScrollHome, + Keys.End => CommandKind.ScrollEnd, + Keys.N => CommandKind.NextChange, + Keys.P => CommandKind.PreviousChange, + Keys.Tab => shift ? CommandKind.PreviousItem : CommandKind.NextItem, + Keys.A => shift ? CommandKind.AcceptAll : CommandKind.Accept, + Keys.D => CommandKind.Discard, + Keys.Q or Keys.Escape => CommandKind.Quit, + _ => CommandKind.None + }; + } + + /// + /// Records all the way down, so this is structural apart from the lists, which compare by + /// reference and are rebuilt every frame. + /// + static bool Same(Screen? left, Screen right) => + left is not null && + left.Title == right.Title && + left.Subtitle == right.Subtitle && + left.Status == right.Status && + left.Queue.SequenceEqual(right.Queue) && + left.Buttons.SequenceEqual(right.Buttons) && + Same(left.Left, right.Left) && + Same(left.Right, right.Right); + + static bool Same(Pane left, Pane right) => + left.Header == right.Header && + left.ScrollTop == right.ScrollTop && + left.TotalRows == right.TotalRows && + left.Rows.SequenceEqual(right.Rows); +} diff --git a/src/DiffEngineViewer/AsciiRenderer.cs b/src/DiffEngineViewer/AsciiRenderer.cs index 14448be5..438afdf8 100644 --- a/src/DiffEngineViewer/AsciiRenderer.cs +++ b/src/DiffEngineViewer/AsciiRenderer.cs @@ -167,7 +167,7 @@ static string Justify(string left, string right, int width) static string Fit(string text, int width) { // Tabs and stray newlines would break the grid, so flatten them before measuring. - var flat = Flatten(text); + var flat = RowText.Flatten(text); if (flat.Length == width) { return flat; @@ -180,17 +180,4 @@ static string Fit(string text, int width) return $"{flat.AsSpan(0, width - 1)}>"; } - - static string Flatten(string text) - { - if (text.AsSpan().IndexOfAny('\t', '\r', '\n') < 0) - { - return text; - } - - return text - .Replace("\t", " ") - .Replace("\r", "") - .Replace("\n", " "); - } } diff --git a/src/DiffEngineViewer/DiffEngineViewer.csproj b/src/DiffEngineViewer/DiffEngineViewer.csproj index 10b5f1b2..58f53a03 100644 --- a/src/DiffEngineViewer/DiffEngineViewer.csproj +++ b/src/DiffEngineViewer/DiffEngineViewer.csproj @@ -1,29 +1,22 @@ - Exe net10.0 true - true - A cross platform diff tool for text files and inline snapshots. - true false - LatestMajor - false + DiffEngineViewer.Core + false @@ -37,27 +30,5 @@ - - - - - - - - - - diff --git a/src/DiffEngineViewer/EmbeddedFont.cs b/src/DiffEngineViewer/EmbeddedFont.cs new file mode 100644 index 00000000..790464b6 --- /dev/null +++ b/src/DiffEngineViewer/EmbeddedFont.cs @@ -0,0 +1,27 @@ +/// +/// JetBrains Mono, carried in this assembly rather than looked up on the machine. Every renderer +/// wants the same glyphs: the shim uploads these bytes to ImGui, and the WinForms head registers +/// them with GDI+. Shipping the font is also what keeps the pixel baselines independent of what +/// happens to be installed on the runner. +/// +static class EmbeddedFont +{ + const string name = "DiffEngineViewer.JetBrainsMono-Regular.ttf"; + + /// + /// Empty when the resource is missing, which each renderer treats as "fall back to a built in + /// font" rather than as a failure. + /// + public static byte[] Bytes() + { + using var stream = typeof(EmbeddedFont).Assembly.GetManifestResourceStream(name); + if (stream is null) + { + return []; + } + + using var memory = new MemoryStream(); + stream.CopyTo(memory); + return memory.ToArray(); + } +} diff --git a/src/DiffEngineViewer/IViewerWindow.cs b/src/DiffEngineViewer/IViewerWindow.cs new file mode 100644 index 00000000..08970f6e --- /dev/null +++ b/src/DiffEngineViewer/IViewerWindow.cs @@ -0,0 +1,38 @@ +/// +/// One platform's renderer, and the only thing in the app that knows how pixels get drawn. +/// Everything above it is a pure function of , which is what lets +/// and its text snapshots stand in for every implementation. +/// +/// The frame contract is deliberately coarse: one carrying a whole screen +/// and one per iteration, rather than a stream of draw calls. That is what +/// makes a retained mode toolkit and an immediate mode renderer equally implementable behind it. +/// +/// +interface IViewerWindow : IDisposable +{ + /// + /// Draws one frame. False once the window has closed, which is how the loop learns to stop. + /// + bool Present(Screen screen); + + /// + /// Everything the user did since the last call. Drains as it reads, so each event arrives once. + /// + ViewerInput Poll(); + + void SetHidden(bool hidden); + + void Focus(); + + /// + /// Renders one frame offscreen to a PNG. Only the pixel snapshots use this. + /// + bool Capture(Screen screen, int width, int height, string pngPath); +} + +/// +/// Opens the window for one platform. Null with an rather than an +/// exception, because a machine with no renderer for its RID and no desktop session to draw into +/// are both ordinary, and both want the same message rather than a stack trace. +/// +delegate IViewerWindow? OpenWindow(string title, int width, int height, bool hidden, out string? error); diff --git a/src/DiffEngineViewer/InternalsVisibleTo.cs b/src/DiffEngineViewer/InternalsVisibleTo.cs index 0541964b..42b2c4a2 100644 --- a/src/DiffEngineViewer/InternalsVisibleTo.cs +++ b/src/DiffEngineViewer/InternalsVisibleTo.cs @@ -1 +1,7 @@ [assembly: InternalsVisibleTo("DiffEngineViewer.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] +[assembly: InternalsVisibleTo("DiffEngineViewer.Windows.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] + +// One entry covers all three platform heads, because all three take the DiffEngineViewer assembly +// name. They need it to reach ViewerProgram and their own IViewerWindow, which stay internal +// rather than being made public for the sake of a project boundary inside one application. +[assembly: InternalsVisibleTo("DiffEngineViewer, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] diff --git a/src/DiffEngineViewer/Model/RowText.cs b/src/DiffEngineViewer/Model/RowText.cs new file mode 100644 index 00000000..91a677cf --- /dev/null +++ b/src/DiffEngineViewer/Model/RowText.cs @@ -0,0 +1,20 @@ +/// +/// Row text as a renderer wants it. A tab or a stray newline would break a character grid, and +/// every renderer has to resolve them the same way or the text snapshots stop describing what the +/// pixel ones show. +/// +static class RowText +{ + public static string Flatten(string text) + { + if (text.AsSpan().IndexOfAny('\t', '\r', '\n') < 0) + { + return text; + } + + return text + .Replace("\t", " ") + .Replace("\r", "") + .Replace("\n", " "); + } +} diff --git a/src/DiffEngineViewer/Native/Deview.cs b/src/DiffEngineViewer/Native/Deview.cs index f6d2dfb9..4be64670 100644 --- a/src/DiffEngineViewer/Native/Deview.cs +++ b/src/DiffEngineViewer/Native/Deview.cs @@ -10,7 +10,7 @@ static unsafe partial class Deview /// Must match DEVIEW_VERSION in native/include/deview.h. Bumped whenever the structs change, /// so a stale native library is reported rather than read as garbage. /// - public const int ExpectedVersion = 1; + public const int ExpectedVersion = 2; [LibraryImport(library, EntryPoint = "deview_version")] public static partial int Version(); diff --git a/src/DiffEngineViewer/Native/ViewerWindow.cs b/src/DiffEngineViewer/Native/NativeViewerWindow.cs similarity index 64% rename from src/DiffEngineViewer/Native/ViewerWindow.cs rename to src/DiffEngineViewer/Native/NativeViewerWindow.cs index 9416debf..6aaf0049 100644 --- a/src/DiffEngineViewer/Native/ViewerWindow.cs +++ b/src/DiffEngineViewer/Native/NativeViewerWindow.cs @@ -1,33 +1,18 @@ /// -/// Owns the native window. The only type in the app that touches the shim, so everything else -/// stays testable on a machine with no GPU. +/// The backed by the native shim. The only type in the app that +/// touches it, so everything else stays testable on a machine with no GPU. /// -sealed class ViewerWindow : IDisposable +sealed class NativeViewerWindow : IViewerWindow { - /// - /// Pixels per character cell, used to translate the window size into the character grid the - /// rest of the app reasons in. Measured for JetBrains Mono at 15px. - /// - const int cellWidth = 9; - - const int cellHeight = 18; - readonly ScreenPayload payload = new(); bool disposed; - ViewerWindow() + NativeViewerWindow() { } - public static bool TryOpen( - string title, - int width, - int height, - bool hidden, - [NotNullWhen(true)] out ViewerWindow? window, - [NotNullWhen(false)] out string? error) + public static IViewerWindow? Open(string title, int width, int height, bool hidden, out string? error) { - window = null; error = null; int version; try @@ -37,32 +22,30 @@ public static bool TryOpen( catch (DllNotFoundException exception) { error = $"Could not load the native renderer for this platform. {exception.Message}"; - return false; + return null; } catch (EntryPointNotFoundException exception) { error = $"The native renderer is missing an entry point. {exception.Message}"; - return false; + return null; } if (version != Deview.ExpectedVersion) { error = $"Native renderer version {version} does not match the expected {Deview.ExpectedVersion}."; - return false; + return null; } - var font = Font(); - if (!Open(title, width, height, hidden, font)) + if (!Init(title, width, height, hidden, EmbeddedFont.Bytes())) { error = "The native renderer could not open a window."; - return false; + return null; } - window = new(); - return true; + return new NativeViewerWindow(); } - static unsafe bool Open(string title, int width, int height, bool hidden, byte[] font) + static unsafe bool Init(string title, int width, int height, bool hidden, byte[] font) { fixed (byte* bytes = font) { @@ -70,21 +53,6 @@ static unsafe bool Open(string title, int width, int height, bool hidden, byte[] } } - static byte[] Font() - { - var assembly = Assembly.GetExecutingAssembly(); - using var stream = assembly.GetManifestResourceStream("DiffEngineViewer.JetBrainsMono-Regular.ttf"); - if (stream is null) - { - // The shim falls back to ImGui's built in font for an empty buffer. - return []; - } - - using var memory = new MemoryStream(); - stream.CopyTo(memory); - return memory.ToArray(); - } - public bool Present(Screen screen) { payload.Build(screen); @@ -97,7 +65,11 @@ public bool Capture(Screen screen, int width, int height, string pngPath) return payload.Capture(width, height, pngPath) == 1; } - public static unsafe ViewerInput Poll() + /// + /// The shim owns one process wide window, so these forward to statics. Exposed as instance + /// members anyway, because that is the shape a per window toolkit needs. + /// + public unsafe ViewerInput Poll() { DeviewInput input; Deview.PollInput(&input); @@ -107,14 +79,16 @@ public static unsafe ViewerInput Poll() ClickedQueueItem: input.ClickedQueueItem, ScrollDelta: input.ScrollDelta, CloseRequested: input.CloseRequested != 0, - Columns: Math.Max(40, input.Columns / cellWidth), - Rows: Math.Max(10, input.Rows / cellHeight)); + // Already cells: the shim measures them from the font it loaded. Only the floors are + // applied here, because they are the app's rule rather than the renderer's. + Columns: Math.Max(40, input.Columns), + Rows: Math.Max(10, input.Rows)); } - public static void SetHidden(bool hidden) => + public void SetHidden(bool hidden) => Deview.SetHidden(hidden ? 1 : 0); - public static void Focus() => + public void Focus() => Deview.Focus(); /// diff --git a/src/DiffEngineViewer/Native/ViewerInput.cs b/src/DiffEngineViewer/ViewerInput.cs similarity index 100% rename from src/DiffEngineViewer/Native/ViewerInput.cs rename to src/DiffEngineViewer/ViewerInput.cs diff --git a/src/DiffEngineViewer/Program.cs b/src/DiffEngineViewer/ViewerProgram.cs similarity index 83% rename from src/DiffEngineViewer/Program.cs rename to src/DiffEngineViewer/ViewerProgram.cs index 9df2fb0c..3f654737 100644 --- a/src/DiffEngineViewer/Program.cs +++ b/src/DiffEngineViewer/ViewerProgram.cs @@ -1,8 +1,12 @@ -static class Program +/// +/// Everything the app does apart from choosing a renderer. Each platform head is a +/// Main that supplies its own and calls in here, so the queue +/// semantics, the wire protocol and the loop are shared rather than reimplemented per platform. +/// +static class ViewerProgram { - static int Main(string[] args) + public static int Run(string[] args, OpenWindow open) { - NativeResolver.Register(); var request = CommandLine.Parse(args); if (request.Error is not null) { @@ -14,10 +18,10 @@ static int Main(string[] args) { if (request.Mode == ViewerMode.Inline) { - return RunInline(); + return RunInline(open); } - return RunFile(request); + return RunFile(request, open); } catch (Exception exception) { @@ -26,7 +30,7 @@ static int Main(string[] args) } } - static int RunInline() + static int RunInline(OpenWindow open) { // Drained before anything slow. OS pipe buffers are around 64 KB, so a parent writing a // larger payload blocks on the write until this side reads it, and that parent is a test @@ -66,11 +70,11 @@ static int RunInline() var start = ViewerSession.Enqueue( SessionState.Start(ViewerMode.Inline), QueueEntry.ForInline(patch)); - return Run(new(start), server); + return Run(new(start), server, open); } } - static int RunFile(ViewerRequest request) + static int RunFile(ViewerRequest request, OpenWindow open) { var left = request.Left!; var right = request.Right!; @@ -82,7 +86,7 @@ static int RunFile(ViewerRequest request) var entry = QueueEntry.ForFiles(left, right, Read(left), Read(right)); var start = ViewerSession.Enqueue(SessionState.Start(ViewerMode.File), entry); - return Run(new(start), null); + return Run(new(start), null, open); } // A missing target is normal: DiffEngine creates an empty one for tools that need it, and a @@ -90,9 +94,10 @@ static int RunFile(ViewerRequest request) static string Read(string path) => File.Exists(path) ? File.ReadAllText(path) : ""; - static int Run(SessionHost host, ViewerServer? server) + static int Run(SessionHost host, ViewerServer? server, OpenWindow open) { - if (!ViewerWindow.TryOpen("DiffEngineViewer", 1100, 700, false, out var window, out var error)) + var window = open("DiffEngineViewer", 1100, 700, false, out var error); + if (window is null) { Console.Error.WriteLine(error); return 4; @@ -125,24 +130,24 @@ static int Run(SessionHost host, ViewerServer? server) static void Loop( SessionHost host, - ViewerWindow window, + IViewerWindow window, ViewerActions actions, ConcurrentQueue windowCommands) { while (true) { - // GLFW is single threaded, so socket driven window changes are applied here rather - // than on the listener's thread. + // Every renderer here is single threaded, so socket driven window changes are applied + // on this thread rather than on the listener's. while (windowCommands.TryDequeue(out var command)) { var hidden = command == WindowCommand.Hide; if (command == WindowCommand.Focus) { - ViewerWindow.Focus(); + window.Focus(); continue; } - ViewerWindow.SetHidden(hidden); + window.SetHidden(hidden); } var state = host.State; @@ -156,7 +161,7 @@ static void Loop( return; } - var input = ViewerWindow.Poll(); + var input = window.Poll(); host.Mutate(_ => Apply(_, input, actions)); if (!input.CloseRequested) @@ -169,7 +174,7 @@ static void Loop( if (TrayDetector.IsRunning() && host.State.Queue.Count > 0) { - ViewerWindow.SetHidden(true); + window.SetHidden(true); continue; } diff --git a/src/DiffEngineViewer/runtimes/osx-arm64/native/libdiffengine_viewer.dylib b/src/DiffEngineViewer/runtimes/osx-arm64/native/libdiffengine_viewer.dylib deleted file mode 100644 index b43ea9ec..00000000 Binary files a/src/DiffEngineViewer/runtimes/osx-arm64/native/libdiffengine_viewer.dylib and /dev/null differ diff --git a/src/DiffEngineViewer/runtimes/osx-x64/native/libdiffengine_viewer.dylib b/src/DiffEngineViewer/runtimes/osx-x64/native/libdiffengine_viewer.dylib deleted file mode 100644 index b43ea9ec..00000000 Binary files a/src/DiffEngineViewer/runtimes/osx-x64/native/libdiffengine_viewer.dylib and /dev/null differ diff --git a/src/DiffEngineViewer/runtimes/win-arm64/native/diffengine_viewer.dll b/src/DiffEngineViewer/runtimes/win-arm64/native/diffengine_viewer.dll deleted file mode 100644 index 44430acd..00000000 Binary files a/src/DiffEngineViewer/runtimes/win-arm64/native/diffengine_viewer.dll and /dev/null differ diff --git a/src/DiffEngineViewer/runtimes/win-x64/native/diffengine_viewer.dll b/src/DiffEngineViewer/runtimes/win-x64/native/diffengine_viewer.dll deleted file mode 100644 index 6358d5a4..00000000 Binary files a/src/DiffEngineViewer/runtimes/win-x64/native/diffengine_viewer.dll and /dev/null differ diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 85c52b9d..3b933363 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -23,4 +23,10 @@ + + + + + + diff --git a/src/Packaging.Tests/Package.DiffEngine.verified.txt b/src/Packaging.Tests/Package.DiffEngine.verified.txt new file mode 100644 index 00000000..9925d0d6 --- /dev/null +++ b/src/Packaging.Tests/Package.DiffEngine.verified.txt @@ -0,0 +1,63 @@ +DiffEngine.nuspec +[Content_Types].xml +_rels/.rels +buildTransitive/DiffEngine.targets +icon.png +lib/net10.0/DiffEngine.dll +lib/net10.0/DiffEngine.xml +lib/net462/DiffEngine.dll +lib/net462/DiffEngine.xml +lib/net472/DiffEngine.dll +lib/net472/DiffEngine.xml +lib/net48/DiffEngine.dll +lib/net48/DiffEngine.xml +lib/net6.0/DiffEngine.dll +lib/net6.0/DiffEngine.xml +lib/net7.0/DiffEngine.dll +lib/net7.0/DiffEngine.xml +lib/net8.0/DiffEngine.dll +lib/net8.0/DiffEngine.xml +lib/net9.0/DiffEngine.dll +lib/net9.0/DiffEngine.xml +nuget.md +package/services/metadata/core-properties/{guid}.psmdcp +tools/viewer/linux-arm64/DiffEngineViewer +tools/viewer/linux-arm64/DiffEngineViewer.Core.dll +tools/viewer/linux-arm64/DiffEngineViewer.deps.json +tools/viewer/linux-arm64/DiffEngineViewer.dll +tools/viewer/linux-arm64/DiffEngineViewer.runtimeconfig.json +tools/viewer/linux-arm64/DiffPlex.dll +tools/viewer/linux-arm64/runtimes/linux-arm64/native/libdiffengine_viewer.so +tools/viewer/linux-x64/DiffEngineViewer +tools/viewer/linux-x64/DiffEngineViewer.Core.dll +tools/viewer/linux-x64/DiffEngineViewer.deps.json +tools/viewer/linux-x64/DiffEngineViewer.dll +tools/viewer/linux-x64/DiffEngineViewer.runtimeconfig.json +tools/viewer/linux-x64/DiffPlex.dll +tools/viewer/linux-x64/runtimes/linux-x64/native/libdiffengine_viewer.so +tools/viewer/osx-arm64/DiffEngineViewer +tools/viewer/osx-arm64/DiffEngineViewer.Core.dll +tools/viewer/osx-arm64/DiffEngineViewer.deps.json +tools/viewer/osx-arm64/DiffEngineViewer.dll +tools/viewer/osx-arm64/DiffEngineViewer.runtimeconfig.json +tools/viewer/osx-arm64/DiffPlex.dll +tools/viewer/osx-arm64/runtimes/osx-arm64/native/libdiffengine_viewer.dylib +tools/viewer/osx-x64/DiffEngineViewer +tools/viewer/osx-x64/DiffEngineViewer.Core.dll +tools/viewer/osx-x64/DiffEngineViewer.deps.json +tools/viewer/osx-x64/DiffEngineViewer.dll +tools/viewer/osx-x64/DiffEngineViewer.runtimeconfig.json +tools/viewer/osx-x64/DiffPlex.dll +tools/viewer/osx-x64/runtimes/osx-x64/native/libdiffengine_viewer.dylib +tools/viewer/win-arm64/DiffEngineViewer.Core.dll +tools/viewer/win-arm64/DiffEngineViewer.deps.json +tools/viewer/win-arm64/DiffEngineViewer.dll +tools/viewer/win-arm64/DiffEngineViewer.exe +tools/viewer/win-arm64/DiffEngineViewer.runtimeconfig.json +tools/viewer/win-arm64/DiffPlex.dll +tools/viewer/win-x64/DiffEngineViewer.Core.dll +tools/viewer/win-x64/DiffEngineViewer.deps.json +tools/viewer/win-x64/DiffEngineViewer.dll +tools/viewer/win-x64/DiffEngineViewer.exe +tools/viewer/win-x64/DiffEngineViewer.runtimeconfig.json +tools/viewer/win-x64/DiffPlex.dll \ No newline at end of file diff --git a/src/Packaging.Tests/Package.DiffEngineTray.verified.txt b/src/Packaging.Tests/Package.DiffEngineTray.verified.txt new file mode 100644 index 00000000..0364b9f4 --- /dev/null +++ b/src/Packaging.Tests/Package.DiffEngineTray.verified.txt @@ -0,0 +1,15 @@ +DiffEngineTray.nuspec +[Content_Types].xml +_rels/.rels +icon.png +nuget.md +package/services/metadata/core-properties/{guid}.psmdcp +tools/net10.0/any/DiffEngine.dll +tools/net10.0/any/DiffEngine.xml +tools/net10.0/any/DiffEngineTray.deps.json +tools/net10.0/any/DiffEngineTray.dll +tools/net10.0/any/DiffEngineTray.runtimeconfig.json +tools/net10.0/any/DotnetToolSettings.xml +tools/net10.0/any/EmptyFiles.dll +tools/net10.0/any/Serilog.Sinks.File.dll +tools/net10.0/any/Serilog.dll \ No newline at end of file diff --git a/src/Packaging.Tests/Package.DiffEngineViewer.Linux.verified.txt b/src/Packaging.Tests/Package.DiffEngineViewer.Linux.verified.txt new file mode 100644 index 00000000..fba41b8c --- /dev/null +++ b/src/Packaging.Tests/Package.DiffEngineViewer.Linux.verified.txt @@ -0,0 +1,15 @@ +DiffEngineViewer.Linux.nuspec +JetBrainsMono-OFL.txt +[Content_Types].xml +_rels/.rels +icon.png +nuget.md +package/services/metadata/core-properties/{guid}.psmdcp +tools/net10.0/any/DiffEngineViewer.Core.dll +tools/net10.0/any/DiffEngineViewer.deps.json +tools/net10.0/any/DiffEngineViewer.dll +tools/net10.0/any/DiffEngineViewer.runtimeconfig.json +tools/net10.0/any/DiffPlex.dll +tools/net10.0/any/DotnetToolSettings.xml +tools/net10.0/any/runtimes/linux-arm64/native/libdiffengine_viewer.so +tools/net10.0/any/runtimes/linux-x64/native/libdiffengine_viewer.so \ No newline at end of file diff --git a/src/Packaging.Tests/Package.DiffEngineViewer.Mac.verified.txt b/src/Packaging.Tests/Package.DiffEngineViewer.Mac.verified.txt new file mode 100644 index 00000000..47a95228 --- /dev/null +++ b/src/Packaging.Tests/Package.DiffEngineViewer.Mac.verified.txt @@ -0,0 +1,15 @@ +DiffEngineViewer.Mac.nuspec +JetBrainsMono-OFL.txt +[Content_Types].xml +_rels/.rels +icon.png +nuget.md +package/services/metadata/core-properties/{guid}.psmdcp +tools/net10.0/any/DiffEngineViewer.Core.dll +tools/net10.0/any/DiffEngineViewer.deps.json +tools/net10.0/any/DiffEngineViewer.dll +tools/net10.0/any/DiffEngineViewer.runtimeconfig.json +tools/net10.0/any/DiffPlex.dll +tools/net10.0/any/DotnetToolSettings.xml +tools/net10.0/any/runtimes/osx-arm64/native/libdiffengine_viewer.dylib +tools/net10.0/any/runtimes/osx-x64/native/libdiffengine_viewer.dylib \ No newline at end of file diff --git a/src/Packaging.Tests/Package.DiffEngineViewer.Windows.verified.txt b/src/Packaging.Tests/Package.DiffEngineViewer.Windows.verified.txt new file mode 100644 index 00000000..cf3fb703 --- /dev/null +++ b/src/Packaging.Tests/Package.DiffEngineViewer.Windows.verified.txt @@ -0,0 +1,13 @@ +DiffEngineViewer.Windows.nuspec +JetBrainsMono-OFL.txt +[Content_Types].xml +_rels/.rels +icon.png +nuget.md +package/services/metadata/core-properties/{guid}.psmdcp +tools/net10.0/any/DiffEngineViewer.Core.dll +tools/net10.0/any/DiffEngineViewer.deps.json +tools/net10.0/any/DiffEngineViewer.dll +tools/net10.0/any/DiffEngineViewer.runtimeconfig.json +tools/net10.0/any/DiffPlex.dll +tools/net10.0/any/DotnetToolSettings.xml \ No newline at end of file diff --git a/src/Packaging.Tests/PackageTestAttribute.cs b/src/Packaging.Tests/PackageTestAttribute.cs new file mode 100644 index 00000000..bd88b120 --- /dev/null +++ b/src/Packaging.Tests/PackageTestAttribute.cs @@ -0,0 +1,9 @@ +/// +/// Package assertions read the .nupkg files a Release build drops in nugets. A Debug +/// build packs nothing, so they skip rather than failing on an ordinary inner loop. +/// +public sealed class PackageTestAttribute() : SkipAttribute("Only a Release build produces packages.") +{ + public override Task ShouldSkip(TestRegisteredContext context) => + Task.FromResult(Packages.Produced().Count == 0); +} diff --git a/src/Packaging.Tests/PackageTests.Produced.verified.txt b/src/Packaging.Tests/PackageTests.Produced.verified.txt new file mode 100644 index 00000000..6072bc90 --- /dev/null +++ b/src/Packaging.Tests/PackageTests.Produced.verified.txt @@ -0,0 +1,5 @@ +DiffEngine.20.0.0-beta.6.nupkg +DiffEngineTray.20.0.0-beta.6.nupkg +DiffEngineViewer.Linux.20.0.0-beta.6.nupkg +DiffEngineViewer.Mac.20.0.0-beta.6.nupkg +DiffEngineViewer.Windows.20.0.0-beta.6.nupkg \ No newline at end of file diff --git a/src/Packaging.Tests/PackageTests.cs b/src/Packaging.Tests/PackageTests.cs new file mode 100644 index 00000000..5285ce49 --- /dev/null +++ b/src/Packaging.Tests/PackageTests.cs @@ -0,0 +1,165 @@ +/// +/// Snapshots of what actually ships, so an accidental addition or removal shows up as a reviewable +/// diff rather than as a surprise on nuget.org. Package content is assembled by MSBuild from +/// several unrelated mechanisms, and nothing else in the build asserts the result. +/// +/// The failure mode these were written for is stale build output. PackAsTool packages the +/// publish directory wholesale, and MSBuild's incremental copy never removes a file that stopped +/// being produced, so anything a discarded experiment once left in bin keeps shipping. CI +/// builds from a fresh checkout and never sees it; a maintainer packing locally does. +/// +/// +/// Windows only, by way of the solution file: Release-NotWindows drops DiffEngineTray, so +/// its package would be absent and these baselines would not describe a full release. That is also +/// why publish-nuget.yml runs on windows-latest. +/// +/// +/// One caveat: nugets is never cleaned, so a Release build followed by unrelated Debug work +/// leaves these asserting against the last packages that were actually produced. +/// +/// +public class PackageTests +{ + const string bundled = "tools/viewer/"; + const string runtimeConfig = ".runtimeconfig.json"; + + /// + /// Guards the set itself. Without this, a package that stopped being produced would take its + /// content assertions with it and everything would still pass. + /// + [Test] + [PackageTest] + public Task Produced() => + Verify(string.Join('\n', Packages.Produced())); + + [Test] + [PackageTest] + [Arguments("DiffEngine")] + [Arguments("DiffEngineTray")] + [Arguments("DiffEngineViewer.Windows")] + [Arguments("DiffEngineViewer.Mac")] + [Arguments("DiffEngineViewer.Linux")] + public async Task Contents(string id) + { + using var archive = Packages.Open(id); + await Verify(string.Join('\n', Packages.Entries(archive))) + .UseFileName($"Package.{id}"); + } + + /// + /// A runtime config with no assembly beside it is an apphost that cannot start. Cheap to check + /// and it names the problem, where the content snapshot only records it. + /// + [Test] + [PackageTest] + [Arguments("DiffEngine")] + [Arguments("DiffEngineTray")] + [Arguments("DiffEngineViewer.Windows")] + [Arguments("DiffEngineViewer.Mac")] + [Arguments("DiffEngineViewer.Linux")] + public async Task EveryApphostHasItsAssembly(string id) + { + using var archive = Packages.Open(id); + var paths = Packages.Entries(archive).ToHashSet(StringComparer.Ordinal); + var orphaned = paths + .Where(_ => _.EndsWith(runtimeConfig, StringComparison.Ordinal)) + .Select(_ => $"{_[..^runtimeConfig.Length]}.dll") + .Where(_ => !paths.Contains(_)) + .Order(StringComparer.Ordinal) + .ToList(); + + await Assert.That(orphaned).IsEmpty(); + } + + /// + /// The SBOM is dropped from the content snapshots, since it only exists on CI, so its absence + /// there would otherwise go unnoticed. This is the half of that which can still be checked. + /// + [Test] + [PackageTest] + public async Task TheSbomIsGeneratedOnCi() + { + if (!Packages.OnCi()) + { + return; + } + + using var archive = Packages.Open("DiffEngine"); + await Assert.That(Packages.HasSbom(archive)).IsTrue(); + } + + /// + /// The tray resolves a viewer through DiffTools like any other tool rather than shipping + /// one, so nothing named after it belongs in its package. + /// + [Test] + [PackageTest] + public async Task TheTrayShipsNoViewer() + { + using var archive = Packages.Open("DiffEngineTray"); + var viewerFiles = Packages.Entries(archive) + .Where(_ => _.Contains("DiffEngineViewer", StringComparison.Ordinal) || + _.Contains("diffengine_viewer", StringComparison.Ordinal)) + .ToList(); + + await Assert.That(viewerFiles).IsEmpty(); + } + + /// + /// A bundled head is only worth carrying if it can start, which takes the apphost, the managed + /// assembly, both config files, DiffPlex and the one native renderer for that RID. + /// + [Test] + [PackageTest] + public async Task EveryBundledViewerIsComplete() + { + using var archive = Packages.Open("DiffEngine"); + var rids = Packages.Entries(archive) + .Where(_ => _.StartsWith(bundled, StringComparison.Ordinal)) + .GroupBy(_ => _[bundled.Length..].Split('/')[0]) + .ToList(); + + // Otherwise a package that bundled nothing at all would pass vacuously. + await Assert.That(rids).IsNotEmpty(); + + var problems = new List(); + foreach (var rid in rids) + { + var names = rid + .Select(_ => _[(bundled.Length + rid.Key.Length + 1)..]) + .ToList(); + + foreach (var required in (string[]) + [ + "DiffEngineViewer.dll", + "DiffEngineViewer.Core.dll", + "DiffEngineViewer.deps.json", + $"DiffEngineViewer{runtimeConfig}", + "DiffPlex.dll" + ]) + { + if (!names.Contains(required)) + { + problems.Add($"{rid.Key} has no {required}"); + } + } + + // Extensionless off Windows, where NuGet would otherwise read the apphost as a folder. + if (!names.Any(_ => _ is "DiffEngineViewer" or "DiffEngineViewer.exe")) + { + problems.Add($"{rid.Key} has no apphost"); + } + + // Windows renders with WinForms, so a native renderer there is a leftover rather than + // a payload. Everywhere else exactly one, for this RID and no other. + var expected = rid.Key.StartsWith("win-", StringComparison.Ordinal) ? 0 : 1; + var natives = names.Count(_ => _.StartsWith("runtimes/", StringComparison.Ordinal)); + if (natives != expected) + { + problems.Add($"{rid.Key} has {natives} native renderers, expected {expected}"); + } + } + + await Assert.That(problems).IsEmpty(); + } +} diff --git a/src/Packaging.Tests/Packages.cs b/src/Packaging.Tests/Packages.cs new file mode 100644 index 00000000..2fddf296 --- /dev/null +++ b/src/Packaging.Tests/Packages.cs @@ -0,0 +1,117 @@ +/// +/// Locates the packages a Release build drops in nugets, and reads their entry lists with +/// everything that moves between builds normalised away. +/// +static class Packages +{ + static string Repository { get; } = FindRepository(); + + /// + /// The version this build produced, so the assertions ignore the older packages that + /// accumulate in nugets. + /// + /// Read out of the props file rather than off this assembly, because ProjectDefaults sets + /// GenerateAssemblyInfo=false for every project here, so there is no + /// AssemblyInformationalVersionAttribute to read. + /// + /// + public static string Version { get; } = ReadVersion(); + + public static string NugetsDirectory { get; } = Path.Combine(Repository, "nugets"); + + /// + /// The package file names this build produced. Empty for a Debug build, which packs nothing. + /// + public static IReadOnlyList Produced() + { + if (!Directory.Exists(NugetsDirectory)) + { + return []; + } + + return new DirectoryInfo(NugetsDirectory) + .GetFiles($"*.{Version}.nupkg") + .Select(_ => _.Name) + .Order(StringComparer.Ordinal) + .ToList(); + } + + public static ZipArchive Open(string id) + { + var path = Path.Combine(NugetsDirectory, $"{id}.{Version}.nupkg"); + if (!File.Exists(path)) + { + throw new($"{id}.{Version}.nupkg is missing from {NugetsDirectory}."); + } + + return ZipFile.OpenRead(path); + } + + public static IReadOnlyList Entries(ZipArchive archive) => + archive.Entries + .Select(_ => _.FullName) + .Where(_ => !_.StartsWith(manifest, StringComparison.Ordinal)) + .Select(Normalize) + .Order(StringComparer.Ordinal) + .ToList(); + + const string coreProperties = "package/services/metadata/core-properties/"; + + /// + /// The SBOM, which only exists on CI: DiffEngine references Microsoft.Sbom.Targets under a + /// condition on the CI variable. Left out of the snapshots so one baseline describes both a + /// local pack and a release, and asserted separately where it is actually produced. + /// + public const string manifest = "_manifest/"; + + public static bool HasSbom(ZipArchive archive) => + archive.Entries.Any(_ => _.FullName.StartsWith(manifest, StringComparison.Ordinal)); + + /// + /// Whether this build was the one that generates an SBOM, matching the condition in + /// DiffEngine.csproj rather than guessing at a CI provider. + /// + public static bool OnCi() => + Environment.GetEnvironmentVariable("CI") == "true"; + + static string Normalize(string path) + { + // NuGet stamps a fresh guid into the core properties part name on every pack. + if (path.StartsWith(coreProperties, StringComparison.Ordinal)) + { + return $"{coreProperties}{{guid}}.psmdcp"; + } + + return path.Replace(Version, "{version}", StringComparison.Ordinal); + } + + static string ReadVersion() + { + var path = Path.Combine(Repository, "src", "Directory.Build.props"); + var version = XDocument.Load(path) + .Descendants("Version") + .FirstOrDefault(); + if (version is null) + { + throw new($"No Version element in {path}."); + } + + return version.Value; + } + + static string FindRepository() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "src", "DiffEngine.slnx"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new($"Could not find the repository root above {AppContext.BaseDirectory}."); + } +} diff --git a/src/Packaging.Tests/Packaging.Tests.csproj b/src/Packaging.Tests/Packaging.Tests.csproj new file mode 100644 index 00000000..56a0636b --- /dev/null +++ b/src/Packaging.Tests/Packaging.Tests.csproj @@ -0,0 +1,20 @@ + + + net10.0 + Exe + + + + + + + + + + + +