Skip to content

Commit bc8b34e

Browse files
authored
Per os renderers (#733)
* Snapshot the contents of every package Package content is assembled by MSBuild from several unrelated mechanisms and nothing in the build asserted the result, so a stray or missing file only surfaced once it was on nuget.org. The baselines record today's output, which includes a defect worth naming: the tray package carries DiffEngineViewer.exe, its runtimeconfig and all six native renderers, but no DiffEngineViewer.dll. That apphost cannot start, and the natives are 13 MB of dead weight in a Windows only package. Fixed next, so the removal reads as a diff. Windows only via the solution file. Release-NotWindows drops DiffEngineTray, so a run there would be describing an incomplete release. * Stop the viewer leaking into the tray package A solution level BuildDependency is treated as a reference, not just as ordering. DiffEngine declared one on DiffEngineViewer to make sure the viewer was built before PublishViewerHeads shelled out to dotnet publish, and the viewer is an Exe carrying six native renderers as content, so all of it was copied into DiffEngine's output for each of its eight target frameworks and then on into DiffEngineTray through the ordinary project reference. DiffEngine's own package was unaffected, because lib/{tfm} only takes the primary assembly. DiffEngineTray's was not: PackAsTool packages the whole publish directory, so it shipped an apphost with no assembly beside it and 13 MB of renderers for platforms it does not run on. 6,421,352 bytes to 363,032. It takes a solution build to reproduce, which is what publish-nuget.yml runs, so any DiffEngineTray package published since the viewer landed carries it. The apphost was never startable, since the assembly it needs was not copied either, so the effect was dead weight rather than a broken command. Ordering is now a project reference in DiffEngine.csproj, where ReferenceOutputAssembly, Private and ExcludeAssets can say that nothing is wanted from it, and where it can be limited to the one target framework the viewer has. The three invariants added here are the ones a content snapshot states poorly: an apphost with no assembly, a viewer file in the tray, and an incomplete bundled head. * Put an interface where the renderer is ViewerWindow was already the only type touching the native shim and already had close to the right shape, so this mostly promotes it: TryOpen becomes an OpenWindow delegate returning null with a message, Poll, SetHidden and Focus become instance members, and the class becomes NativeViewerWindow. Program splits in two. ViewerProgram.Run takes the renderer to use, so the queue semantics, the wire protocol and the loop are shared. Program.Main keeps only the choice of renderer and the NativeResolver registration that goes with it, which is the shape each platform head will take. Loop itself is untouched, which is the point: the scroll amplification, the button index lookup and the tray-running close-means-hide rule stay in one place rather than being reimplemented per platform. ViewerInput moves out of Native, having never had anything native about it. * Split the viewer into a core and one head per OS A portable RID agnostic tool package and a per platform GUI framework cannot coexist: WinForms needs Microsoft.WindowsDesktop.App named in runtimeconfig.json, which fails to start on macOS and Linux. The single package only ever worked because the platform variation was native files that NativeResolver picks at load time. So DiffEngineViewer becomes a library, and DiffEngineViewer.Windows, .Mac and .Linux are the packages. All three still build a DiffEngineViewer apphost, so tools/viewer keeps its shape and BundledViewerDirectory, ViewerLauncher and the tool definition are all untouched. The core takes the .Core assembly name to leave the plain one for the heads, which is the name the launcher looks up. The committed renderers move to the head that loads them, which is also the statement that the Windows head is about to stop having any. Every head is still the native renderer here. Swapping the Windows one for WinForms is the next commit, so that diff is only the renderer. A user now downloads 1.0, 2.3 or 3.5 MB rather than 6.2, and DiffEngine.nupkg is unchanged apart from carrying DiffEngineViewer.Core.dll per RID. * Render with WinForms on Windows The Screen model was already renderer agnostic, so this head consumes it directly: no marshalling layer, no flat blittable frame description, and no committed binary. The two Windows renderers, 1.9 MB, are deleted. One owner drawn surface rather than a control per pane, because ScreenBuilder has already sliced each pane to the rows that fit. A scrolling control would want to own that decision, and the text snapshots would stop describing what this draws. The footer buttons are real controls, so they keep native focus, keyboard access and theming. Pumped through DoEvents rather than inverted onto Application.Run. ViewerProgram owns the loop for all three heads, and keeping it there is what keeps the scroll amplification, the button lookup and the close-means-hide rule in one place. The usual objections to DoEvents do not apply: no modal dialogs, no nested message loops, and session state already behind its own lock. Two things this fixes rather than ports. The window reports the character grid it measured from the font instead of dividing pixels by a hardcoded 9 by 18, which is what makes it right on a scaled display. And closing is FormClosing with Cancel, rather than declaring a GLFW symbol by hand because raylib latches its close flag and offers no way to clear it. Text goes through GDI+ with grayscale antialiasing rather than GDI, which honours whatever ClearType setting the machine has. That is what lets the four baselines here be committed at all. DiffEngineViewer.Windows is 275 KB against the 6.2 MB the one portable package used to be, and DiffEngine.nupkg drops from 9.38 MB to 8.63 MB. * Report the character grid in cells, not pixels DeviewInput.columns and rows carried pixels, and NativeViewerWindow divided them by a hardcoded 9 by 18 measured once for JetBrains Mono at 15px. That is the whole of the viewer's DPI handling, and it is wrong on any scaled display: the grid ScreenBuilder slices to is either larger than the window or leaves it half empty. The shim is the only side that knows what font it loaded, so it measures and reports cells. The managed side keeps only the floors, which are the app's rule rather than the renderer's. DEVIEW_VERSION goes to 2, since the struct is the same shape but a field means something else, which is exactly the case a version check has to catch. The WinForms head already did this properly by measuring GDI+ metrics, so this brings the other two into line rather than introducing the idea. The committed Linux and macOS binaries are still version 1, so the native smoke job in test.yml fails until build-native regenerates them. The unix job does not: it builds the Linux shim from source, which is also why the four pixel baselines are unaffected. * Point CI and the docs at the per OS heads build-native drops its two Windows matrix entries, and lays its artifacts out as src/{head}/runtimes/{rid}/native so the propose job can merge them straight into the head that loads them. The native smoke job loses win-arm64 for the same reason. publish-nuget checks four RIDs rather than six; its push step already enumerates whatever is in nugets, so the three viewer packages need nothing. Docs now say the renderer is per platform and name the three packages, with the reason a portable one is not possible. * Render with AppKit and Core Text on macOS Implements the same eight exports as native/, so the managed side is untouched: Deview, DeviewStructs, ScreenPayload, NativeResolver and NativeViewerWindow all stay exactly as they are and neither knows which library it loaded. The header is imported rather than redeclared, because Swift does not guarantee struct layout and two copies of an ABI is the bug DEVIEW_VERSION exists to catch. deview.h grows a DEVIEW_TYPES_ONLY guard so this side can take the structs without also taking prototypes for symbols it is about to define. C# keeps the loop. deview_present drains NSApp.nextEvent up to a deadline and returns, rather than handing control to NSApplication.run, which is what keeps the scroll amplification, the button lookup and the close-means-hide rule in ViewerProgram for every platform. The deadline doubles as the frame throttle. No app bundle: setActivationPolicy plus finishLaunching is enough for a window that takes focus, the same thing GLFW does for the Linux build. Nothing is flipped either; layout is written top down and converted once, rather than fighting the text matrix to keep glyphs upright. 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 pinned there instead, which also means capture needs no window server. Linux and macOS now render differently, so PixelTests gains UniqueForOSPlatform and the existing baselines become .Linux. The macOS set does not exist yet: it has to be produced by the pinned macos-14 runner and accepted from the received artifacts, the same way the Linux one was. Not compiled. There is no macOS here to build or run it on, so the first real feedback is CI. * Collect a universal dylib rather than the first one found swift build --arch arm64 --arch x86_64 compiles both slices but leaves a per architecture dylib in more than one place, so taking the first match gave an arm64 only binary. It shipped to both osx RIDs, and the lipo check in the native smoke job caught it, which is what that check is for. Every candidate is now checked, and if none is already universal they are merged, so the only thing collectable is a fat binary. The arch assertion moves into this step too: finding out at collect time beats finding out a job later. * Leave the SBOM out of the package snapshots DiffEngine references Microsoft.Sbom.Targets under a condition on the CI variable, so a release package carries two _manifest entries that a local pack does not. The baselines were taken locally, so the Windows job failed on the difference rather than on anything being wrong. Dropped from the entry lists so one baseline describes both. Absence would then go unnoticed, so the half that can still be checked is: on CI, the SBOM is there. * Skip the dSYM when looking for the dylib The debug bundle holds a DWARF file of the same name, and it is universal too, so the arch check would have accepted it and shipped symbols as the library. Ordering happened to favour the real product this time. * Rebuild native renderer binaries * Do not create a window when macOS starts hidden The pixel snapshots crashed with "NSWindow should only be instantiated on the main thread". TUnit runs Before(Class) on a worker, and deview_init built a window there. The app was never at risk: ViewerProgram.Run is called from Main, which is the main thread. A hidden start now builds only the renderer, which is all capture needs, since it draws into a bitmap context of its own making. The window is deferred to deview_set_hidden, so the ABI contract is unchanged rather than diverging from the Linux head, and the header says so. Test passed on macOS through all of this because NativeTests only calls deview_version, which touches no AppKit. * Rebuild native renderer binaries * Accept the macOS pixel baselines Produced by the pinned macos-14 runner, which is the only place they can come from: Core Text is the system text stack, so a developer machine renders correctly but not identically. Checked against the WinForms and Linux renders of the same four screens rather than accepted on the strength of the tests going green. Between them they cover the three column inline layout and the two column file one, added and modified rows, filler, the queue selection, a disabled button and a status message. * Declare the usings that a rebuild loses ProjectDefaults supplies System.Collections, System.Diagnostics, System.Reflection and System.Collections.Concurrent through a preprocessed content file, which NuGet materialises into obj and records in FileWrites. A rebuild deletes it and nothing puts it back in the same invocation, so the compile runs without those four namespaces and every use of Process, DebuggerDisplay, ConcurrentDictionary or the non generic IDictionary fails. Reproducible on main with dotnet build src --no-incremental, and the reason nobody noticed is that CI always builds a fresh clone and an incremental build never removes the file. Rider rebuilding is what found it. Declaring them here puts them in the generated GlobalUsings instead, which the SDK owns and a clean does not touch. Overlapping with the content file is harmless: a duplicate global using is not even a warning, and warnings are errors here. --------- Co-authored-by: SimonCropp <122666+SimonCropp@users.noreply.github.com>
1 parent 6bf2081 commit bc8b34e

85 files changed

Lines changed: 3027 additions & 234 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/build-native.yml

Lines changed: 72 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
name: Build native
22

33
# Produces the diffengine_viewer binaries committed under
4-
# src/DiffEngineViewer/runtimes/{rid}/native.
4+
# src/DiffEngineViewer.{Linux,Mac}/runtimes/{rid}/native, in the head that loads them. There is no
5+
# Windows equivalent: that head renders with WinForms.
56
#
67
# They are committed rather than built during a normal build so that a plain
78
# `dotnet build src --configuration Release` produces a shippable package on any machine, and
@@ -34,12 +35,7 @@ jobs:
3435
fail-fast: false
3536
matrix:
3637
include:
37-
- rid: win-x64
38-
os: windows-latest
39-
generator: -A x64
40-
- rid: win-arm64
41-
os: windows-latest
42-
generator: -A ARM64
38+
# No Windows entries. That head renders with WinForms and loads no native library.
4339
- rid: linux-x64
4440
os: ubuntu-24.04
4541
- rid: linux-arm64
@@ -61,40 +57,83 @@ jobs:
6157
libgl1-mesa-dev libglu1-mesa-dev libwayland-dev libxkbcommon-dev
6258
6359
- name: Configure
64-
shell: bash
65-
run: |
66-
if [ "${{ matrix.rid }}" = "osx" ]; then
67-
cmake -S native -B build -DCMAKE_BUILD_TYPE=Release \
68-
-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64"
69-
elif [ "${{ runner.os }}" = "Windows" ]; then
70-
cmake -S native -B build ${{ matrix.generator }}
71-
else
72-
cmake -S native -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
73-
fi
60+
if: matrix.rid != 'osx'
61+
run: cmake -S native -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
7462

7563
- name: Build
64+
if: matrix.rid != 'osx'
7665
run: cmake --build build --config Release
7766

67+
# macOS draws with AppKit and Core Text rather than raylib and ImGui, so it is a Swift
68+
# package rather than a CMake project. Both --arch flags in one invocation produce a
69+
# universal binary, so there is no separate lipo step.
70+
#
71+
# Nothing of the Swift runtime is shipped: it has been part of macOS since 10.14.4, which is
72+
# why this dylib is a fraction of the size of the one it replaced.
73+
- name: Build
74+
if: matrix.rid == 'osx'
75+
run: swift build -c release --arch arm64 --arch x86_64 --package-path native/swift
76+
7877
- name: Collect
7978
shell: bash
8079
run: |
80+
# Laid out as src/{head}/runtimes/{rid}/native, so the propose job below can merge every
81+
# artifact straight into src and the binaries land in the head that loads them.
8182
collect() {
82-
mkdir -p "artifacts/$1/native"
83-
cp "$2" "artifacts/$1/native/"
83+
mkdir -p "artifacts/$1/runtimes/$2/native"
84+
cp "$3" "artifacts/$1/runtimes/$2/native/"
8485
}
8586
case "${{ matrix.rid }}" in
86-
win-*)
87-
collect "${{ matrix.rid }}" build/Release/diffengine_viewer.dll
88-
;;
8987
linux-*)
9088
strip build/libdiffengine_viewer.so
91-
collect "${{ matrix.rid }}" build/libdiffengine_viewer.so
89+
collect DiffEngineViewer.Linux "${{ matrix.rid }}" build/libdiffengine_viewer.so
9290
;;
9391
osx)
94-
strip -x build/libdiffengine_viewer.dylib
92+
# swift build leaves a per architecture dylib in more than one place, so taking the
93+
# first one found gives a single slice binary that loads on half the Macs in the
94+
# world. Every candidate is checked, and if none is already universal they are
95+
# merged, so the only thing that can be collected is a fat binary.
96+
# The dSYM contains a DWARF file of the same name which is also universal, so the
97+
# arch check below would happily accept it and ship debug symbols as the library.
98+
candidates=$(find native/swift/.build -name libdiffengine_viewer.dylib -not -path '*.dSYM/*')
99+
if [ -z "$candidates" ]; then
100+
echo "::error::swift build produced no libdiffengine_viewer.dylib"
101+
exit 1
102+
fi
103+
104+
echo "$candidates" | while read -r candidate; do
105+
echo "$candidate: $(lipo -archs "$candidate" 2>/dev/null)"
106+
done
107+
108+
dylib=""
109+
for candidate in $candidates; do
110+
archs=$(lipo -archs "$candidate" 2>/dev/null || echo "")
111+
if [[ "$archs" == *x86_64* && "$archs" == *arm64* ]]; then
112+
dylib="$candidate"
113+
break
114+
fi
115+
done
116+
117+
if [ -z "$dylib" ]; then
118+
dylib=universal/libdiffengine_viewer.dylib
119+
mkdir -p universal
120+
# shellcheck disable=SC2086
121+
lipo -create $candidates -output "$dylib"
122+
fi
123+
124+
archs=$(lipo -archs "$dylib")
125+
echo "collecting $dylib: $archs"
126+
for arch in x86_64 arm64; do
127+
case " $archs " in
128+
*" $arch "*) ;;
129+
*) echo "::error::$dylib is missing the $arch slice"; exit 1 ;;
130+
esac
131+
done
132+
133+
strip -x "$dylib"
95134
# The dylib is universal, so both macOS RIDs get the same file.
96-
collect osx-x64 build/libdiffengine_viewer.dylib
97-
collect osx-arm64 build/libdiffengine_viewer.dylib
135+
collect DiffEngineViewer.Mac osx-x64 "$dylib"
136+
collect DiffEngineViewer.Mac osx-arm64 "$dylib"
98137
;;
99138
esac
100139
ls -lhR artifacts
@@ -133,11 +172,11 @@ jobs:
133172
with:
134173
pattern: native-*
135174
merge-multiple: true
136-
path: src/DiffEngineViewer/runtimes
175+
path: src
137176

138177
- name: Show what changed
139178
run: |
140-
ls -lhR src/DiffEngineViewer/runtimes
179+
ls -lhR src/DiffEngineViewer.Linux/runtimes src/DiffEngineViewer.Mac/runtimes
141180
git status --short
142181
143182
# A PR rather than a direct push: these are binaries, so the diff is not reviewable and the
@@ -151,7 +190,10 @@ jobs:
151190
title: 'Rebuild native renderer binaries'
152191
commit-message: 'Rebuild native renderer binaries'
153192
body: |
154-
Rebuilt `diffengine_viewer` from `native/` for all six RIDs.
193+
Rebuilt `diffengine_viewer` from `native/` for the four RIDs that load one.
194+
Windows is not among them: that head renders with WinForms.
155195
156196
Produced by the `build-native` workflow from ${{ github.sha }}.
157-
add-paths: src/DiffEngineViewer/runtimes
197+
add-paths: |
198+
src/DiffEngineViewer.Linux/runtimes
199+
src/DiffEngineViewer.Mac/runtimes

.github/workflows/publish-nuget.yml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,18 @@ jobs:
5959
shell: bash
6060
run: |
6161
missing=0
62-
for rid in win-x64 win-arm64 linux-x64 linux-arm64 osx-x64 osx-arm64; do
63-
directory="src/DiffEngineViewer/runtimes/$rid/native"
62+
check() {
63+
directory="src/$1/runtimes/$2/native"
6464
if [ -z "$(ls -A "$directory" 2>/dev/null)" ]; then
65-
echo "::error::No native renderer for $rid. Run the build-native workflow."
65+
echo "::error::No native renderer for $2. Run the build-native workflow."
6666
missing=1
6767
fi
68-
done
68+
}
69+
# No Windows RIDs: that head renders with WinForms and loads no native library.
70+
check DiffEngineViewer.Linux linux-x64
71+
check DiffEngineViewer.Linux linux-arm64
72+
check DiffEngineViewer.Mac osx-x64
73+
check DiffEngineViewer.Mac osx-arm64
6974
exit $missing
7075
7176
# Enumerated rather than passed as a glob. This job runs on Windows, where the shell is pwsh

.github/workflows/test.yml

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ jobs:
6666
strategy:
6767
fail-fast: false
6868
matrix:
69-
os: [ubuntu-latest, macos-latest]
69+
# macOS is pinned rather than latest, because it carries committed pixel baselines and
70+
# Core Text rasterisation moves between OS versions. Same image build-native uses.
71+
os: [ubuntu-latest, macos-14]
7072
steps:
7173
- name: Checkout
7274
uses: actions/checkout@v4
@@ -104,8 +106,8 @@ jobs:
104106
run: |
105107
cmake -S native -B native/build/linux-x64 -G Ninja -DCMAKE_BUILD_TYPE=Release
106108
cmake --build native/build/linux-x64
107-
mkdir -p src/DiffEngineViewer/runtimes/linux-x64/native
108-
cp native/build/linux-x64/libdiffengine_viewer.so src/DiffEngineViewer/runtimes/linux-x64/native/
109+
mkdir -p src/DiffEngineViewer.Linux/runtimes/linux-x64/native
110+
cp native/build/linux-x64/libdiffengine_viewer.so src/DiffEngineViewer.Linux/runtimes/linux-x64/native/
109111
110112
# Release-NotWindows drops the WinForms tray and its tests from the solution.
111113
- name: Build
@@ -130,6 +132,17 @@ jobs:
130132
dotnet test src/DiffEngineViewer.Tests/DiffEngineViewer.Tests.csproj
131133
--configuration Release --no-build --no-restore
132134
135+
# No xvfb and no GL flags: deview_capture on this platform draws into a bitmap context of its
136+
# own making, so it needs neither a window nor a window server. Determinism is pinned inside
137+
# that call rather than by the environment.
138+
- name: Pixel snapshots
139+
if: runner.os == 'macOS'
140+
env:
141+
DIFFENGINE_VIEWER_PIXEL_TESTS: 'true'
142+
run: >
143+
dotnet test src/DiffEngineViewer.Tests/DiffEngineViewer.Tests.csproj
144+
--configuration Release --no-build --no-restore
145+
133146
- name: Upload received on failure
134147
if: failure()
135148
uses: actions/upload-artifact@v4
@@ -139,10 +152,10 @@ jobs:
139152
if-no-files-found: ignore
140153
retention-days: 14
141154

142-
# The jobs above only ever load the x64 natives: the Linux one rebuilds its own from source, and
143-
# Windows never P/Invokes because the pixel tests are Linux gated. This job exists so the other
144-
# four committed binaries are actually loaded somewhere, which is what catches a wrong
145-
# architecture, a file corrupted by a text mode checkout, or an unsatisfied runtime dependency.
155+
# The jobs above only ever load the x64 natives, and the Linux one rebuilds its own from source.
156+
# This job exists so the arm64 binaries are actually loaded somewhere, which is what catches a
157+
# wrong architecture, a file corrupted by a text mode checkout, or an unsatisfied runtime
158+
# dependency.
146159
#
147160
# It runs DiffEngineViewer.Tests rather than the whole suite: that is where the native smoke test
148161
# lives, it takes seconds, and it gives the screen and IPC tests some cross architecture coverage
@@ -159,12 +172,12 @@ jobs:
159172
# targeting one now sits queued indefinitely. Both macOS RIDs ship the same universal
160173
# dylib, so loading it here covers the arm64 slice and the step below checks that the
161174
# x86_64 slice is present.
175+
#
176+
# No Windows entry either, since that head renders with WinForms and loads nothing.
162177
- rid: osx-arm64
163178
os: macos-14
164179
- rid: linux-arm64
165180
os: ubuntu-24.04-arm
166-
- rid: win-arm64
167-
os: windows-11-arm
168181
steps:
169182
- name: Checkout
170183
uses: actions/checkout@v4
@@ -189,7 +202,7 @@ jobs:
189202
if: matrix.rid == 'osx-arm64'
190203
run: |
191204
for rid in osx-arm64 osx-x64; do
192-
dylib="src/DiffEngineViewer/runtimes/$rid/native/libdiffengine_viewer.dylib"
205+
dylib="src/DiffEngineViewer.Mac/runtimes/$rid/native/libdiffengine_viewer.dylib"
193206
archs=$(lipo -archs "$dylib")
194207
echo "$rid: $archs"
195208
for arch in x86_64 arm64; do

claude.md

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,30 +46,40 @@ DiffEngine is a library that manages launching and cleanup of diff tools for sna
4646
- `ResolvedTool` - A diff tool that was found on the system with its resolved executable path.
4747
- `BuildServerDetector` - Detects CI/build server environments to disable diff tool launching.
4848

49-
**DiffEngineViewer (`src/DiffEngineViewer/`):**
50-
- Cross platform GUI diff tool: Dear ImGui rendered through raylib. Reviews inline snapshots and
51-
plain two-file diffs.
49+
**DiffEngineViewer (`src/DiffEngineViewer/` plus three heads):**
50+
- Cross platform GUI diff tool. Reviews inline snapshots and plain two-file diffs.
51+
- `src/DiffEngineViewer/` is a **library** (`DiffEngineViewer.Core.dll`) holding everything that is
52+
not a renderer. `src/DiffEngineViewer.{Windows,Mac,Linux}/` are thin `Exe` heads, one package
53+
each, all named `DiffEngineViewer` so the launcher can resolve the executable by name.
54+
- One package per OS rather than one portable one, because WinForms must be named as a framework
55+
dependency and such a package cannot start on macOS or Linux.
5256
- Bundled inside DiffEngine.nupkg under `tools/viewer/{rid}/`, so inline snapshots work with no
53-
extra install. Also shipped standalone as the `DiffEngineViewer` dotnet tool.
57+
extra install. `DiffEngine.csproj` maps each RID to the head that renders on it.
5458
- `ViewerSession` is a pure state machine over an immutable `SessionState`. `ScreenBuilder`
5559
projects that into a `Screen` (already sliced to the visible rows), which `AsciiRenderer` draws
56-
as text and the native shim draws as pixels. Both renderers consume the identical structure,
57-
which is what makes the text snapshots meaningful.
60+
as text and each `IViewerWindow` draws as pixels. Every renderer consumes the identical
61+
structure, which is what makes the text snapshots meaningful and keeps three renderers honest.
62+
- `ViewerProgram.Run(args, OpenWindow)` owns the loop for all heads. A head is a `Main` that
63+
chooses a renderer; nothing else about the app is per platform.
64+
- Windows renders with **WinForms** and loads no native library. It is pumped through
65+
`Application.DoEvents` rather than `Application.Run`, so the shared loop stays shared.
66+
- macOS renders with **AppKit and Core Text** (`native/swift/`), Linux with **raylib and Dear
67+
ImGui** (`native/`). Both implement the same C ABI, so the managed interop layer is identical.
5868
- Does **not** reference DiffEngine. It links `Inline/*.cs` and `Tray/TrayDetector.cs` as source,
59-
because DiffEngine publishes and embeds the viewer and a reference back would be a cycle.
69+
because DiffEngine publishes and embeds the heads and a reference back would be a cycle.
6070
- Single instance by socket bind on 3493 (`DiffEngine_ViewerPort`): whoever binds owns the window,
6171
and a process that fails to bind forwards its patch and exits.
6272

63-
**Native shim (`native/`):**
73+
**Native shim (`native/`), used by the Mac and Linux heads only:**
6474
- `raylib` and `imgui` are fetched by CMake (`FetchContent`), pinned by tag in
6575
`native/CMakeLists.txt`. Deliberately not submodules: nothing in a normal `dotnet build` touches
6676
this folder, so a recursive clone on every checkout would serve a path almost nobody takes.
6777
- Building it needs CMake 3.24+, a C++17 compiler and network access. Contributors do not need
6878
any of that, because the binaries are committed.
69-
- `native/src/deview.cpp` is a renderer for the `Screen` model, not an ImGui binding: ~12 exports
79+
- `native/src/deview.cpp` is a renderer for the `Screen` model, not an ImGui binding: eight exports
7080
taking one flat blittable frame description. The ABI is `native/include/deview.h`; bump
71-
`DEVIEW_VERSION` whenever the structs change.
72-
- Built binaries are **committed** to `src/DiffEngineViewer/runtimes/{rid}/native/`, so a plain
81+
`DEVIEW_VERSION` whenever the structs change **or a field changes meaning**.
82+
- Built binaries are **committed** to `src/DiffEngineViewer.{Linux,Mac}/runtimes/{rid}/native/`, so a plain
7383
`dotnet build` produces a shippable package and contributors never need CMake. Regenerate them
7484
with the `build-native` GitHub workflow, which opens a PR.
7585

@@ -82,6 +92,16 @@ DiffEngine is a library that manages launching and cleanup of diff tools for sna
8292
every platform rather than a Windows-only copy that can drift.
8393
- Allows accepting/discarding diffs from system tray
8494

95+
**Packaging.Tests (`src/Packaging.Tests/`):**
96+
- Opens each `.nupkg` a Release build drops in `nugets` and snapshots its entry list, plus a few
97+
invariants a snapshot states poorly: an apphost with no assembly beside it, a viewer file in the
98+
tray package, an incomplete bundled head.
99+
- Exists because package content is assembled by several unrelated MSBuild mechanisms and nothing
100+
else asserts the result. The failure mode it was written for is stale build output: `PackAsTool`
101+
packages the publish directory wholesale, and MSBuild never removes a file that stopped being
102+
produced, so anything a discarded experiment left in `bin` keeps shipping.
103+
- Windows only, and skipped entirely when no packages were produced, which is every Debug build.
104+
85105
### Adding a New Diff Tool
86106

87107
1. Add enum value to `DiffTool.cs`

docs/diff-tool.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,8 +284,8 @@ DiffTools.UseOrder(DiffTool.DiffEngineViewer);
284284
#### Notes:
285285

286286
* Bundled inside the DiffEngine package, so it needs no install
287-
* Also available standalone via `dotnet tool install -g DiffEngineViewer`
288-
* Cross platform: Windows, macOS and Linux
287+
* Also available standalone as `DiffEngineViewer.Windows`, `.Mac` or `.Linux`
288+
* Cross platform: WinForms on Windows, Dear ImGui through raylib elsewhere
289289

290290
#### Windows settings:
291291

docs/mdsource/viewer.source.md

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,34 @@ source file.
88
Unlike every other entry in the [tool list](/docs/diff-tool.md), it does not need to be installed.
99
A copy ships inside the DiffEngine package, so it is always present.
1010

11-
The UI is [Dear ImGui](https://github.com/ocornut/imgui) rendered through
12-
[raylib](https://github.com/raysan5/raylib).
11+
The renderer is native to each platform:
12+
13+
| Platform | Renderer |
14+
| --- | --- |
15+
| Windows | WinForms |
16+
| macOS | AppKit and Core Text |
17+
| Linux | [Dear ImGui](https://github.com/ocornut/imgui) through [raylib](https://github.com/raysan5/raylib) |
18+
19+
All three draw the same screen model, and the layout, scrolling and keyboard handling are shared,
20+
so the only difference is how the pixels get there.
1321

1422

1523
## NuGet
1624

17-
* https://www.nuget.org/packages/DiffEngineViewer
25+
* https://www.nuget.org/packages/DiffEngineViewer.Windows
26+
* https://www.nuget.org/packages/DiffEngineViewer.Mac
27+
* https://www.nuget.org/packages/DiffEngineViewer.Linux
1828

1929
Only needed to use the viewer outside a project that references DiffEngine, since DiffEngine
2030
already bundles it.
2131

22-
`dotnet tool install -g DiffEngineViewer`
32+
```
33+
dotnet tool install -g DiffEngineViewer.Windows
34+
```
35+
36+
One package per operating system rather than one for all of them, because WinForms has to be named
37+
as a framework dependency and a package that names it cannot start anywhere else. The copy bundled
38+
in DiffEngine is unaffected: it is published per RID and resolved by directory.
2339

2440

2541
## Usage
@@ -80,5 +96,5 @@ continuous testing and AI CLIs.
8096
## Platforms
8197

8298
Ships for `win-x64`, `win-arm64`, `linux-x64`, `linux-arm64`, `osx-x64` and `osx-arm64`. On a
83-
platform with no matching binary, resolution falls through to a globally installed
99+
platform with no matching build, resolution falls through to a globally installed
84100
DiffEngineViewer tool, and then to whatever other diff tool is available.

0 commit comments

Comments
 (0)